mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-18 13:25:45 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67d314b089 | |||
| 22eddbc474 |
@@ -112,14 +112,9 @@ secret: don't paste it into shared logs, PRs, or commit it anywhere.
|
||||
|
||||
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
|
||||
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
|
||||
3. If repo-root `.env` exists and `web-seed` fails, do **not** seed or modify the current DB; treat it as an existing local environment and use Cookie injection.
|
||||
4. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
|
||||
5. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:` → **Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
|
||||
6. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
|
||||
`ENABLE_MOCK_DEV_USER` is not Web auth. It only affects server-side API context
|
||||
and does not satisfy Better Auth or stop the SPA from redirecting to `/signin`.
|
||||
Do not use it as a substitute for `status --surface web` or Cookie injection.
|
||||
3. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
|
||||
4. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:` → **Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
|
||||
5. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
|
||||
### Using the authenticated session
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ SERVER_URL="${SERVER_URL:-$(default_server_url)}"
|
||||
SESSION="${SESSION:-lobehub-dev}"
|
||||
AUTH_DIR="${AUTH_DIR:-$HOME/.lobehub-agent-testing}"
|
||||
STATE_FILE="$AUTH_DIR/web-state.json"
|
||||
ROOT_ENV_FILE="$REPO_ROOT/.env"
|
||||
CLI_HOME_NAME="${LOBEHUB_CLI_HOME:-.lobehub-dev}"
|
||||
CLI_HOME="$HOME/${CLI_HOME_NAME#/}"
|
||||
CLI_CREDENTIALS_FILE="$CLI_HOME/credentials.json"
|
||||
@@ -482,13 +481,8 @@ PY
|
||||
|
||||
if [[ ! "$code" =~ ^[23] ]]; then
|
||||
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
|
||||
if [[ -f "$ROOT_ENV_FILE" ]]; then
|
||||
note "root .env exists; do not seed or modify this DB for Web auth."
|
||||
note "Use Chrome Cookie injection instead: $0 open-chrome, then pbpaste | $0 web"
|
||||
else
|
||||
note "make sure the seed user exists:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
fi
|
||||
note "make sure the seed user exists:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -523,7 +517,6 @@ cmd_web_verify() {
|
||||
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
|
||||
return 1
|
||||
fi
|
||||
agent-browser --session "$SESSION" wait --load networkidle > /dev/null 2>&1 || true
|
||||
local url
|
||||
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
|
||||
if [[ -z "$url" ]]; then
|
||||
|
||||
@@ -46,13 +46,6 @@ Every data surface has **four** states — design all of them, not just "has dat
|
||||
over skeleton rows or a blank body. _(Meaningful)_
|
||||
- [ ] **Distinguish the empty variants** — "no data yet" (onboarding CTA) vs
|
||||
"no match for filters" (clear-filters affordance) are different screens. _(Certainty)_
|
||||
- [ ] **Always-rendered chrome still needs a body empty state.** When a surface
|
||||
keeps its toolbar / header mounted even with no data (so a create / `+`
|
||||
affordance stays reachable), the **body** below it must still render an empty
|
||||
placeholder — persistent chrome is not an excuse to leave the content area
|
||||
blank. ✅ The agent **Documents** tab keeps its new-folder / new-doc toolbar
|
||||
and renders an `Empty` below it when there are no documents — ❌ not a toolbar
|
||||
over dead space. _(Meaningful)_
|
||||
- [ ] **Loading state** designed (skeleton / NeuralNetworkLoading), not a flash of
|
||||
blank or layout shift. _(Natural)_
|
||||
- [ ] **Error state** designed — surface the reason and a retry/back path. _(Meaningful)_
|
||||
@@ -103,33 +96,6 @@ the selection is restored rather than freshly clicked.
|
||||
sidebar list, so the move picker re-adds it. An empty picker must mean
|
||||
"genuinely none", never "we filtered out the only option". _(Meaningful)_
|
||||
|
||||
### 1.5 Default view reflects entry intent & data state・Certainty・Meaningful
|
||||
|
||||
A surface with multiple tabs / views / panels has a **landing** selection. Don't
|
||||
hardcode it to "the first tab" — derive it from **(a) how the user got here** (the
|
||||
intent their navigation carried) and **(b) which views actually have data**. A
|
||||
static default that lands the user on an empty tab while a sibling holds exactly
|
||||
what they came for reads as broken. This pairs with §1.1: the empty state is the
|
||||
fallback _within_ a view; this rule is about not landing on that empty view in the
|
||||
first place when a better one exists.
|
||||
|
||||
- [ ] **Open on the tab the entry implies.** When navigation carries intent — the
|
||||
user clicked a Skill, a file, a record of a specific type — land on the view
|
||||
that shows it, not the static first tab. ✅ Opening a document page by clicking
|
||||
a **skill** lands the right panel on the **Skills** tab; opening a plain
|
||||
document lands on **Documents**. _(Meaningful)_
|
||||
- [ ] **Fall back to a populated view when the default would be empty.** If the
|
||||
default tab has no data but a sibling does, default to the populated one so
|
||||
the surface opens on content. ✅ An agent with only skills (no documents)
|
||||
opens the panel on **Skills** instead of an empty **Documents** tab. _(Certainty)_
|
||||
- [ ] **Decide from resolved state, not mid-load.** Compute the default once the
|
||||
data has loaded — choosing off an empty _in-flight_ list flips the tab as data
|
||||
arrives. Hold the static default while loading, switch on resolved-empty. _(Certainty)_
|
||||
- [ ] **A manual choice wins and sticks.** Once the user picks a tab, stop
|
||||
auto-selecting — track "user-picked" separately (e.g. a nullable `pickedTab`
|
||||
that overrides the derived default) so later data changes don't yank them off
|
||||
their choice. _(Natural)_
|
||||
|
||||
---
|
||||
|
||||
## 2. Edit — entering & changing content
|
||||
@@ -284,11 +250,10 @@ The product should grow with the user — deeper power shows up as needs deepen.
|
||||
|
||||
**Read — viewing data & lists**
|
||||
|
||||
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA. Always-rendered chrome (toolbar/header) still gets a body empty state.
|
||||
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA.
|
||||
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
|
||||
- [ ] Capped/scrollable/virtualized list scrolls the restored active item into view on mount (`block: 'nearest'`, re-run after async rows mount).
|
||||
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
|
||||
- [ ] Multi-tab/view surface lands on the tab the entry intent implies (and falls back to a populated view, decided from resolved state); a manual pick sticks.
|
||||
|
||||
**Edit — entering & changing content**
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -80,40 +77,6 @@ describe('lh file - E2E', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── upload (local file) ───────────────────────────────
|
||||
|
||||
describe('upload', () => {
|
||||
it('should upload a local file passed as a positional argument', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello from lh e2e upload');
|
||||
|
||||
try {
|
||||
const result = runJson<{ id: string }>(`file upload ${tmpFile} --json id`);
|
||||
expect(result).toHaveProperty('id');
|
||||
if (result.id) run(`file delete ${result.id} --yes`);
|
||||
} finally {
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should upload a local file passed via --file', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-f-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello from lh e2e --file upload');
|
||||
|
||||
try {
|
||||
const result = runJson<{ id: string }>(`file upload --file ${tmpFile} --json id`);
|
||||
expect(result).toHaveProperty('id');
|
||||
if (result.id) run(`file delete ${result.id} --yes`);
|
||||
} finally {
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should error when the local file does not exist', () => {
|
||||
expect(() => run('file upload -f /no/such/lh-file.txt')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── recent ────────────────────────────────────────────
|
||||
|
||||
describe('recent', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.31" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.29" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.31",
|
||||
"version": "0.0.29",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -21,9 +17,6 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
removeFiles: { mutate: vi.fn() },
|
||||
updateFile: { mutate: vi.fn() },
|
||||
},
|
||||
upload: {
|
||||
createS3PreSignedUrl: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -45,11 +38,9 @@ describe('file command', () => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const group of [mockTrpcClient.file, mockTrpcClient.upload]) {
|
||||
for (const method of Object.values(group)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.file)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -214,111 +205,6 @@ describe('file command', () => {
|
||||
expect(mockTrpcClient.file.createFile.mutate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
|
||||
});
|
||||
|
||||
it('should upload a local file passed as a positional argument', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-${process.pid}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello world');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
|
||||
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({
|
||||
id: 'f-local',
|
||||
url: 'files/x.txt',
|
||||
});
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
|
||||
|
||||
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://s3/presigned',
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
);
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileType: 'text/plain',
|
||||
name: path.basename(tmpFile),
|
||||
url: expect.stringContaining('.txt'),
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('File created'));
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should upload a local file passed via --file', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-f-${process.pid}.json`);
|
||||
fs.writeFileSync(tmpFile, '{}');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
|
||||
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-json' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', '--file', tmpFile]);
|
||||
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileType: 'application/json' }),
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should skip the S3 upload when the local file hash already exists', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-dedup-${process.pid}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'dedup me');
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({
|
||||
isExist: true,
|
||||
url: 'files/2024-01-01/existing.txt',
|
||||
});
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-dedup' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
|
||||
|
||||
// No pre-sign and no S3 PUT should happen
|
||||
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
// The record reuses the existing url
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: 'files/2024-01-01/existing.txt' }),
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should error when local file does not exist', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', '-f', '/no/such/file.txt']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('File not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should error when no source is provided', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Provide a local file path'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import pc from 'picocolors';
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { uploadLocalFile } from '../utils/uploadLocalFile';
|
||||
|
||||
export function registerFileCommand(program: Command) {
|
||||
const file = program.command('file').description('Manage files');
|
||||
@@ -114,20 +113,18 @@ export function registerFileCommand(program: Command) {
|
||||
// ── upload ───────────────────────────────────────────
|
||||
|
||||
file
|
||||
.command('upload [source]')
|
||||
.description('Upload a file from a local path or a URL')
|
||||
.option('-f, --file <path>', 'Local file path to upload')
|
||||
.option('--hash <hash>', 'File hash for deduplication check (URL mode)')
|
||||
.option('--name <name>', 'File name (URL mode)')
|
||||
.option('--type <type>', 'File MIME type (URL mode)')
|
||||
.option('--size <size>', 'File size in bytes (URL mode)')
|
||||
.command('upload <url>')
|
||||
.description('Upload a file by URL (checks hash first)')
|
||||
.option('--hash <hash>', 'File hash for deduplication check')
|
||||
.option('--name <name>', 'File name')
|
||||
.option('--type <type>', 'File MIME type')
|
||||
.option('--size <size>', 'File size in bytes')
|
||||
.option('--parent-id <id>', 'Parent folder ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
source: string | undefined,
|
||||
url: string,
|
||||
options: {
|
||||
file?: string;
|
||||
hash?: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
@@ -136,47 +133,8 @@ export function registerFileCommand(program: Command) {
|
||||
type?: string;
|
||||
},
|
||||
) => {
|
||||
const isUrl = (value: string) =>
|
||||
value.startsWith('http://') || value.startsWith('https://');
|
||||
|
||||
// Resolve the local file path: explicit --file, or a positional that is
|
||||
// not a URL (e.g. `lh file upload ./games_list.txt`).
|
||||
const localPath = options.file ?? (source && !isUrl(source) ? source : undefined);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// ── Local file upload ──
|
||||
if (localPath) {
|
||||
let result;
|
||||
try {
|
||||
result = await uploadLocalFile(client, localPath, { parentId: options.parentId });
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} File created: ${pc.bold(r.id || '')}`);
|
||||
if (r.url) console.log(` URL: ${pc.dim(r.url)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── URL upload ──
|
||||
if (!source) {
|
||||
log.error('Provide a local file path, --file <path>, or a URL to upload.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = source;
|
||||
|
||||
// Check hash first if provided
|
||||
if (options.hash) {
|
||||
const check = await client.file.checkFileHash.mutate({ hash: options.hash });
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -10,9 +6,6 @@ import { registerGenerateCommand } from './generate';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
asr: {
|
||||
transcribe: { mutate: vi.fn() },
|
||||
},
|
||||
generation: {
|
||||
deleteGeneration: { mutate: vi.fn() },
|
||||
getGenerationStatus: { query: vi.fn() },
|
||||
@@ -42,15 +35,6 @@ const { writeFileSync: mockWriteFileSync } = vi.hoisted(() => ({
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const { uploadLocalFile: mockUploadLocalFile } = vi.hoisted(() => ({
|
||||
uploadLocalFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/uploadLocalFile', async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return { ...actual, uploadLocalFile: mockUploadLocalFile };
|
||||
});
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -385,130 +369,6 @@ describe('generate command', () => {
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should upload large local audio and transcribe by fileId', async () => {
|
||||
// Real >3MB temp file so existsSync/statSync (unmocked) see it as large.
|
||||
const bigPath = path.join(os.tmpdir(), `lh-asr-test-${process.pid}-${Date.now()}.mp3`);
|
||||
await fsWriteFile(bigPath, Buffer.alloc(4 * 1024 * 1024));
|
||||
mockUploadLocalFile.mockResolvedValue({ id: 'file_999' });
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'big result' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'generate', 'asr', bigPath]);
|
||||
|
||||
expect(mockUploadLocalFile).toHaveBeenCalledWith(expect.anything(), bigPath);
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileId: 'file_999', model: 'whisper-1', provider: 'openai' }),
|
||||
);
|
||||
// never inlines bytes for the large file
|
||||
expect(mockTrpcClient.asr.transcribe.mutate.mock.calls[0][0]).not.toHaveProperty(
|
||||
'audioBase64',
|
||||
);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('big result');
|
||||
} finally {
|
||||
await fsRm(bigPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should download and transcribe an audio URL', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers(),
|
||||
ok: true,
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'hello world' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/audio/sample.mp3',
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://example.com/audio/sample.mp3');
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audioBase64: Buffer.from('audio-bytes').toString('base64'),
|
||||
fileName: 'sample.mp3',
|
||||
model: 'whisper-1',
|
||||
provider: 'openai',
|
||||
}),
|
||||
);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('hello world');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should derive an extension and mime type from Content-Type when the URL has none', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers({ 'content-type': 'audio/mpeg; charset=binary' }),
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'generate', 'asr', 'https://example.com/download']);
|
||||
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: 'download.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer the filename from Content-Disposition', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers({
|
||||
'content-disposition': 'attachment; filename="recording.wav"',
|
||||
}),
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/files/abc123?sig=xyz',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileName: 'recording.wav' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit when audio URL download fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }),
|
||||
);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/missing.mp3',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to download audio'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { getAuthInfo } from '../../api/http';
|
||||
import { log } from '../../utils/logger';
|
||||
import { uploadLocalFile } from '../../utils/uploadLocalFile';
|
||||
|
||||
// Audio at or below this size is sent inline as base64; anything larger is
|
||||
// uploaded first and transcribed by `fileId`. Kept in sync with the server-side
|
||||
// inline cap in `apps/server/src/routers/lambda/asr.ts`.
|
||||
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
export function registerAsrCommand(parent: Command) {
|
||||
parent
|
||||
.command('asr <audio-file>')
|
||||
.description(
|
||||
'Convert speech to text (automatic speech recognition). Accepts a local path or a URL',
|
||||
)
|
||||
.description('Convert speech to text (automatic speech recognition)')
|
||||
.option('--model <model>', 'STT model', 'whisper-1')
|
||||
.option('--provider <provider>', 'AI provider', 'openai')
|
||||
.option('--language <lang>', 'Language code (e.g. en, zh)')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(
|
||||
@@ -31,175 +20,58 @@ export function registerAsrCommand(parent: Command) {
|
||||
json?: boolean;
|
||||
language?: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
},
|
||||
) => {
|
||||
const isUrl = audioFile.startsWith('http://') || audioFile.startsWith('https://');
|
||||
|
||||
if (!isUrl && !existsSync(audioFile)) {
|
||||
if (!existsSync(audioFile)) {
|
||||
log.error(`File not found: ${audioFile}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the input to a local file path (downloading URLs to a temp
|
||||
// file) so large audio can reuse the shared upload flow.
|
||||
let localPath: string;
|
||||
let fileName: string;
|
||||
let mimeType: string | undefined;
|
||||
let size: number;
|
||||
let tempPath: string | undefined;
|
||||
try {
|
||||
if (isUrl) {
|
||||
const downloaded = await fetchAudioFromUrl(audioFile);
|
||||
fileName = downloaded.name;
|
||||
mimeType = downloaded.mimeType;
|
||||
size = downloaded.bytes.byteLength;
|
||||
tempPath = path.join(os.tmpdir(), `lh-asr-${process.pid}-${Date.now()}-${fileName}`);
|
||||
await writeFile(tempPath, downloaded.bytes);
|
||||
localPath = tempPath;
|
||||
} else {
|
||||
localPath = audioFile;
|
||||
fileName = path.basename(audioFile);
|
||||
size = statSync(audioFile).size;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
|
||||
const sttOptions: Record<string, any> = { model: options.model };
|
||||
if (options.language) sttOptions.language = options.language;
|
||||
|
||||
const formData = new FormData();
|
||||
const fileBuffer = await readFileAsBlob(audioFile);
|
||||
formData.append('speech', fileBuffer, path.basename(audioFile));
|
||||
formData.append('options', JSON.stringify(sttOptions));
|
||||
|
||||
// Remove Content-Type for multipart/form-data (let fetch set it with boundary)
|
||||
const { 'Content-Type': _, ...formHeaders } = headers;
|
||||
|
||||
const res = await fetch(`${serverUrl}/webapi/stt/openai`, {
|
||||
body: formData,
|
||||
headers: formHeaders,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
log.error(`ASR failed: ${res.status} ${errText}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await getTrpcClient();
|
||||
const result = await res.json();
|
||||
|
||||
let result: { text: string };
|
||||
if (size > MAX_INLINE_AUDIO_BYTES) {
|
||||
// Large audio: upload to storage, then transcribe by fileId so the
|
||||
// bytes never travel inline through tRPC.
|
||||
process.stderr.write(
|
||||
`Audio is ${(size / 1024 / 1024).toFixed(1)}MB — uploading before transcription…\n`,
|
||||
);
|
||||
const record = (await uploadLocalFile(client, localPath)) as { id: string };
|
||||
result = await client.asr.transcribe.mutate({
|
||||
fileId: record.id,
|
||||
language: options.language,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
});
|
||||
} else {
|
||||
const bytes = await readFile(localPath);
|
||||
result = await client.asr.transcribe.mutate({
|
||||
audioBase64: Buffer.from(bytes).toString('base64'),
|
||||
fileName,
|
||||
language: options.language,
|
||||
mimeType,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
process.stdout.write(result.text);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`ASR failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (tempPath) {
|
||||
await rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
const text = (result as any).text || JSON.stringify(result);
|
||||
process.stdout.write(text);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Common audio MIME types mapped to a file extension the transcription
|
||||
// provider can recognize. Keep the extensions within the set OpenAI's
|
||||
// /audio/transcriptions endpoint accepts.
|
||||
const AUDIO_MIME_TO_EXT: Record<string, string> = {
|
||||
'audio/aac': 'aac',
|
||||
'audio/flac': 'flac',
|
||||
'audio/m4a': 'm4a',
|
||||
'audio/mp3': 'mp3',
|
||||
'audio/mp4': 'm4a',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/mpga': 'mp3',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/opus': 'ogg',
|
||||
'audio/wav': 'wav',
|
||||
'audio/wave': 'wav',
|
||||
'audio/webm': 'webm',
|
||||
'audio/x-m4a': 'm4a',
|
||||
'audio/x-wav': 'wav',
|
||||
};
|
||||
|
||||
async function fetchAudioFromUrl(
|
||||
url: string,
|
||||
): Promise<{ bytes: Uint8Array; mimeType?: string; name: string }> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download audio: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
|
||||
// Strip any parameters from the Content-Type (e.g. `audio/mpeg; charset=...`).
|
||||
const contentType = res.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
|
||||
const mimeType = contentType?.startsWith('audio/') ? contentType : undefined;
|
||||
|
||||
// Prefer the name the server advertises, then the URL path, then a fallback.
|
||||
const name =
|
||||
fileNameFromContentDisposition(res.headers.get('content-disposition')) ||
|
||||
basenameFromUrl(url) ||
|
||||
'audio';
|
||||
|
||||
// Transcription providers infer the audio format from the file extension, so
|
||||
// make sure the name carries one. Signed URLs and /download endpoints often
|
||||
// have no extension in the path — in that case borrow it from the
|
||||
// Content-Type when we recognize it.
|
||||
const ext = contentType ? AUDIO_MIME_TO_EXT[contentType] : undefined;
|
||||
const finalName = path.extname(name) || !ext ? name : `${name}.${ext}`;
|
||||
|
||||
return { bytes, mimeType, name: finalName };
|
||||
}
|
||||
|
||||
// Extract a file name from a Content-Disposition header, handling both the
|
||||
// plain `filename="x"` form and the RFC 5987 extended `filename*=UTF-8''x` form.
|
||||
function fileNameFromContentDisposition(header: string | null): string | undefined {
|
||||
if (!header) return undefined;
|
||||
|
||||
// Extended form takes precedence and may be percent-encoded.
|
||||
const extended = /filename\*=\s*(?:UTF-8|ISO-8859-1)?''([^;]+)/i.exec(header);
|
||||
if (extended?.[1]) {
|
||||
try {
|
||||
return path.basename(decodeURIComponent(extended[1].trim()));
|
||||
} catch {
|
||||
// Malformed encoding — fall through to the plain form.
|
||||
}
|
||||
}
|
||||
|
||||
const plain = /filename=\s*"?([^";]+)"?/i.exec(header);
|
||||
const value = plain?.[1]?.trim();
|
||||
return value ? path.basename(value) : undefined;
|
||||
}
|
||||
|
||||
// Derive the (URL-decoded) last path segment of a URL, if any.
|
||||
function basenameFromUrl(url: string): string | undefined {
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(url).pathname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const base = path.basename(pathname);
|
||||
if (!base) return undefined;
|
||||
try {
|
||||
return decodeURIComponent(base);
|
||||
} catch {
|
||||
return base;
|
||||
async function readFileAsBlob(filePath: string): Promise<Blob> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const stream = createReadStream(filePath);
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk as Uint8Array);
|
||||
}
|
||||
return new Blob(chunks);
|
||||
}
|
||||
|
||||
+74
-13
@@ -1,12 +1,14 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getAuthInfo } from '../api/http';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { uploadLocalFile } from '../utils/uploadLocalFile';
|
||||
|
||||
function formatFileType(fileType: string): string {
|
||||
if (!fileType) return '';
|
||||
@@ -322,22 +324,81 @@ export function registerKbCommand(program: Command) {
|
||||
.description('Upload a file to a knowledge base')
|
||||
.option('--parent <parentId>', 'Parent folder ID')
|
||||
.action(async (knowledgeBaseId: string, filePath: string, options: { parent?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await uploadLocalFile(client, filePath, {
|
||||
knowledgeBaseId,
|
||||
parentId: options.parent,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
log.error(`File not found: ${resolved}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolved);
|
||||
const fileName = path.basename(resolved);
|
||||
const fileBuffer = fs.readFileSync(resolved);
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
// Detect MIME type from extension
|
||||
const ext = path.extname(fileName).toLowerCase().slice(1);
|
||||
const mimeMap: Record<string, string> = {
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
gif: 'image/gif',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
json: 'application/json',
|
||||
md: 'text/markdown',
|
||||
mp3: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
svg: 'image/svg+xml',
|
||||
txt: 'text/plain',
|
||||
webp: 'image/webp',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
const fileType = mimeMap[ext] || 'application/octet-stream';
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
|
||||
// 1. Get presigned URL
|
||||
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
const pathname = `files/${date}/${hash}.${ext}`;
|
||||
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
|
||||
|
||||
// 2. Upload to S3
|
||||
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
|
||||
const uploadRes = await fetch(presignedUrl, {
|
||||
body: fileBuffer,
|
||||
headers: { 'Content-Type': fileType },
|
||||
method: 'PUT',
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
log.error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Create file record
|
||||
const result = await client.file.createFile.mutate({
|
||||
fileType,
|
||||
hash,
|
||||
knowledgeBaseId,
|
||||
metadata: {
|
||||
date,
|
||||
dirname: '',
|
||||
filename: fileName,
|
||||
path: pathname,
|
||||
},
|
||||
name: fileName,
|
||||
parentId: options.parent,
|
||||
size: stat.size,
|
||||
url: pathname,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Uploaded ${pc.bold(path.basename(filePath))} → ${pc.bold((result as any).id)}`,
|
||||
`${pc.green('✓')} Uploaded ${pc.bold(fileName)} → ${pc.bold((result as any).id)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { TrpcClient } from '../api/client';
|
||||
|
||||
/**
|
||||
* Minimal extension → MIME map for files uploaded from the local filesystem.
|
||||
* Unknown extensions fall back to `application/octet-stream`.
|
||||
*/
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
aac: 'audio/aac',
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
flac: 'audio/flac',
|
||||
gif: 'image/gif',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
json: 'application/json',
|
||||
m4a: 'audio/mp4',
|
||||
md: 'text/markdown',
|
||||
mp3: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
ogg: 'audio/ogg',
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
svg: 'image/svg+xml',
|
||||
txt: 'text/plain',
|
||||
wav: 'audio/wav',
|
||||
webm: 'audio/webm',
|
||||
webp: 'image/webp',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect a MIME type from a file name's extension.
|
||||
*/
|
||||
export const detectMimeType = (fileName: string): string => {
|
||||
const ext = path.extname(fileName).toLowerCase().slice(1);
|
||||
return MIME_MAP[ext] || 'application/octet-stream';
|
||||
};
|
||||
|
||||
export interface UploadLocalFileOptions {
|
||||
knowledgeBaseId?: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from the local filesystem, upload it to S3 via a pre-signed URL,
|
||||
* and create the corresponding file record. Shared by `file upload` and
|
||||
* `kb upload`.
|
||||
*
|
||||
* @returns the created file record
|
||||
*/
|
||||
export const uploadLocalFile = async (
|
||||
client: TrpcClient,
|
||||
filePath: string,
|
||||
options: UploadLocalFileOptions = {},
|
||||
) => {
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new Error(`File not found: ${resolved}`);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Not a file: ${resolved}`);
|
||||
}
|
||||
|
||||
const fileName = path.basename(resolved);
|
||||
const fileBuffer = fs.readFileSync(resolved);
|
||||
|
||||
// Compute SHA-256 hash for deduplication
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
const ext = path.extname(fileName).toLowerCase().slice(1);
|
||||
const fileType = detectMimeType(fileName);
|
||||
|
||||
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
|
||||
// 1. Dedup: if the same bytes are already stored (and the object still
|
||||
// exists), skip the S3 upload entirely and reuse the existing url.
|
||||
const existing = (await client.file.checkFileHash.mutate({ hash })) as {
|
||||
isExist?: boolean;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
let pathname: string;
|
||||
if (existing?.isExist && existing.url) {
|
||||
pathname = existing.url;
|
||||
} else {
|
||||
// 2. Get a pre-signed upload URL and PUT the bytes to S3
|
||||
pathname = ext ? `files/${date}/${hash}.${ext}` : `files/${date}/${hash}`;
|
||||
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
|
||||
|
||||
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
|
||||
const uploadRes = await fetch(presignedUrl, {
|
||||
body: fileBuffer,
|
||||
headers: { 'Content-Type': fileType },
|
||||
method: 'PUT',
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create the file record
|
||||
return await client.file.createFile.mutate({
|
||||
fileType,
|
||||
hash,
|
||||
knowledgeBaseId: options.knowledgeBaseId,
|
||||
metadata: {
|
||||
date,
|
||||
dirname: '',
|
||||
filename: fileName,
|
||||
path: pathname,
|
||||
},
|
||||
name: fileName,
|
||||
parentId: options.parentId,
|
||||
size: stat.size,
|
||||
url: pathname,
|
||||
});
|
||||
};
|
||||
@@ -17,9 +17,3 @@ packages:
|
||||
- './stubs/business-const'
|
||||
- './stubs/types'
|
||||
- '.'
|
||||
allowBuilds:
|
||||
electron: set this to true or false
|
||||
electron-winstaller: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
get-windows: set this to true or false
|
||||
node-mac-permissions: set this to true or false
|
||||
|
||||
@@ -1249,12 +1249,6 @@ export const createRuntimeExecutors = (
|
||||
},
|
||||
userTimezone: ctx.userTimezone,
|
||||
capabilities: {
|
||||
isCanUseAudio: (m: string, p: string) => {
|
||||
const info =
|
||||
builtinModels.find((item) => item.id === m && item.providerId === p) ??
|
||||
builtinModels.find((item) => item.id === m);
|
||||
return info?.abilities?.audio ?? false;
|
||||
},
|
||||
isCanUseFC: (m: string, p: string) => {
|
||||
const info = builtinModels.find((item) => item.id === m && item.providerId === p);
|
||||
return info?.abilities?.functionCall ?? true;
|
||||
|
||||
@@ -28,11 +28,7 @@ import { ToolsEngine } from '@lobechat/context-engine';
|
||||
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import {
|
||||
executionTargetToRuntimeMode,
|
||||
resolveExecutionTarget,
|
||||
resolveToolMode,
|
||||
} from '@/helpers/executionTarget';
|
||||
import { executionTargetToRuntimeMode, resolveExecutionTarget } from '@/helpers/executionTarget';
|
||||
import {
|
||||
buildAllowedBuiltinTools,
|
||||
DEVICE_TOOL_IDENTIFIERS,
|
||||
@@ -174,7 +170,9 @@ export const createServerAgentToolsEngine = (
|
||||
const isSearchEnabled = searchMode !== 'off';
|
||||
// Tool mode: explicit `toolMode` wins; otherwise derive from `enableAgentMode`
|
||||
// (undefined = agent). `custom` = toolset is exactly the agent's plugins.
|
||||
const toolMode = resolveToolMode(agentConfig.chatConfig ?? undefined);
|
||||
const toolMode: 'agent' | 'chat' | 'custom' =
|
||||
agentConfig.chatConfig?.toolMode ??
|
||||
(agentConfig.chatConfig?.enableAgentMode === false ? 'chat' : 'agent');
|
||||
const isChatMode = toolMode === 'chat';
|
||||
const isCustomMode = toolMode === 'custom';
|
||||
|
||||
|
||||
@@ -83,7 +83,6 @@ export const serverMessagesEngine = async ({
|
||||
const engine = new MessagesEngine({
|
||||
// Capability injection
|
||||
capabilities: {
|
||||
isCanUseAudio: capabilities?.isCanUseAudio,
|
||||
isCanUseFC: capabilities?.isCanUseFC,
|
||||
isCanUseVideo: capabilities?.isCanUseVideo,
|
||||
isCanUseVision: capabilities?.isCanUseVision,
|
||||
|
||||
@@ -23,8 +23,6 @@ import type { RuntimeInitialContext, UIChatMessage } from '@lobechat/types';
|
||||
* Model capability checker functions for server-side
|
||||
*/
|
||||
export interface ServerModelCapabilities {
|
||||
/** Check if audio input is supported */
|
||||
isCanUseAudio?: (model: string, provider: string) => boolean;
|
||||
/** Check if function calling is supported */
|
||||
isCanUseFC?: (model: string, provider: string) => boolean;
|
||||
/** Check if video is supported */
|
||||
|
||||
@@ -393,7 +393,6 @@ describe('agentRouter', () => {
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: null,
|
||||
});
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
@@ -411,7 +410,6 @@ describe('agentRouter', () => {
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: null,
|
||||
});
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
|
||||
@@ -500,7 +500,6 @@ describe('agentGroupRouter', () => {
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: null,
|
||||
});
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
@@ -518,7 +517,6 @@ describe('agentGroupRouter', () => {
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: null,
|
||||
});
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
|
||||
@@ -29,12 +29,10 @@ describe('aiModelRouter', () => {
|
||||
|
||||
it('should create ai model', async () => {
|
||||
const mockCreate = vi.fn().mockResolvedValue({ id: 'model-1' });
|
||||
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
|
||||
vi.mocked(AiModelModel).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
create: mockCreate,
|
||||
findByIdAndProvider: mockFindByIdAndProvider,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
@@ -46,68 +44,12 @@ describe('aiModelRouter', () => {
|
||||
});
|
||||
|
||||
expect(result).toBe('model-1');
|
||||
expect(mockFindByIdAndProvider).toHaveBeenCalledWith('test-model', 'test-provider');
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
id: 'test-model',
|
||||
providerId: 'test-provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject duplicate ai model before creating', async () => {
|
||||
const mockCreate = vi.fn();
|
||||
const mockFindByIdAndProvider = vi.fn().mockResolvedValue({ id: 'test-model' });
|
||||
vi.mocked(AiModelModel).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
create: mockCreate,
|
||||
findByIdAndProvider: mockFindByIdAndProvider,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const caller = aiModelRouter.createCaller(mockCtx);
|
||||
|
||||
await expect(
|
||||
caller.createAiModel({
|
||||
id: 'test-model',
|
||||
providerId: 'test-provider',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
message: 'Model "test-model" already exists',
|
||||
});
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should convert duplicate insert races to conflict errors', async () => {
|
||||
const duplicateError = Object.assign(new Error('failed query'), {
|
||||
cause: Object.assign(new Error('duplicate key'), {
|
||||
code: '23505',
|
||||
constraint: 'ai_models_id_provider_id_user_id_pk',
|
||||
}),
|
||||
});
|
||||
const mockCreate = vi.fn().mockRejectedValue(duplicateError);
|
||||
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
|
||||
vi.mocked(AiModelModel).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
create: mockCreate,
|
||||
findByIdAndProvider: mockFindByIdAndProvider,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const caller = aiModelRouter.createCaller(mockCtx);
|
||||
|
||||
await expect(
|
||||
caller.createAiModel({
|
||||
id: 'test-model',
|
||||
providerId: 'test-provider',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
message: 'Model "test-model" already exists',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get ai model by id', async () => {
|
||||
const mockModel = {
|
||||
id: 'model-1',
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { asrRouter } from '../asr';
|
||||
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const transcribeMock = vi.fn();
|
||||
vi.mock('@/server/modules/ModelRuntime', () => ({
|
||||
initModelRuntimeFromDB: vi.fn(async () => ({ transcribe: transcribeMock })),
|
||||
}));
|
||||
|
||||
const findByIdMock = vi.fn();
|
||||
vi.mock('@/database/models/file', () => ({
|
||||
FileModel: vi.fn(() => ({ findById: findByIdMock })),
|
||||
}));
|
||||
|
||||
const getFileByteArrayMock = vi.fn();
|
||||
vi.mock('@/server/services/file', () => ({
|
||||
FileService: vi.fn(() => ({ getFileByteArray: getFileByteArrayMock })),
|
||||
}));
|
||||
|
||||
const caller = asrRouter.createCaller({ jwtPayload: { userId: 'u1' }, userId: 'u1' } as any);
|
||||
|
||||
beforeEach(() => {
|
||||
transcribeMock.mockResolvedValue({ text: 'hello world' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('asrRouter.transcribe', () => {
|
||||
it('transcribes inline base64 audio', async () => {
|
||||
const res = await caller.transcribe({
|
||||
audioBase64: Buffer.from('audio-bytes').toString('base64'),
|
||||
fileName: 'clip.mp3',
|
||||
model: 'whisper-1',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(res).toEqual({ text: 'hello world' });
|
||||
expect(findByIdMock).not.toHaveBeenCalled();
|
||||
|
||||
const payload = transcribeMock.mock.calls[0][0];
|
||||
expect(payload.file).toBeInstanceOf(File);
|
||||
expect(payload.fileName).toBe('clip.mp3');
|
||||
expect(await payload.file.text()).toBe('audio-bytes');
|
||||
});
|
||||
|
||||
it('resolves a fileId by downloading the bytes from storage', async () => {
|
||||
findByIdMock.mockResolvedValue({
|
||||
fileType: 'audio/mp4',
|
||||
name: 'meeting.m4a',
|
||||
url: 's3-key/meeting.m4a',
|
||||
});
|
||||
getFileByteArrayMock.mockResolvedValue(new Uint8Array(Buffer.from('from-s3')));
|
||||
|
||||
const res = await caller.transcribe({ fileId: 'file_123', model: 'whisper-1' });
|
||||
|
||||
expect(res).toEqual({ text: 'hello world' });
|
||||
expect(findByIdMock).toHaveBeenCalledWith('file_123');
|
||||
expect(getFileByteArrayMock).toHaveBeenCalledWith('s3-key/meeting.m4a');
|
||||
|
||||
const payload = transcribeMock.mock.calls[0][0];
|
||||
expect(payload.fileName).toBe('meeting.m4a');
|
||||
expect(payload.file.type).toBe('audio/mp4');
|
||||
expect(await payload.file.text()).toBe('from-s3');
|
||||
});
|
||||
|
||||
it('rejects when neither fileId nor audioBase64 is provided', async () => {
|
||||
await expect(caller.transcribe({ model: 'whisper-1' } as any)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects oversized inline base64 and guides to fileId', async () => {
|
||||
// > 3MB decoded → base64 string exceeds the cap
|
||||
const tooBig = 'A'.repeat(5 * 1024 * 1024);
|
||||
|
||||
await expect(caller.transcribe({ audioBase64: tooBig, model: 'whisper-1' })).rejects.toThrow(
|
||||
/fileId/i,
|
||||
);
|
||||
expect(transcribeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when both fileId and audioBase64 are provided', async () => {
|
||||
await expect(
|
||||
caller.transcribe({
|
||||
audioBase64: Buffer.from('x').toString('base64'),
|
||||
fileId: 'file_123',
|
||||
model: 'whisper-1',
|
||||
} as any),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws NOT_FOUND when the fileId does not exist', async () => {
|
||||
findByIdMock.mockResolvedValue(undefined);
|
||||
|
||||
await expect(caller.transcribe({ fileId: 'missing', model: 'whisper-1' })).rejects.toThrow(
|
||||
/not found/i,
|
||||
);
|
||||
expect(getFileByteArrayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NOT_FOUND when the stored object is gone (NoSuchKey)', async () => {
|
||||
findByIdMock.mockResolvedValue({
|
||||
fileType: 'audio/mp4',
|
||||
name: 'gone.m4a',
|
||||
url: 's3-key/gone.m4a',
|
||||
});
|
||||
getFileByteArrayMock.mockRejectedValue({ Code: 'NoSuchKey' });
|
||||
|
||||
await expect(caller.transcribe({ fileId: 'file_x', model: 'whisper-1' })).rejects.toThrow(
|
||||
/no longer available/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DeviceModel } from '@/database/models/device';
|
||||
|
||||
import { assertWorkspaceRootApproved } from '../deviceWorkspaceGuard';
|
||||
|
||||
const mockModel = (row: { defaultCwd?: string | null; workingDirs?: { path: string }[] } | null) =>
|
||||
({
|
||||
findByDeviceId: vi.fn().mockResolvedValue(row),
|
||||
}) as unknown as DeviceModel;
|
||||
|
||||
describe('assertWorkspaceRootApproved', () => {
|
||||
it('allows a root that exactly matches a bound workingDir', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a root nested inside a bound workingDir', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj/packages/app'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a root matching defaultCwd when no workingDirs match', async () => {
|
||||
const model = mockModel({ defaultCwd: '/Users/me/default', workingDirs: [] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/default'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a root that escapes the approved roots (filesystem root)', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(assertWorkspaceRootApproved(model, 'dev-1', '/')).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a sibling directory that shares a path prefix but is not contained', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj-evil'),
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('rejects when the device has no approved roots at all', async () => {
|
||||
const model = mockModel({ workingDirs: [] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('rejects when the device row is missing', async () => {
|
||||
const model = mockModel(null);
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
|
||||
).rejects.toBeInstanceOf(TRPCError);
|
||||
});
|
||||
|
||||
it('rejects an empty workspace root with BAD_REQUEST before hitting the DB', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(assertWorkspaceRootApproved(model, 'dev-1', '')).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(model.findByDeviceId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,6 @@ export const updateDocumentInputSchema = z.object({
|
||||
editorData: z.string().optional(),
|
||||
fileType: z.string().optional(),
|
||||
id: z.string(),
|
||||
lockOwnerId: z.string().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
restoreFromHistoryId: z.string().optional(),
|
||||
@@ -52,7 +51,6 @@ export const updateDocumentInputSchema = z.object({
|
||||
export const saveDocumentHistoryInputSchema = z.object({
|
||||
documentId: z.string(),
|
||||
editorData: z.string(),
|
||||
lockOwnerId: z.string().optional(),
|
||||
saveSource: documentHistorySaveSourceSchema,
|
||||
});
|
||||
|
||||
@@ -100,8 +98,6 @@ export interface UpdateDocumentOutput {
|
||||
export interface SaveDocumentHistoryInput {
|
||||
documentId: string;
|
||||
editorData: string;
|
||||
/** Edit-session id proving the client still holds the workspace page lease. */
|
||||
lockOwnerId?: string;
|
||||
saveSource: DocumentHistorySaveSource;
|
||||
}
|
||||
|
||||
@@ -134,7 +130,6 @@ export interface UpdateDocumentInput {
|
||||
editorData?: string;
|
||||
fileType?: string;
|
||||
id: string;
|
||||
lockOwnerId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
parentId?: string | null;
|
||||
restoreFromHistoryId?: string;
|
||||
|
||||
@@ -372,16 +372,12 @@ export const agentDocumentRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
// Reveal the auto-created `.tool-results` archive. Off by default so
|
||||
// user-facing lists stay clean; the agent document-listing tool opts in.
|
||||
includeArchivedToolResults: z.boolean().optional().default(false),
|
||||
scope: z.enum(['agent', 'currentTopic']).optional().default('agent'),
|
||||
sourceType: z.enum(['all', 'file', 'web']).optional().default('all'),
|
||||
topicId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { includeArchivedToolResults } = input;
|
||||
if (input.scope === 'currentTopic') {
|
||||
if (!input.topicId) throw new Error('topicId is required to list current topic documents');
|
||||
|
||||
@@ -389,13 +385,10 @@ export const agentDocumentRouter = router({
|
||||
input.agentId,
|
||||
input.topicId,
|
||||
input.sourceType,
|
||||
{ includeArchivedToolResults },
|
||||
);
|
||||
}
|
||||
|
||||
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType, {
|
||||
includeArchivedToolResults,
|
||||
});
|
||||
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType);
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { type AiProviderModelListItem } from 'model-bank';
|
||||
import {
|
||||
AiModelTypeSchema,
|
||||
@@ -19,30 +18,6 @@ import { getServerGlobalConfig } from '@/server/globalConfig';
|
||||
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
|
||||
import { type ProviderConfig } from '@/types/user/settings';
|
||||
|
||||
const AI_MODEL_UNIQUE_CONSTRAINT = 'ai_models_id_provider_id_user_id_pk';
|
||||
|
||||
const getPostgresErrorField = (error: unknown, field: 'code' | 'constraint') => {
|
||||
let current = error;
|
||||
|
||||
while (current && typeof current === 'object') {
|
||||
const value = (current as Record<string, unknown>)[field];
|
||||
if (typeof value === 'string') return value;
|
||||
|
||||
current = (current as { cause?: unknown }).cause;
|
||||
}
|
||||
};
|
||||
|
||||
const isDuplicateAiModelError = (error: unknown) =>
|
||||
getPostgresErrorField(error, 'code') === '23505' &&
|
||||
getPostgresErrorField(error, 'constraint') === AI_MODEL_UNIQUE_CONSTRAINT;
|
||||
|
||||
const throwDuplicateAiModelError = (id: string): never => {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `Model "${id}" already exists`,
|
||||
});
|
||||
};
|
||||
|
||||
const aiModelProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
@@ -107,18 +82,9 @@ export const aiModelRouter = router({
|
||||
.use(withScopedPermission('ai_model:create'))
|
||||
.input(CreateAiModelSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const existingModel = await ctx.aiModelModel.findByIdAndProvider(input.id, input.providerId);
|
||||
if (existingModel) throwDuplicateAiModelError(input.id);
|
||||
const data = await ctx.aiModelModel.create(input);
|
||||
|
||||
try {
|
||||
const data = await ctx.aiModelModel.create(input);
|
||||
|
||||
return data?.id;
|
||||
} catch (error) {
|
||||
if (isDuplicateAiModelError(error)) throwDuplicateAiModelError(input.id);
|
||||
|
||||
throw error;
|
||||
}
|
||||
return data?.id;
|
||||
}),
|
||||
|
||||
getAiModelById: aiModelProcedure
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { FileService } from '@/server/services/file';
|
||||
|
||||
const asrProcedure = wsCompatProcedure.use(serverDatabase);
|
||||
|
||||
// Inline base64 is only for short clips. The whole request must fit inside the
|
||||
// platform body limit (≈4.5MB on serverless deploys) and base64 inflates bytes
|
||||
// by ~4/3, so cap the decoded audio well under that — anything larger should be
|
||||
// uploaded and passed as `fileId`.
|
||||
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
|
||||
// base64 length ≈ ceil(bytes / 3) * 4; validating the string length lets us
|
||||
// reject oversized payloads before allocating/decoding them.
|
||||
const MAX_INLINE_AUDIO_BASE64_CHARS = Math.ceil(MAX_INLINE_AUDIO_BYTES / 3) * 4;
|
||||
|
||||
interface ResolvedAudio {
|
||||
bytes: Uint8Array;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export const asrRouter = router({
|
||||
/**
|
||||
* Automatic Speech Recognition (speech-to-text).
|
||||
*
|
||||
* Accepts the audio either as an already-uploaded `fileId` (preferred — the
|
||||
* server streams the bytes from storage, nothing large travels over tRPC) or
|
||||
* inline as base64 for short clips (capped at `MAX_INLINE_AUDIO_BYTES`;
|
||||
* larger payloads are rejected with guidance to upload and pass `fileId`).
|
||||
*
|
||||
* Note on base64: tRPC here uses an `httpLink` + superjson (JSON only), which
|
||||
* has no binary representation for a `Buffer`/`Uint8Array` — a raw buffer would
|
||||
* serialize to a per-byte JSON object, far worse than base64. So inline bytes
|
||||
* stay base64; use `fileId` to avoid inlining entirely.
|
||||
*
|
||||
* Transcription is a single request/response (not streamed), so a mutation is
|
||||
* the right shape.
|
||||
*/
|
||||
transcribe: asrProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
/** Base64-encoded audio bytes (short clips only). Mutually exclusive with `fileId`. */
|
||||
audioBase64: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MAX_INLINE_AUDIO_BASE64_CHARS, {
|
||||
message: `Inline audio is limited to ${MAX_INLINE_AUDIO_BYTES / 1024 / 1024}MB. Upload the file and pass \`fileId\` instead.`,
|
||||
})
|
||||
.optional(),
|
||||
/** Already-uploaded audio file id. Mutually exclusive with `audioBase64`. */
|
||||
fileId: z.string().min(1).optional(),
|
||||
/** Original file name (base64 path); its extension helps format detection. */
|
||||
fileName: z.string().optional(),
|
||||
/** ISO-639-1 language code (e.g. `en`, `zh`). */
|
||||
language: z.string().optional(),
|
||||
/** Audio mime type (base64 path, e.g. `audio/mp4`). */
|
||||
mimeType: z.string().optional(),
|
||||
model: z.string().min(1),
|
||||
/** Optional text to guide the model's style. */
|
||||
prompt: z.string().optional(),
|
||||
provider: z.string().default('openai'),
|
||||
responseFormat: z.enum(['json', 'srt', 'text', 'verbose_json', 'vtt']).optional(),
|
||||
})
|
||||
.refine((d) => Boolean(d.fileId) !== Boolean(d.audioBase64), {
|
||||
message: 'Provide exactly one of `fileId` or `audioBase64`.',
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }): Promise<{ text: string }> => {
|
||||
const workspaceId = ctx.workspaceId ?? undefined;
|
||||
|
||||
const { bytes, fileName, mimeType } = await resolveAudio(ctx, input, workspaceId);
|
||||
|
||||
// Resolve the user's provider config (key + baseURL) from the database,
|
||||
// falling back to server env keys, exactly like chat/embeddings do.
|
||||
const runtime = await initModelRuntimeFromDB(
|
||||
ctx.serverDB,
|
||||
ctx.userId,
|
||||
input.provider,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// `Uint8Array` is a valid BlobPart at runtime; the cast sidesteps the
|
||||
// `Uint8Array<ArrayBufferLike>` vs BlobPart generic mismatch in lib.dom.
|
||||
const file = new File([bytes as BlobPart], fileName, {
|
||||
type: mimeType || 'application/octet-stream',
|
||||
});
|
||||
|
||||
const result = await runtime.transcribe(
|
||||
{
|
||||
file,
|
||||
fileName,
|
||||
language: input.language,
|
||||
model: input.model,
|
||||
prompt: input.prompt,
|
||||
responseFormat: input.responseFormat,
|
||||
},
|
||||
{ user: ctx.userId },
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_IMPLEMENTED',
|
||||
message: `Provider "${input.provider}" does not support ASR.`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Turn the request into raw audio bytes + metadata, from either a stored file
|
||||
* (downloaded from S3, ownership enforced by the userId-scoped FileModel) or the
|
||||
* inline base64 payload.
|
||||
*/
|
||||
async function resolveAudio(
|
||||
ctx: { serverDB: LobeChatDatabase; userId: string },
|
||||
input: { audioBase64?: string; fileId?: string; fileName?: string; mimeType?: string },
|
||||
workspaceId?: string,
|
||||
): Promise<ResolvedAudio> {
|
||||
if (input.fileId) {
|
||||
const fileModel = new FileModel(ctx.serverDB, ctx.userId, workspaceId);
|
||||
const fileItem = await fileModel.findById(input.fileId);
|
||||
|
||||
if (!fileItem) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `File "${input.fileId}" not found.` });
|
||||
}
|
||||
|
||||
const fileService = new FileService(ctx.serverDB, ctx.userId, workspaceId);
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = await fileService.getFileByteArray(fileItem.url);
|
||||
} catch (error) {
|
||||
if ((error as { Code?: string }).Code === 'NoSuchKey') {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `File "${input.fileId}" is no longer available in storage.`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { bytes, fileName: fileItem.name, mimeType: fileItem.fileType };
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: new Uint8Array(Buffer.from(input.audioBase64!, 'base64')),
|
||||
fileName: input.fileName || 'audio',
|
||||
mimeType: input.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
export type AsrRouter = typeof asrRouter;
|
||||
@@ -8,7 +8,6 @@ import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
|
||||
import { preserveWorkspaceCache } from './deviceWorkingDirs';
|
||||
import { assertWorkspaceRootApproved } from './deviceWorkspaceGuard';
|
||||
|
||||
// Derive the zod enum from the canonical config so new platforms are
|
||||
// automatically covered without touching this file.
|
||||
@@ -30,23 +29,6 @@ const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
|
||||
});
|
||||
});
|
||||
|
||||
const workspaceFileInput = z.object({
|
||||
deviceId: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* `deviceProcedure` that additionally requires `workingDirectory` to be an
|
||||
* approved workspace root for the device. Builds the guard into the procedure
|
||||
* so every file-mutating route inherits it and can never forget the check —
|
||||
* see {@link assertWorkspaceRootApproved} for why the check is necessary.
|
||||
*/
|
||||
const workspaceFileProcedure = deviceProcedure.input(workspaceFileInput).use(async (opts) => {
|
||||
const { deviceId, workingDirectory } = workspaceFileInput.parse(await opts.getRawInput());
|
||||
await assertWorkspaceRootApproved(opts.ctx.deviceModel, deviceId, workingDirectory);
|
||||
return opts.next();
|
||||
});
|
||||
|
||||
export const deviceRouter = router({
|
||||
/**
|
||||
* Probe whether a specific agent platform (openclaw / hermes) is available
|
||||
@@ -352,22 +334,24 @@ export const deviceRouter = router({
|
||||
* Read-only local file preview for a file on a remote device. The web client
|
||||
* receives render data, not a `localfile://` URL; saving remains unsupported.
|
||||
*/
|
||||
getLocalFilePreview: workspaceFileProcedure
|
||||
getLocalFilePreview: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
accept: z.enum(['image']).optional(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return deviceGateway.getLocalFilePreview({
|
||||
.query(async ({ ctx, input }) =>
|
||||
deviceGateway.getLocalFilePreview({
|
||||
accept: input.accept,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
|
||||
@@ -400,67 +384,6 @@ export const deviceRouter = router({
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Move files/folders within a directory on a remote device, via the device's
|
||||
* `moveLocalFiles` RPC. Powers the Files tree's drag-to-move in device mode.
|
||||
*/
|
||||
moveProjectFiles: workspaceFileProcedure
|
||||
.input(
|
||||
z.object({
|
||||
items: z.array(z.object({ newPath: z.string(), oldPath: z.string() })),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return deviceGateway.moveProjectFiles({
|
||||
deviceId: input.deviceId,
|
||||
items: input.items,
|
||||
userId: ctx.userId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Rename a single file/folder in a directory on a remote device, via the
|
||||
* device's `renameLocalFile` RPC.
|
||||
*/
|
||||
renameProjectFile: workspaceFileProcedure
|
||||
.input(
|
||||
z.object({
|
||||
newName: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return deviceGateway.renameProjectFile({
|
||||
deviceId: input.deviceId,
|
||||
newName: input.newName,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Save edited content back to a file on a remote device, via the device's
|
||||
* `writeLocalFile` RPC. Powers remote save in the LocalFile editor.
|
||||
*/
|
||||
writeProjectFile: workspaceFileProcedure
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return deviceGateway.writeProjectFile({
|
||||
content: input.content,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check whether a path exists on a remote device and is a directory, via the
|
||||
* device's `statPath` RPC. Lets a web client validate a manually-entered
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import type { DeviceModel } from '@/database/models/device';
|
||||
import { isPathWithinRoot } from '@/server/services/deviceGateway';
|
||||
|
||||
/**
|
||||
* Validate that a client-supplied workspace root is actually one the user has
|
||||
* bound to this device.
|
||||
*
|
||||
* The file routes (move / rename / write / preview) receive `workingDirectory`
|
||||
* from the same untrusted browser session that supplies the file paths. The
|
||||
* gateway's `assertPathsWithinWorkspace` only proves the paths sit *inside that
|
||||
* directory* — it never proves the directory itself is legitimate. So a caller
|
||||
* could set `workingDirectory` to `/` (or `C:\`), pass that containment check
|
||||
* trivially, and reach any path on the device.
|
||||
*
|
||||
* To close that hole we re-derive the approved roots from the *server-owned*
|
||||
* device row — the `workingDirs` recent list and `defaultCwd`, both written only
|
||||
* via `device.updateDevice` / the run path, never trusted from this request —
|
||||
* and require the requested root to equal or nest inside one of them before any
|
||||
* RPC is forwarded. The picker upserts every chosen directory into `workingDirs`
|
||||
* (see `useCommitWorkingDirectory`) and run start upserts the bound cwd, so a
|
||||
* legitimately-selected workspace is always present here.
|
||||
*/
|
||||
export const assertWorkspaceRootApproved = async (
|
||||
deviceModel: DeviceModel,
|
||||
deviceId: string,
|
||||
workingDirectory: string,
|
||||
): Promise<void> => {
|
||||
if (!workingDirectory) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'A workspace root is required for file operations',
|
||||
});
|
||||
}
|
||||
|
||||
const device = await deviceModel.findByDeviceId(deviceId);
|
||||
|
||||
const approvedRoots = [
|
||||
...(device?.workingDirs ?? []).map((dir) => dir.path),
|
||||
...(device?.defaultCwd ? [device.defaultCwd] : []),
|
||||
].filter((root): root is string => Boolean(root));
|
||||
|
||||
const approved = approvedRoots.some((root) => isPathWithinRoot(root, workingDirectory));
|
||||
|
||||
if (!approved) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Working directory is not an approved workspace for this device',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -183,7 +183,6 @@ export const documentRouter = router({
|
||||
input.documentId,
|
||||
editorData,
|
||||
input.saveSource,
|
||||
input.lockOwnerId,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -256,27 +255,23 @@ export const documentRouter = router({
|
||||
|
||||
acquireDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return input.ownerId
|
||||
? ctx.documentService.acquireDocumentLockWithOwner(input.id, input.ownerId)
|
||||
: ctx.documentService.acquireDocumentLock(input.id);
|
||||
return ctx.documentService.acquireDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
getDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.documentService.getDocumentLock(input.id, input.ownerId);
|
||||
return ctx.documentService.getDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
releaseDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.ownerId)
|
||||
await ctx.documentService.releaseDocumentLockWithOwner(input.id, input.ownerId);
|
||||
else await ctx.documentService.releaseDocumentLock(input.id);
|
||||
await ctx.documentService.releaseDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
updateDocument: documentProcedure
|
||||
|
||||
@@ -32,7 +32,6 @@ import { aiChatRouter } from './aiChat';
|
||||
import { aiModelRouter } from './aiModel';
|
||||
import { aiProviderRouter } from './aiProvider';
|
||||
import { apiKeyRouter } from './apiKey';
|
||||
import { asrRouter } from './asr';
|
||||
import { botMessageRouter } from './botMessage';
|
||||
import { briefRouter } from './brief';
|
||||
import { changelogRouter } from './changelog';
|
||||
@@ -99,7 +98,6 @@ export const lambdaRouter = router({
|
||||
aiModel: aiModelRouter,
|
||||
aiProvider: aiProviderRouter,
|
||||
apiKey: apiKeyRouter,
|
||||
asr: asrRouter,
|
||||
chunk: chunkRouter,
|
||||
comfyui: comfyuiRouter,
|
||||
config: configRouter,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEFAULT_INBOX_AVATAR, INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, desc, eq, ne, or } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
isMessengerPlatformEnabled,
|
||||
type MessengerPlatform,
|
||||
} from '@/config/messenger';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import {
|
||||
MessengerAccountLinkConflictError,
|
||||
MessengerAccountLinkModel,
|
||||
@@ -23,6 +23,7 @@ import { RbacModel } from '@/database/models/rbac';
|
||||
import { WorkspaceModel } from '@/database/models/workspace';
|
||||
import { agents, users } from '@/database/schemas';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { buildWorkspaceWhere } from '@/database/utils/workspace';
|
||||
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { getServerFeatureFlagsStateFromRuntimeConfig } from '@/server/featureFlags';
|
||||
@@ -121,12 +122,6 @@ const messengerProcedure = authedProcedure.use(serverDatabase).use(async (opts)
|
||||
// userId), and per-agent authorization happens in-handler via
|
||||
// `resolveAuthorizedAgentScope`.
|
||||
messengerLinkModel: new MessengerAccountLinkModel(ctx.serverDB, ctx.userId),
|
||||
// The bindable-agents scope is request-driven — the cascading scope
|
||||
// picker passes the workspace via input, not the ambient header — so
|
||||
// expose a workspace-parameterized AgentModel factory rather than a
|
||||
// single pre-scoped instance.
|
||||
getAgentModel: (workspaceId?: string | null) =>
|
||||
new AgentModel(ctx.serverDB, ctx.userId, workspaceId ?? undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -459,10 +454,44 @@ export const messengerRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// Inbox meta fallback, the virtual-or-inbox filter, inbox pinning, and the
|
||||
// `isInbox` flag all live in the model. Blank non-inbox titles stay null
|
||||
// here so the web picker can apply its own i18n default.
|
||||
return ctx.getAgentModel(workspaceId).listMessengerBindableAgents();
|
||||
const rows = await serverDB
|
||||
.select({
|
||||
avatar: agents.avatar,
|
||||
backgroundColor: agents.backgroundColor,
|
||||
id: agents.id,
|
||||
slug: agents.slug,
|
||||
title: agents.title,
|
||||
})
|
||||
.from(agents)
|
||||
.where(
|
||||
and(
|
||||
buildWorkspaceWhere({ userId, workspaceId: workspaceId ?? undefined }, agents),
|
||||
or(ne(agents.virtual, true), eq(agents.slug, INBOX_SESSION_ID)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agents.updatedAt));
|
||||
|
||||
const mapped = rows
|
||||
.filter((row) => row.id)
|
||||
.map((row) => ({
|
||||
avatar: row.avatar || (row.slug === INBOX_SESSION_ID ? DEFAULT_INBOX_AVATAR : null),
|
||||
backgroundColor: row.backgroundColor,
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title || (row.slug === INBOX_SESSION_ID ? 'LobeAI' : null),
|
||||
}));
|
||||
|
||||
// Pin the inbox/LobeAI agent to the top regardless of updatedAt — it's
|
||||
// the implicit "default" agent and should always be the first option.
|
||||
const inboxIdx = mapped.findIndex((row) => row.slug === INBOX_SESSION_ID);
|
||||
if (inboxIdx > 0) {
|
||||
const [inbox] = mapped.splice(inboxIdx, 1);
|
||||
mapped.unshift(inbox);
|
||||
}
|
||||
return mapped.map(({ slug, ...rest }) => ({
|
||||
...rest,
|
||||
isInbox: slug === INBOX_SESSION_ID,
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { TASK_TEMPLATE_RECOMMEND_MAX_COUNT } from '@lobechat/const';
|
||||
import { KNOWN_TASK_TEMPLATE_IDS } from '@lobechat/const';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import {
|
||||
ENABLED_TASK_TEMPLATE_CONNECTORS,
|
||||
TaskTemplateService,
|
||||
} from '@/server/services/taskTemplate';
|
||||
import { ENABLED_SKILL_SOURCES, TaskTemplateService } from '@/server/services/taskTemplate';
|
||||
|
||||
const listDailyRecommendSchema = z.object({
|
||||
count: z.number().int().min(1).max(TASK_TEMPLATE_RECOMMEND_MAX_COUNT).optional(),
|
||||
count: z.number().int().min(1).optional(),
|
||||
interestKeys: z.array(z.string().max(64)).max(32),
|
||||
locale: z.string().max(32).optional(),
|
||||
refreshSeed: z.string().min(1).max(32).optional(),
|
||||
});
|
||||
|
||||
const templateIdSchema = z.object({
|
||||
templateId: z.number().int().positive(),
|
||||
templateId: z
|
||||
.string()
|
||||
.max(64)
|
||||
.refine((id) => KNOWN_TASK_TEMPLATE_IDS.has(id), { message: 'Unknown task template id' }),
|
||||
});
|
||||
|
||||
export const taskTemplateRouter = router({
|
||||
@@ -29,8 +28,7 @@ export const taskTemplateRouter = router({
|
||||
const service = new TaskTemplateService(ctx.userId);
|
||||
const data = await service.listDailyRecommend(input.interestKeys, {
|
||||
count: input.count,
|
||||
enabledConnectors: ENABLED_TASK_TEMPLATE_CONNECTORS,
|
||||
locale: input.locale,
|
||||
enabledSkillSources: ENABLED_SKILL_SOURCES,
|
||||
refreshSeed: input.refreshSeed,
|
||||
});
|
||||
return { data, success: true };
|
||||
|
||||
@@ -4,21 +4,19 @@ import {
|
||||
type RecentTopicGroupMember,
|
||||
} from '@lobechat/types';
|
||||
import { cleanObject } from '@lobechat/utils';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { after } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { ChatGroupModel } from '@/database/models/chatGroup';
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import { TopicShareModel } from '@/database/models/topicShare';
|
||||
import { AgentMigrationRepo } from '@/database/repositories/agentMigration';
|
||||
import { TopicImporterRepo } from '@/database/repositories/topicImporter';
|
||||
import { chatGroups } from '@/database/schemas';
|
||||
import { agents, chatGroups, chatGroupsAgents } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { type BatchTaskResult } from '@/types/service';
|
||||
@@ -37,9 +35,7 @@ const topicProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
|
||||
return opts.next({
|
||||
ctx: {
|
||||
agentMigrationRepo: new AgentMigrationRepo(ctx.serverDB, ctx.userId, wsId),
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
agentOperationModel: new AgentOperationModel(ctx.serverDB, ctx.userId, wsId),
|
||||
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
|
||||
topicImporterRepo: new TopicImporterRepo(ctx.serverDB, ctx.userId, wsId),
|
||||
topicModel: new TopicModel(ctx.serverDB, ctx.userId, wsId),
|
||||
topicShareModel: new TopicShareModel(ctx.serverDB, ctx.userId, wsId),
|
||||
@@ -449,14 +445,22 @@ export const topicRouter = router({
|
||||
// Collect all agentIds to fetch agent info
|
||||
const allAgentIds = [...new Set(topicAgentIdMap.values())];
|
||||
|
||||
// Batch query agent info (already normalized for the inbox agent)
|
||||
// Batch query agent info
|
||||
const agentInfoMap = new Map<
|
||||
string,
|
||||
{ avatar: string | null; backgroundColor: string | null; id: string; title: string | null }
|
||||
>();
|
||||
|
||||
if (allAgentIds.length > 0) {
|
||||
const agentInfos = await ctx.agentModel.getAgentAvatarsByIds(allAgentIds);
|
||||
const agentInfos = await ctx.serverDB
|
||||
.select({
|
||||
avatar: agents.avatar,
|
||||
backgroundColor: agents.backgroundColor,
|
||||
id: agents.id,
|
||||
title: agents.title,
|
||||
})
|
||||
.from(agents)
|
||||
.where(inArray(agents.id, allAgentIds));
|
||||
|
||||
for (const agent of agentInfos) {
|
||||
agentInfoMap.set(agent.id, agent);
|
||||
@@ -477,9 +481,28 @@ export const topicRouter = router({
|
||||
.from(chatGroups)
|
||||
.where(inArray(chatGroups.id, allGroupIds));
|
||||
|
||||
// Query group member avatars (already normalized for the inbox agent)
|
||||
const groupMembersMap: Map<string, RecentTopicGroupMember[]> =
|
||||
await ctx.chatGroupModel.getMemberAvatarsByGroupIds(allGroupIds);
|
||||
// Query group member agents (get avatar info)
|
||||
const groupMembersRaw = await ctx.serverDB
|
||||
.select({
|
||||
agentAvatar: agents.avatar,
|
||||
agentBackgroundColor: agents.backgroundColor,
|
||||
chatGroupId: chatGroupsAgents.chatGroupId,
|
||||
order: chatGroupsAgents.order,
|
||||
})
|
||||
.from(chatGroupsAgents)
|
||||
.leftJoin(agents, eq(chatGroupsAgents.agentId, agents.id))
|
||||
.where(inArray(chatGroupsAgents.chatGroupId, allGroupIds));
|
||||
|
||||
// Group members by chatGroupId
|
||||
const groupMembersMap = new Map<string, RecentTopicGroupMember[]>();
|
||||
for (const member of groupMembersRaw) {
|
||||
const members = groupMembersMap.get(member.chatGroupId) || [];
|
||||
members.push({
|
||||
avatar: member.agentAvatar,
|
||||
backgroundColor: member.agentBackgroundColor,
|
||||
});
|
||||
groupMembersMap.set(member.chatGroupId, members);
|
||||
}
|
||||
|
||||
// Build group info map
|
||||
for (const group of chatGroupInfos) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { DEFAULT_AGENT_CONFIG, DEFAULT_INBOX_AVATAR, DEFAULT_INBOX_TITLE } from '@lobechat/const';
|
||||
import { DEFAULT_AGENT_CONFIG } from '@lobechat/const';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
@@ -190,12 +190,10 @@ describe('AgentService', () => {
|
||||
expect(result?.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should fallback inbox title and avatar', async () => {
|
||||
it('should merge avatar from builtin-agents package definition', async () => {
|
||||
const mockAgent = {
|
||||
avatar: null,
|
||||
id: 'agent-1',
|
||||
slug: 'inbox',
|
||||
title: null,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
@@ -209,8 +207,8 @@ describe('AgentService', () => {
|
||||
const newService = new AgentService(mockDb, mockUserId);
|
||||
const result = await newService.getBuiltinAgent('inbox');
|
||||
|
||||
expect((result as any)?.avatar).toBe(DEFAULT_INBOX_AVATAR);
|
||||
expect((result as any)?.title).toBe(DEFAULT_INBOX_TITLE);
|
||||
// Avatar should be merged from BUILTIN_AGENTS definition
|
||||
expect((result as any)?.avatar).toBe('/avatars/lobe-ai.png');
|
||||
});
|
||||
|
||||
it('should not include avatar for non-builtin agents', async () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { type PartialDeep } from 'type-fest';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import { SessionModel } from '@/database/models/session';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import { normalizeInboxAgentAvatar, normalizeInboxAgentTitle } from '@/database/utils/inboxAgent';
|
||||
import { getRedisConfig } from '@/envs/redis';
|
||||
import {
|
||||
getJSONFromRedis,
|
||||
@@ -84,20 +83,14 @@ export class AgentService {
|
||||
|
||||
const mergedConfig = this.mergeDefaultConfig(agent, defaultAgentConfig);
|
||||
if (!mergedConfig) return null;
|
||||
const identity = { slug: (mergedConfig as { slug?: string | null }).slug ?? slug };
|
||||
const normalizedConfig = {
|
||||
...mergedConfig,
|
||||
avatar: normalizeInboxAgentAvatar(mergedConfig.avatar, identity),
|
||||
title: normalizeInboxAgentTitle(mergedConfig.title, identity),
|
||||
};
|
||||
|
||||
// Use builtin avatar as fallback only when DB has no custom avatar
|
||||
const builtinAgent = BUILTIN_AGENTS[slug as BuiltinAgentSlug];
|
||||
if (builtinAgent?.avatar && !normalizedConfig.avatar) {
|
||||
return { ...normalizedConfig, avatar: builtinAgent.avatar };
|
||||
if (builtinAgent?.avatar && !mergedConfig.avatar) {
|
||||
return { ...mergedConfig, avatar: builtinAgent.avatar };
|
||||
}
|
||||
|
||||
return normalizedConfig;
|
||||
return mergedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -96,7 +96,6 @@ describe('AgentDocumentsService', () => {
|
||||
findContextByAgent: vi.fn(),
|
||||
findByDocumentIds: vi.fn(),
|
||||
findByFilename: vi.fn(),
|
||||
findByParentAndFilename: vi.fn(),
|
||||
findSkillDocsByAgent: vi.fn(),
|
||||
hasByAgent: vi.fn(),
|
||||
listByAgent: vi.fn(),
|
||||
@@ -334,65 +333,6 @@ describe('AgentDocumentsService', () => {
|
||||
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1', { sourceType: 'web' });
|
||||
expect(mockModel.findByAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide the .tool-results archive folder and its children by default', async () => {
|
||||
mockModel.listByAgent.mockResolvedValue([
|
||||
{
|
||||
documentId: 'archive-root',
|
||||
fileType: 'custom/folder',
|
||||
filename: '.tool-results',
|
||||
id: 'doc-archive',
|
||||
parentId: null,
|
||||
title: '.tool-results',
|
||||
},
|
||||
{
|
||||
documentId: 'archive-child',
|
||||
filename: 'dump.md',
|
||||
id: 'doc-child',
|
||||
parentId: 'archive-root',
|
||||
title: 'dump',
|
||||
},
|
||||
{
|
||||
documentId: 'documents-1',
|
||||
filename: 'a.md',
|
||||
id: 'doc-1',
|
||||
parentId: null,
|
||||
title: 'A',
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
const result = await service.listDocuments('agent-1');
|
||||
|
||||
expect(result.map((d) => d.documentId)).toEqual(['documents-1']);
|
||||
});
|
||||
|
||||
it('should include the .tool-results archive when includeArchivedToolResults is set', async () => {
|
||||
mockModel.listByAgent.mockResolvedValue([
|
||||
{
|
||||
documentId: 'archive-root',
|
||||
fileType: 'custom/folder',
|
||||
filename: '.tool-results',
|
||||
id: 'doc-archive',
|
||||
parentId: null,
|
||||
title: '.tool-results',
|
||||
},
|
||||
{
|
||||
documentId: 'documents-1',
|
||||
filename: 'a.md',
|
||||
id: 'doc-1',
|
||||
parentId: null,
|
||||
title: 'A',
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
const result = await service.listDocuments('agent-1', undefined, {
|
||||
includeArchivedToolResults: true,
|
||||
});
|
||||
|
||||
expect(result.map((d) => d.documentId)).toEqual(['archive-root', 'documents-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listDocumentsForTopic', () => {
|
||||
@@ -457,64 +397,6 @@ describe('AgentDocumentsService', () => {
|
||||
});
|
||||
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide an archived tool result whose `.tool-results` folder is not topic-associated', async () => {
|
||||
// The archive folder is created by mkdir but only the archived file gets
|
||||
// associated with the topic, so the folder never appears in the list.
|
||||
mockTopicDocumentModel.findByTopicId.mockResolvedValue([
|
||||
{ id: 'archive-child', title: 'dump' },
|
||||
]);
|
||||
mockModel.listByDocumentIds.mockResolvedValue([
|
||||
{
|
||||
documentId: 'archive-child',
|
||||
filename: 'topic_call.txt',
|
||||
id: 'agent-doc-archive-child',
|
||||
parentId: 'archive-root',
|
||||
title: 'dump',
|
||||
},
|
||||
]);
|
||||
mockModel.findByParentAndFilename.mockResolvedValue({
|
||||
documentId: 'archive-root',
|
||||
fileType: 'custom/folder',
|
||||
filename: '.tool-results',
|
||||
id: 'agent-doc-archive-root',
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
const result = await service.listDocumentsForTopic('agent-1', 'topic-1');
|
||||
|
||||
expect(mockModel.findByParentAndFilename).toHaveBeenCalledWith(
|
||||
'agent-1',
|
||||
null,
|
||||
'.tool-results',
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should keep the archived tool result when includeArchivedToolResults is set', async () => {
|
||||
mockTopicDocumentModel.findByTopicId.mockResolvedValue([
|
||||
{ id: 'archive-child', title: 'dump' },
|
||||
]);
|
||||
mockModel.listByDocumentIds.mockResolvedValue([
|
||||
{
|
||||
documentId: 'archive-child',
|
||||
filename: 'topic_call.txt',
|
||||
id: 'agent-doc-archive-child',
|
||||
parentId: 'archive-root',
|
||||
title: 'dump',
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
const result = await service.listDocumentsForTopic('agent-1', 'topic-1', undefined, {
|
||||
includeArchivedToolResults: true,
|
||||
});
|
||||
|
||||
expect(result.map((d) => d.documentId)).toEqual(['archive-child']);
|
||||
// No folder lookup needed when archives are included.
|
||||
expect(mockModel.findByParentAndFilename).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDocumentByFilename', () => {
|
||||
|
||||
@@ -71,13 +71,18 @@ type ProjectableAgentDocument = Pick<
|
||||
'content' | 'editorData' | 'fileType' | 'templateId'
|
||||
>;
|
||||
|
||||
/** Collect ids of root `.tool-results` archive folders present in a doc list. */
|
||||
const collectArchiveFolderIds = <
|
||||
/**
|
||||
* Hide the auto-created `.tool-results/` archive (root folder + its children)
|
||||
* from user-facing document lists. Agents still discover archived entries via
|
||||
* the tool-oriented `listDocuments` / `listDocumentsForTopic` paths, which hit
|
||||
* the model directly.
|
||||
*/
|
||||
const excludeArchivedToolResults = <
|
||||
T extends Pick<AgentDocument, 'documentId' | 'parentId' | 'filename' | 'fileType'>,
|
||||
>(
|
||||
docs: T[],
|
||||
): Set<string> =>
|
||||
new Set(
|
||||
): T[] => {
|
||||
const archiveFolderIds = new Set(
|
||||
docs
|
||||
.filter(
|
||||
(d) =>
|
||||
@@ -87,24 +92,6 @@ const collectArchiveFolderIds = <
|
||||
)
|
||||
.map((d) => d.documentId),
|
||||
);
|
||||
|
||||
/**
|
||||
* Hide the auto-created `.tool-results/` archive (root folder + its children)
|
||||
* from user-facing document lists. Applied by default everywhere, including
|
||||
* `listDocuments` / `listDocumentsForTopic`. The tool runtime that lets agents
|
||||
* discover archived entries opts back in via `includeArchivedToolResults`.
|
||||
*
|
||||
* `archiveFolderIds` lets callers whose list may not contain the folder row
|
||||
* supply the ids explicitly — the topic path only sees the archived file
|
||||
* (which is topic-associated), never the folder, so it can't be derived from
|
||||
* the list alone.
|
||||
*/
|
||||
const excludeArchivedToolResults = <
|
||||
T extends Pick<AgentDocument, 'documentId' | 'parentId' | 'filename' | 'fileType'>,
|
||||
>(
|
||||
docs: T[],
|
||||
archiveFolderIds: Set<string> = collectArchiveFolderIds(docs),
|
||||
): T[] => {
|
||||
if (archiveFolderIds.size === 0) return docs;
|
||||
return docs.filter(
|
||||
(d) =>
|
||||
@@ -626,23 +613,16 @@ export class AgentDocumentsService {
|
||||
}
|
||||
}
|
||||
|
||||
async listDocuments(
|
||||
agentId: string,
|
||||
sourceType?: AgentDocumentListSourceType,
|
||||
options?: { includeArchivedToolResults?: boolean },
|
||||
) {
|
||||
const docs = sourceType
|
||||
? await this.agentDocumentModel.listByAgent(agentId, { sourceType })
|
||||
: await this.agentDocumentModel.listByAgent(agentId);
|
||||
async listDocuments(agentId: string, sourceType?: AgentDocumentListSourceType) {
|
||||
if (!sourceType) return this.agentDocumentModel.listByAgent(agentId);
|
||||
|
||||
return options?.includeArchivedToolResults ? docs : excludeArchivedToolResults(docs);
|
||||
return this.agentDocumentModel.listByAgent(agentId, { sourceType });
|
||||
}
|
||||
|
||||
async listDocumentsForTopic(
|
||||
agentId: string,
|
||||
topicId: string,
|
||||
sourceType?: AgentDocumentListSourceType,
|
||||
options?: { includeArchivedToolResults?: boolean },
|
||||
) {
|
||||
const topicDocs = await this.topicDocumentModel.findByTopicId(topicId);
|
||||
const documentIds = topicDocs.map((doc) => doc.id);
|
||||
@@ -651,26 +631,9 @@ export class AgentDocumentsService {
|
||||
: await this.agentDocumentModel.listByDocumentIds(agentId, documentIds);
|
||||
const docsByDocumentId = new Map(docs.map((doc) => [doc.documentId, doc]));
|
||||
|
||||
const ordered = topicDocs
|
||||
return topicDocs
|
||||
.map((topicDoc) => docsByDocumentId.get(topicDoc.id))
|
||||
.filter((doc): doc is AgentDocumentListItem => Boolean(doc));
|
||||
|
||||
if (options?.includeArchivedToolResults) return ordered;
|
||||
|
||||
// The `.tool-results` folder is never topic-associated (only the archived
|
||||
// file is), so it isn't in `ordered`. Look it up directly so the archived
|
||||
// file can be filtered out by its parent id.
|
||||
const archiveFolder = await this.agentDocumentModel.findByParentAndFilename(
|
||||
agentId,
|
||||
null,
|
||||
TOOL_RESULTS_DIR_NAME,
|
||||
);
|
||||
const archiveFolderIds =
|
||||
archiveFolder?.fileType === DOCUMENT_FOLDER_TYPE
|
||||
? new Set([archiveFolder.documentId])
|
||||
: new Set<string>();
|
||||
|
||||
return excludeArchivedToolResults(ordered, archiveFolderIds);
|
||||
}
|
||||
|
||||
async getDocumentByFilename(agentId: string, filename: string) {
|
||||
|
||||
@@ -3,78 +3,69 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDefaultSnapshotStore, shouldUseAgentS3Tracing } from '../snapshotStore';
|
||||
|
||||
const s3Store = { kind: 's3' } as any;
|
||||
const fileStore = { kind: 'file' } as any;
|
||||
const createS3 = vi.fn(() => s3Store);
|
||||
const createFile = vi.fn(() => fileStore);
|
||||
const factories = { createFile, createS3 };
|
||||
const s3SnapshotStoreMock = vi.fn(() => ({ kind: 's3' }));
|
||||
const fileSnapshotStoreMock = vi.fn(() => ({ kind: 'file' }));
|
||||
|
||||
const setEnv = (nodeEnv: string, agentS3Tracing?: string) => {
|
||||
vi.stubEnv('NODE_ENV', nodeEnv);
|
||||
vi.stubEnv('ENABLE_AGENT_S3_TRACING', agentS3Tracing);
|
||||
};
|
||||
|
||||
const loadModule = vi.fn((moduleName: string) => {
|
||||
if (moduleName === '@/server/modules/AgentTracing') {
|
||||
return { S3SnapshotStore: s3SnapshotStoreMock };
|
||||
}
|
||||
|
||||
if (moduleName === '@lobechat/agent-tracing') {
|
||||
return { FileSnapshotStore: fileSnapshotStoreMock };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected module: ${moduleName}`);
|
||||
});
|
||||
|
||||
describe('agent runtime snapshot store defaults', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('enables S3 tracing by default in production when env is unset', () => {
|
||||
setEnv('production');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(true);
|
||||
expect(createDefaultSnapshotStore(factories)).toBe(s3Store);
|
||||
expect(createS3).toHaveBeenCalledTimes(1);
|
||||
expect(createFile).not.toHaveBeenCalled();
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
|
||||
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the local file snapshot store in development when env is unset', () => {
|
||||
setEnv('development');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(false);
|
||||
expect(createDefaultSnapshotStore(factories)).toBe(fileStore);
|
||||
expect(createS3).not.toHaveBeenCalled();
|
||||
expect(createFile).toHaveBeenCalledTimes(1);
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 'file' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@lobechat/agent-tracing');
|
||||
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(fileSnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets ENABLE_AGENT_S3_TRACING=1 force S3 tracing outside production', () => {
|
||||
setEnv('development', '1');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(true);
|
||||
expect(createDefaultSnapshotStore(factories)).toBe(s3Store);
|
||||
expect(createS3).toHaveBeenCalledTimes(1);
|
||||
expect(createFile).not.toHaveBeenCalled();
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
|
||||
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an explicit ENABLE_AGENT_S3_TRACING value disable the production default', () => {
|
||||
setEnv('production', '0');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(false);
|
||||
expect(createDefaultSnapshotStore(factories)).toBeNull();
|
||||
expect(createS3).not.toHaveBeenCalled();
|
||||
expect(createFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('degrades to null (never throws) when S3 store construction fails', () => {
|
||||
setEnv('production');
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const boom = vi.fn(() => {
|
||||
throw new Error('missing S3 creds');
|
||||
});
|
||||
|
||||
expect(createDefaultSnapshotStore({ createS3: boom })).toBeNull();
|
||||
expect(boom).toHaveBeenCalledTimes(1);
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('constructs a real store via the default (non-injected) path', () => {
|
||||
// Guards the regression: the default path must build a store with NO dynamic
|
||||
// require. In dev that is the statically-imported FileSnapshotStore
|
||||
// (S3 needs creds, so dev is the safe env to assert a non-null default).
|
||||
setEnv('development');
|
||||
|
||||
expect(createDefaultSnapshotStore()).not.toBeNull();
|
||||
expect(createDefaultSnapshotStore(loadModule)).toBeNull();
|
||||
expect(loadModule).not.toHaveBeenCalled();
|
||||
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { FileSnapshotStore, type ISnapshotStore } from '@lobechat/agent-tracing';
|
||||
|
||||
import { S3SnapshotStore } from '@/server/modules/AgentTracing';
|
||||
import type { ISnapshotStore } from '@lobechat/agent-tracing';
|
||||
|
||||
const ENABLE_AGENT_S3_TRACING_VALUE = '1';
|
||||
|
||||
type SnapshotStoreConstructor = new () => ISnapshotStore;
|
||||
type SnapshotStoreModuleLoader = (moduleName: string) => unknown;
|
||||
|
||||
interface FileSnapshotStoreModule {
|
||||
FileSnapshotStore: SnapshotStoreConstructor;
|
||||
}
|
||||
|
||||
interface S3SnapshotStoreModule {
|
||||
S3SnapshotStore: SnapshotStoreConstructor;
|
||||
}
|
||||
|
||||
const nodeRequire: SnapshotStoreModuleLoader = (moduleName) => require(moduleName);
|
||||
|
||||
export const shouldUseAgentS3Tracing = () => {
|
||||
const explicitValue = process.env.ENABLE_AGENT_S3_TRACING;
|
||||
|
||||
@@ -12,18 +23,6 @@ export const shouldUseAgentS3Tracing = () => {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor injection for tests. The defaults are the statically-imported
|
||||
* stores — never load them via a dynamic `require(moduleName)`: the module name
|
||||
* goes through an indirection the bundler can't statically analyze, so the `@/`
|
||||
* build-time alias fails to resolve at runtime and the store silently becomes
|
||||
* `null` (this once disabled ALL production snapshots).
|
||||
*/
|
||||
export interface SnapshotStoreFactories {
|
||||
createFile?: () => ISnapshotStore;
|
||||
createS3?: () => ISnapshotStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default snapshot store based on environment.
|
||||
* - ENABLE_AGENT_S3_TRACING=1 -> S3SnapshotStore
|
||||
@@ -32,22 +31,28 @@ export interface SnapshotStoreFactories {
|
||||
* - Otherwise -> null (no tracing)
|
||||
*/
|
||||
export const createDefaultSnapshotStore = (
|
||||
factories: SnapshotStoreFactories = {},
|
||||
loadModule: SnapshotStoreModuleLoader = nodeRequire,
|
||||
): ISnapshotStore | null => {
|
||||
if (shouldUseAgentS3Tracing()) {
|
||||
try {
|
||||
return (factories.createS3 ?? (() => new S3SnapshotStore()))();
|
||||
} catch (e) {
|
||||
// Tracing is best-effort — a misconfigured S3 (e.g. missing creds) must
|
||||
// never break the agent run. But surface it loudly: a swallowed failure
|
||||
// here previously disabled all production snapshots without a trace.
|
||||
console.error('[snapshotStore] failed to create S3SnapshotStore, tracing disabled:', e);
|
||||
return null;
|
||||
const { S3SnapshotStore } = loadModule(
|
||||
'@/server/modules/AgentTracing',
|
||||
) as S3SnapshotStoreModule;
|
||||
return new S3SnapshotStore();
|
||||
} catch {
|
||||
// S3SnapshotStore not available
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return (factories.createFile ?? (() => new FileSnapshotStore()))();
|
||||
try {
|
||||
const { FileSnapshotStore } = loadModule(
|
||||
'@lobechat/agent-tracing',
|
||||
) as FileSnapshotStoreModule;
|
||||
return new FileSnapshotStore();
|
||||
} catch {
|
||||
// agent-tracing not available
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -27,7 +27,6 @@ import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
|
||||
import { buildTaskManagerDefaultsPrompt } from '@lobechat/prompts';
|
||||
import type {
|
||||
ChatAudioItem,
|
||||
ChatFileItem,
|
||||
ChatTopicBotContext,
|
||||
ChatVideoItem,
|
||||
@@ -477,7 +476,6 @@ export class AiAgentService {
|
||||
files?: InternalExecAgentParams['files'];
|
||||
throwIfAborted: (stage: string) => Promise<void>;
|
||||
}): Promise<{
|
||||
audioList?: ChatAudioItem[];
|
||||
fileIds?: string[];
|
||||
fileList?: ChatFileItem[];
|
||||
imageList?: Array<{ alt: string; id: string; url: string }>;
|
||||
@@ -488,15 +486,13 @@ export class AiAgentService {
|
||||
let fileIds: string[] | undefined;
|
||||
let imageList: Array<{ alt: string; id: string; url: string }> | undefined;
|
||||
let videoList: ChatVideoItem[] | undefined;
|
||||
let audioList: ChatAudioItem[] | undefined;
|
||||
let fileList: ChatFileItem[] | undefined;
|
||||
|
||||
// Upload raw bot/IM files to S3 and classify them (image / video / audio / document).
|
||||
// Upload raw bot/IM files to S3 and classify them (image / video / document).
|
||||
if (files && files.length > 0) {
|
||||
fileIds = [];
|
||||
imageList = [];
|
||||
videoList = [];
|
||||
audioList = [];
|
||||
fileList = [];
|
||||
const fileService = new FileService(this.db, this.userId, this.workspaceId);
|
||||
const documentService = new DocumentService(this.db, this.userId, this.workspaceId);
|
||||
@@ -526,16 +522,7 @@ export class AiAgentService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.isAudio) {
|
||||
audioList.push({
|
||||
alt: file.name || 'audio',
|
||||
id: result.fileId,
|
||||
url: result.resolvedUrl,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-image / non-video / non-audio: parse file content into the documents table so
|
||||
// Non-image / non-video: parse file content into the documents table so
|
||||
// the MessageContentProcessor can inject it via filesPrompts(). Mirrors
|
||||
// what the web upload path does, ensuring bot-uploaded PDFs / text /
|
||||
// JSON / .skill files are actually visible to the LLM (instead of
|
||||
@@ -572,17 +559,15 @@ export class AiAgentService {
|
||||
|
||||
if (fileIds.length > 0) {
|
||||
log(
|
||||
'execAgent: uploaded %d files to S3 (%d images, %d videos, %d audios, %d documents)',
|
||||
'execAgent: uploaded %d files to S3 (%d images, %d videos, %d documents)',
|
||||
fileIds.length,
|
||||
imageList.length,
|
||||
videoList.length,
|
||||
audioList.length,
|
||||
fileList.length,
|
||||
);
|
||||
}
|
||||
if (imageList.length === 0) imageList = undefined;
|
||||
if (videoList.length === 0) videoList = undefined;
|
||||
if (audioList.length === 0) audioList = undefined;
|
||||
if (fileList.length === 0) fileList = undefined;
|
||||
}
|
||||
|
||||
@@ -612,9 +597,6 @@ export class AiAgentService {
|
||||
if (resolved.videoList.length > 0) {
|
||||
videoList = [...(videoList ?? []), ...resolved.videoList];
|
||||
}
|
||||
if (resolved.audioList.length > 0) {
|
||||
audioList = [...(audioList ?? []), ...resolved.audioList];
|
||||
}
|
||||
if (resolved.fileList.length > 0) {
|
||||
fileList = [...(fileList ?? []), ...resolved.fileList];
|
||||
}
|
||||
@@ -632,7 +614,7 @@ export class AiAgentService {
|
||||
// an empty messagesFiles relation.
|
||||
if (fileIds && fileIds.length === 0) fileIds = undefined;
|
||||
|
||||
return { audioList, fileIds, fileList, imageList, videoList, warnings };
|
||||
return { fileIds, fileList, imageList, videoList, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1916,17 +1898,9 @@ export class AiAgentService {
|
||||
// `isDesktop` uses `gatewayConfigured` as a proxy: a device-gateway
|
||||
// deployment serves desktop-class users, so the unset-target default
|
||||
// resolves to `local` there and `none` otherwise.
|
||||
//
|
||||
// Chat mode is orthogonal to `executionTarget` (the UI toggle only writes
|
||||
// `enableAgentMode`), so a default/stored `local` target would otherwise
|
||||
// resolve a device and `buildStepToolDelta` would re-inject local-system.
|
||||
// Pass `chatConfig` so the plan degrades to `none` in chat mode — the
|
||||
// chat-mode derivation lives in `resolveExecutionPlan` (`resolveToolMode`),
|
||||
// the same source of truth the tools engine uses.
|
||||
executionPlan = resolveExecutionPlan({
|
||||
agencyConfig: agentConfig.agencyConfig,
|
||||
canUseDevice,
|
||||
chatConfig: agentConfig.chatConfig ?? undefined,
|
||||
isDesktop: gatewayConfigured,
|
||||
onlineDeviceIds: onlineDevices.map((device) => device.deviceId),
|
||||
requestedDeviceId,
|
||||
@@ -2442,10 +2416,8 @@ export class AiAgentService {
|
||||
// row created above).
|
||||
// - imageList: vision models render these as image_url parts
|
||||
// - videoList: video-capable models render these as video parts
|
||||
// - audioList: audio-capable models render these as audio parts
|
||||
// - fileList: MessageContentProcessor injects content via filesPrompts() XML
|
||||
const userMessage = {
|
||||
audioList: runAttachments.audioList,
|
||||
content: ephemeralUserMessage ?? prompt,
|
||||
fileList: runAttachments.fileList,
|
||||
id: userMessageRecord?.id,
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface AttachmentSource {
|
||||
|
||||
export interface IngestResult {
|
||||
fileId: string;
|
||||
isAudio: boolean;
|
||||
isImage: boolean;
|
||||
isVideo: boolean;
|
||||
key: string;
|
||||
@@ -150,17 +149,12 @@ export async function ingestAttachment(
|
||||
// MessageContentProcessor can pass the video to vision/video-capable models.
|
||||
const isVideo = !isImage && mimeType.startsWith('video/');
|
||||
|
||||
// Audio is passed through untouched; audio-capable models (e.g. Gemini) receive
|
||||
// it as an inline/file media part instead of being parsed into document text.
|
||||
const isAudio = !isImage && !isVideo && mimeType.startsWith('audio/');
|
||||
|
||||
log(
|
||||
'ingestAttachment: classified name=%s, finalMimeType=%s, isImage=%s, isVideo=%s, isAudio=%s, bufferSize=%d',
|
||||
'ingestAttachment: classified name=%s, finalMimeType=%s, isImage=%s, isVideo=%s, bufferSize=%d',
|
||||
source.name,
|
||||
mimeType,
|
||||
isImage,
|
||||
isVideo,
|
||||
isAudio,
|
||||
buffer.length,
|
||||
);
|
||||
|
||||
@@ -170,11 +164,9 @@ export async function ingestAttachment(
|
||||
const pathname = `files/${userId}/${nanoid()}/${source.name || `file.${ext}`}`;
|
||||
const { fileId, key } = await fileService.uploadFromBuffer(buffer, mimeType, pathname);
|
||||
|
||||
// 5. Resolve access URL for images, videos and audio.
|
||||
// 5. Resolve access URL for images and videos.
|
||||
const resolvedUrl =
|
||||
isImage || isVideo || isAudio
|
||||
? await fileService.getFileAccessUrl({ id: fileId, url: key })
|
||||
: '';
|
||||
isImage || isVideo ? await fileService.getFileAccessUrl({ id: fileId, url: key }) : '';
|
||||
|
||||
log(
|
||||
'ingestAttachment: uploaded fileId=%s, key=%s, resolvedUrl=%s',
|
||||
@@ -183,5 +175,5 @@ export async function ingestAttachment(
|
||||
resolvedUrl ? 'set' : '(empty)',
|
||||
);
|
||||
|
||||
return { fileId, isAudio, isImage, isVideo, key, resolvedUrl };
|
||||
return { fileId, isImage, isVideo, key, resolvedUrl };
|
||||
}
|
||||
|
||||
@@ -769,148 +769,6 @@ describe('DeviceGateway', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('file mutation containment', () => {
|
||||
const configure = () => {
|
||||
mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com';
|
||||
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
|
||||
};
|
||||
|
||||
describe('writeProjectFile', () => {
|
||||
it('invokes the rpc when the path is inside the workspace', async () => {
|
||||
configure();
|
||||
mockClient.invokeRpc.mockResolvedValue({ data: { success: true }, success: true });
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.writeProjectFile({
|
||||
content: 'next',
|
||||
deviceId: 'dev-1',
|
||||
path: '/proj/src/App.tsx',
|
||||
userId: 'user-1',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
|
||||
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
|
||||
{ method: 'writeLocalFile', params: { content: 'next', path: '/proj/src/App.tsx' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws without invoking the rpc when the path escapes the workspace', async () => {
|
||||
configure();
|
||||
const proxy = new DeviceGateway();
|
||||
|
||||
await expect(
|
||||
proxy.writeProjectFile({
|
||||
content: 'pwned',
|
||||
deviceId: 'dev-1',
|
||||
path: '/etc/passwd',
|
||||
userId: 'user-1',
|
||||
workingDirectory: '/proj',
|
||||
}),
|
||||
).rejects.toThrow(/outside the approved workspace/);
|
||||
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a `..` traversal that resolves outside the workspace', async () => {
|
||||
configure();
|
||||
const proxy = new DeviceGateway();
|
||||
|
||||
await expect(
|
||||
proxy.writeProjectFile({
|
||||
content: 'pwned',
|
||||
deviceId: 'dev-1',
|
||||
path: '/proj/../secrets.env',
|
||||
userId: 'user-1',
|
||||
workingDirectory: '/proj',
|
||||
}),
|
||||
).rejects.toThrow(/outside the approved workspace/);
|
||||
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('contains Windows device paths using Windows path semantics', async () => {
|
||||
configure();
|
||||
const proxy = new DeviceGateway();
|
||||
|
||||
await expect(
|
||||
proxy.writeProjectFile({
|
||||
content: 'pwned',
|
||||
deviceId: 'dev-1',
|
||||
path: 'C:\\Windows\\System32\\drivers\\etc\\hosts',
|
||||
userId: 'user-1',
|
||||
workingDirectory: 'C:\\proj',
|
||||
}),
|
||||
).rejects.toThrow(/outside the approved workspace/);
|
||||
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameProjectFile', () => {
|
||||
it('throws without invoking the rpc when the path escapes the workspace', async () => {
|
||||
configure();
|
||||
const proxy = new DeviceGateway();
|
||||
|
||||
await expect(
|
||||
proxy.renameProjectFile({
|
||||
deviceId: 'dev-1',
|
||||
newName: 'evil.ts',
|
||||
path: '/etc/hosts',
|
||||
userId: 'user-1',
|
||||
workingDirectory: '/proj',
|
||||
}),
|
||||
).rejects.toThrow(/outside the approved workspace/);
|
||||
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveProjectFiles', () => {
|
||||
it('throws when any item moves out of the workspace', async () => {
|
||||
configure();
|
||||
const proxy = new DeviceGateway();
|
||||
|
||||
await expect(
|
||||
proxy.moveProjectFiles({
|
||||
deviceId: 'dev-1',
|
||||
items: [
|
||||
{ newPath: '/proj/b.ts', oldPath: '/proj/a.ts' },
|
||||
{ newPath: '/tmp/exfil.ts', oldPath: '/proj/c.ts' },
|
||||
],
|
||||
userId: 'user-1',
|
||||
workingDirectory: '/proj',
|
||||
}),
|
||||
).rejects.toThrow(/outside the approved workspace/);
|
||||
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes the rpc when every item stays inside the workspace', async () => {
|
||||
configure();
|
||||
mockClient.invokeRpc.mockResolvedValue({
|
||||
data: [{ newPath: '/proj/b.ts', sourcePath: '/proj/a.ts', success: true }],
|
||||
success: true,
|
||||
});
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.moveProjectFiles({
|
||||
deviceId: 'dev-1',
|
||||
items: [{ newPath: '/proj/b.ts', oldPath: '/proj/a.ts' }],
|
||||
userId: 'user-1',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ newPath: '/proj/b.ts', sourcePath: '/proj/a.ts', success: true },
|
||||
]);
|
||||
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
|
||||
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
|
||||
{
|
||||
method: 'moveLocalFiles',
|
||||
params: { items: [{ newPath: '/proj/b.ts', oldPath: '/proj/a.ts' }] },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClient (lazy initialization)', () => {
|
||||
it('should return null when URL is missing', async () => {
|
||||
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device';
|
||||
import {
|
||||
type DeviceMessageApiResult,
|
||||
@@ -28,11 +26,7 @@ import type {
|
||||
DeviceGitWorktreeListItem,
|
||||
DeviceListProjectSkillsResult,
|
||||
DeviceLocalFilePreviewResult,
|
||||
DeviceMoveProjectFileItem,
|
||||
DeviceMoveProjectFileResultItem,
|
||||
DeviceProjectFileIndexResult,
|
||||
DeviceRenameProjectFileResult,
|
||||
DeviceWriteProjectFileResult,
|
||||
ProjectSkillMeta,
|
||||
WorkspaceInitResult,
|
||||
} from '@lobechat/types';
|
||||
@@ -42,42 +36,6 @@ import { gatewayEnv } from '@/envs/gateway';
|
||||
|
||||
const log = debug('lobe-server:device-gateway');
|
||||
|
||||
/**
|
||||
* Is `target` the same as, or nested inside, `root`?
|
||||
*
|
||||
* The device's working directory may be a POSIX path (`/Users/…`) or a Windows
|
||||
* path (`C:\…`) while this check runs on the cloud server (POSIX). We pick the
|
||||
* path flavour from the root's shape so a Windows device path is still resolved
|
||||
* with Windows semantics rather than being mangled by `path.posix`.
|
||||
*/
|
||||
export const isPathWithinRoot = (root: string, target: string): boolean => {
|
||||
const p = /^[A-Z]:[/\\]/i.test(root) ? path.win32 : path.posix;
|
||||
if (!p.isAbsolute(root) || !p.isAbsolute(target)) return false;
|
||||
const relative = p.relative(p.resolve(root), p.resolve(target));
|
||||
return relative === '' || (!relative.startsWith('..') && !p.isAbsolute(relative));
|
||||
};
|
||||
|
||||
/**
|
||||
* Guard the web/remote file mutations (move / rename / write) against escaping
|
||||
* the project root. These routes accept absolute paths straight from an
|
||||
* untrusted browser session, so before forwarding them to a device we confirm
|
||||
* every path stays inside the workspace the UI is operating in — otherwise a
|
||||
* caller could bypass the Files tree and mutate arbitrary locations on the
|
||||
* device. Mirrors the read path's `workspaceRoot` containment check.
|
||||
*/
|
||||
const assertPathsWithinWorkspace = (
|
||||
workspaceRoot: string,
|
||||
candidates: Array<string | undefined>,
|
||||
): void => {
|
||||
if (!workspaceRoot) throw new Error('A workspace root is required for file mutations');
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || !isPathWithinRoot(workspaceRoot, candidate)) {
|
||||
throw new Error(`Path is outside the approved workspace: ${candidate ?? '(empty)'}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export type { DeviceAttachment, DeviceStatusResult, DeviceSystemInfo };
|
||||
|
||||
export class DeviceGateway {
|
||||
@@ -725,108 +683,6 @@ export class DeviceGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one or more files/folders within a directory on a remote device, via
|
||||
* the device's `moveLocalFiles` RPC. Powers the Files tree's move in device
|
||||
* mode. Unlike the read RPCs this is a user-initiated mutation, so a missing
|
||||
* gateway / offline device / failed call throws rather than degrading to
|
||||
* `undefined` — the UI surfaces the error instead of silently no-op'ing.
|
||||
*/
|
||||
async moveProjectFiles(params: {
|
||||
deviceId: string;
|
||||
items: DeviceMoveProjectFileItem[];
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<DeviceMoveProjectFileResultItem[]> {
|
||||
const { userId, deviceId, items, workingDirectory, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Device gateway not configured');
|
||||
|
||||
assertPathsWithinWorkspace(
|
||||
workingDirectory,
|
||||
items.flatMap((item) => [item.oldPath, item.newPath]),
|
||||
);
|
||||
|
||||
const result = await client.invokeRpc<DeviceMoveProjectFileResultItem[]>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'moveLocalFiles', params: { items } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('moveProjectFiles: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
throw new Error(result.error || 'Move failed');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a single file/folder in a directory on a remote device, via the
|
||||
* device's `renameLocalFile` RPC. Like `moveProjectFiles`, a transport failure
|
||||
* throws rather than degrading silently.
|
||||
*/
|
||||
async renameProjectFile(params: {
|
||||
deviceId: string;
|
||||
newName: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<DeviceRenameProjectFileResult> {
|
||||
const { userId, deviceId, path, newName, workingDirectory, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Device gateway not configured');
|
||||
|
||||
// The rename stays in the same directory (the device rejects separators in
|
||||
// `newName`), so containing the source path also contains the target.
|
||||
assertPathsWithinWorkspace(workingDirectory, [path]);
|
||||
|
||||
const result = await client.invokeRpc<DeviceRenameProjectFileResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'renameLocalFile', params: { newName, path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('renameProjectFile: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
throw new Error(result.error || 'Rename failed');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save edited content back to a file on a remote device, via the device's
|
||||
* `writeLocalFile` RPC. Powers remote save in the LocalFile editor. Like the
|
||||
* other file mutations, a transport failure throws rather than degrading.
|
||||
*/
|
||||
async writeProjectFile(params: {
|
||||
content: string;
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<DeviceWriteProjectFileResult> {
|
||||
const { userId, deviceId, path, content, workingDirectory, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Device gateway not configured');
|
||||
|
||||
assertPathsWithinWorkspace(workingDirectory, [path]);
|
||||
|
||||
const result = await client.invokeRpc<DeviceWriteProjectFileResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'writeLocalFile', params: { content, path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('writeProjectFile: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
throw new Error(result.error || 'Write failed');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a path exists on the device and is a directory, via the same
|
||||
* generic `invokeRpc` channel as `gitInfo`. Lets a web / remote client
|
||||
|
||||
@@ -806,7 +806,7 @@ describe('DocumentService', () => {
|
||||
it('should reject a workspace save when another member holds the edit lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' }));
|
||||
vi.spyOn(EditLockService.prototype, 'canWrite').mockResolvedValue(false);
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
await expect(wsService.updateDocument('doc-1', { content: 'x' })).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
@@ -818,25 +818,13 @@ describe('DocumentService', () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' });
|
||||
mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' }));
|
||||
vi.spyOn(EditLockService.prototype, 'canWrite').mockResolvedValue(true);
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
await wsService.updateDocument('doc-1', { content: 'x' });
|
||||
|
||||
expect(mockDocumentModel.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks workspace body saves against the provided lock owner id', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' });
|
||||
mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' }));
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'canWrite').mockResolvedValue(true);
|
||||
|
||||
await wsService.updateDocument('doc-1', { content: 'x', lockOwnerId: 'owner-1' });
|
||||
|
||||
expect(guardSpy).toHaveBeenCalledWith('document', 'doc-1', 'owner-1');
|
||||
expect(mockDocumentModel.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a metadata-only save while another member holds the lock (only the body is locked)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' });
|
||||
@@ -844,7 +832,7 @@ describe('DocumentService', () => {
|
||||
mockDocumentModel.findById.mockResolvedValue(
|
||||
createCurrentDocument({ content: 'body', editorData: { blocks: [] }, workspaceId: 'ws-1' }),
|
||||
);
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'canWrite');
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
await wsService.updateDocument('doc-1', {
|
||||
content: 'body',
|
||||
@@ -865,7 +853,7 @@ describe('DocumentService', () => {
|
||||
mockDocumentModel.findById.mockResolvedValue(
|
||||
createCurrentDocument({ editorData: { blocks: [] }, workspaceId: 'ws-1' }),
|
||||
);
|
||||
vi.spyOn(EditLockService.prototype, 'canWrite').mockResolvedValue(false);
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
// editorData changed (historyAppended) → guard runs even with no `content`.
|
||||
await expect(
|
||||
@@ -875,151 +863,13 @@ describe('DocumentService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runWithDocumentLock', () => {
|
||||
it('runs the callback without touching the lock for personal documents', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release');
|
||||
const fn = vi.fn().mockResolvedValue('ok');
|
||||
|
||||
const result = await service.runWithDocumentLock('doc-1', fn);
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
expect(releaseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acquires a free lock, runs the callback, then releases it', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveLock').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: 'server-owner',
|
||||
});
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
const fn = vi.fn().mockResolvedValue('written');
|
||||
|
||||
const result = await wsService.runWithDocumentLock('doc-1', fn);
|
||||
|
||||
expect(result).toBe('written');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(releaseSpy).toHaveBeenCalledWith(
|
||||
'document',
|
||||
'doc-1',
|
||||
expect.stringMatching(/^server:/),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when the same user already holds the lease in another edit session', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveLock').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
ownerId: 'page-owner',
|
||||
userId,
|
||||
});
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: true,
|
||||
ownerId: 'page-owner',
|
||||
});
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release');
|
||||
const fn = vi.fn();
|
||||
|
||||
await expect(wsService.runWithDocumentLock('doc-1', fn)).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
});
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(releaseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects with CONFLICT and skips the callback when another member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveLock').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
ownerId: 'other-owner',
|
||||
userId: 'other-user',
|
||||
});
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: 'other-user',
|
||||
lockedByOther: true,
|
||||
ownerId: 'other-owner',
|
||||
});
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release');
|
||||
const fn = vi.fn();
|
||||
|
||||
await expect(wsService.runWithDocumentLock('doc-1', fn)).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
});
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(releaseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still releases a freshly-claimed lock when the callback throws', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveLock').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: 'server-owner',
|
||||
});
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
const fn = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(wsService.runWithDocumentLock('doc-1', fn)).rejects.toThrow('boom');
|
||||
expect(releaseSpy).toHaveBeenCalledWith(
|
||||
'document',
|
||||
'doc-1',
|
||||
expect.stringMatching(/^server:/),
|
||||
);
|
||||
});
|
||||
|
||||
it('rides along on the user existing lease and skips release', async () => {
|
||||
// The user's live editor already holds the lock. The server run must
|
||||
// refresh under the user's ownerId — not mint a fresh one and release
|
||||
// afterwards — or the editor's next save would be rejected by the
|
||||
// owner-scoped guard, and another collaborator could grab the gap.
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveLock').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
ownerId: 'user-tab-A',
|
||||
userId,
|
||||
});
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: 'user-tab-A',
|
||||
});
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release');
|
||||
const fn = vi.fn().mockResolvedValue('written');
|
||||
|
||||
const result = await wsService.runWithDocumentLock('doc-1', fn);
|
||||
|
||||
expect(result).toBe('written');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(acquireSpy).toHaveBeenCalledWith('document', 'doc-1', 'user-tab-A');
|
||||
expect(releaseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('document edit lock', () => {
|
||||
it('reports unlocked for personal documents without touching the lock service', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const result = await service.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
ownerId: null,
|
||||
});
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1028,17 +878,12 @@ describe('DocumentService', () => {
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
const acquireSpy = vi
|
||||
.spyOn(EditLockService.prototype, 'acquire')
|
||||
.mockResolvedValue({ expiresAt, holderId: userId, lockedByOther: false, ownerId: userId });
|
||||
.mockResolvedValue({ expiresAt, holderId: userId, lockedByOther: false });
|
||||
|
||||
const result = await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(acquireSpy).toHaveBeenCalledWith('document', 'doc-1', userId);
|
||||
expect(result).toEqual({
|
||||
expiresAt,
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: userId,
|
||||
});
|
||||
expect(acquireSpy).toHaveBeenCalledWith('document', 'doc-1');
|
||||
expect(result).toEqual({ expiresAt, holderId: userId, lockedByOther: false });
|
||||
});
|
||||
|
||||
it('reports another member as holder when the lock is taken', async () => {
|
||||
@@ -1048,17 +893,11 @@ describe('DocumentService', () => {
|
||||
expiresAt,
|
||||
holderId: 'other-user',
|
||||
lockedByOther: true,
|
||||
ownerId: 'other-owner',
|
||||
});
|
||||
|
||||
const result = await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
expiresAt,
|
||||
holderId: 'other-user',
|
||||
lockedByOther: true,
|
||||
ownerId: 'other-owner',
|
||||
});
|
||||
expect(result).toEqual({ expiresAt, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('releaseDocumentLock is a no-op for personal documents', async () => {
|
||||
@@ -1071,42 +910,33 @@ describe('DocumentService', () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
expect(releaseSpy).toHaveBeenCalledWith('document', 'doc-1', userId);
|
||||
expect(releaseSpy).toHaveBeenCalledWith('document', 'doc-1');
|
||||
});
|
||||
|
||||
it('acquireDocumentLock broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveLock').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: userId,
|
||||
});
|
||||
|
||||
await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ holderId: userId, ownerId: userId }),
|
||||
type: 'lock.changed',
|
||||
}),
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('acquireDocumentLock does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveLock').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
ownerId: userId,
|
||||
userId,
|
||||
});
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
ownerId: userId,
|
||||
});
|
||||
|
||||
await wsService.acquireDocumentLock('doc-1');
|
||||
@@ -1122,10 +952,7 @@ describe('DocumentService', () => {
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ holderId: null, ownerId: null }),
|
||||
type: 'lock.changed',
|
||||
}),
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1183,7 +1010,7 @@ describe('DocumentService', () => {
|
||||
|
||||
it('does not check the lock for personal documents', async () => {
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'canWrite');
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
await service.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call');
|
||||
|
||||
@@ -1194,7 +1021,7 @@ describe('DocumentService', () => {
|
||||
it('rejects a workspace history snapshot when another member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
vi.spyOn(EditLockService.prototype, 'canWrite').mockResolvedValue(false);
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
await expect(
|
||||
wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call'),
|
||||
@@ -1205,22 +1032,12 @@ describe('DocumentService', () => {
|
||||
it('allows a workspace history snapshot when no other member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
vi.spyOn(EditLockService.prototype, 'canWrite').mockResolvedValue(true);
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
await wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call');
|
||||
|
||||
expect(mockDocumentHistoryService.createHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards the lock owner so the holder can snapshot its own page', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'canWrite').mockResolvedValue(true);
|
||||
|
||||
await wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call', 'page-owner-1');
|
||||
|
||||
expect(guardSpy).toHaveBeenCalledWith('document', 'doc-1', 'page-owner-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trySaveCurrentDocumentHistory', () => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { CUSTOM_DOCUMENT_FILE_TYPE, CUSTOM_FOLDER_FILE_TYPE } from '@lobechat/const';
|
||||
import { type LobeChatDatabase } from '@lobechat/database';
|
||||
import { type DocumentItem } from '@lobechat/database/schemas';
|
||||
@@ -218,34 +216,18 @@ export class DocumentService {
|
||||
* always report as unlocked.
|
||||
*/
|
||||
async acquireDocumentLock(id: string): Promise<DocumentLockResult> {
|
||||
return this.acquireDocumentLockWithOwner(id, this.userId);
|
||||
}
|
||||
if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
|
||||
async acquireDocumentLockWithOwner(id: string, ownerId: string): Promise<DocumentLockResult> {
|
||||
if (!this.workspaceId)
|
||||
return { expiresAt: null, holderId: null, lockedByOther: false, ownerId: null };
|
||||
|
||||
const prevHolder = await this.editLockService.getActiveLock('document', id);
|
||||
const result = await this.editLockService.acquire('document', id, ownerId);
|
||||
const prevHolder = await this.editLockService.getActiveHolder('document', id);
|
||||
const result = await this.editLockService.acquire('document', id);
|
||||
|
||||
// Broadcast only on a holder edge (first claim / takeover). This method also
|
||||
// serves the periodic heartbeat, so a steady-state refresh (same holder)
|
||||
// must not emit an event.
|
||||
if (
|
||||
(result.holderId ?? null) !== (prevHolder?.userId ?? null) ||
|
||||
(result.ownerId ?? null) !== (prevHolder?.ownerId ?? null)
|
||||
) {
|
||||
if ((result.holderId ?? null) !== (prevHolder ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{
|
||||
actorId: this.userId,
|
||||
data: {
|
||||
expiresAt: result.expiresAt?.toISOString() ?? null,
|
||||
holderId: result.holderId,
|
||||
ownerId: result.ownerId,
|
||||
},
|
||||
type: 'lock.changed',
|
||||
},
|
||||
{ actorId: this.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,20 +238,13 @@ export class DocumentService {
|
||||
* Read-only peek of the current edit lock (does not acquire). Lets a client
|
||||
* render a workspace page read-only on open when another member holds it.
|
||||
*/
|
||||
async getDocumentLock(id: string, ownerId?: string): Promise<DocumentLockResult> {
|
||||
if (!this.workspaceId)
|
||||
return { expiresAt: null, holderId: null, lockedByOther: false, ownerId: null };
|
||||
const holder = await this.editLockService.getActiveLock('document', id);
|
||||
const lockedByOther = holder
|
||||
? holder.ownerId
|
||||
? holder.ownerId !== ownerId
|
||||
: holder.userId !== this.userId
|
||||
: false;
|
||||
async getDocumentLock(id: string): Promise<DocumentLockResult> {
|
||||
if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await this.editLockService.getActiveHolder('document', id);
|
||||
return {
|
||||
expiresAt: holder?.expiresAt ?? null,
|
||||
holderId: holder?.userId ?? null,
|
||||
lockedByOther,
|
||||
ownerId: holder?.ownerId ?? null,
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== this.userId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,87 +252,18 @@ export class DocumentService {
|
||||
* Release the edit lock if the current user holds it. No-op in personal mode.
|
||||
*/
|
||||
async releaseDocumentLock(id: string): Promise<void> {
|
||||
return this.releaseDocumentLockWithOwner(id, this.userId);
|
||||
}
|
||||
|
||||
async releaseDocumentLockWithOwner(id: string, ownerId: string): Promise<void> {
|
||||
if (!this.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease had expired and another member took over, the lock is still held and
|
||||
// a bogus holderId:null would wrongly flip their viewers to editable.
|
||||
const released = await this.editLockService.release('document', id, ownerId);
|
||||
const released = await this.editLockService.release('document', id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{
|
||||
actorId: this.userId,
|
||||
data: { expiresAt: null, holderId: null, ownerId: null },
|
||||
type: 'lock.changed',
|
||||
},
|
||||
{ actorId: this.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a server-initiated read-modify-write (e.g. a Page Agent tool) under the
|
||||
* collaborative edit lock. Acquiring the lock up front — rather than only
|
||||
* checking it at persist time like {@link updateDocument} — serializes agent
|
||||
* writes against other workspace members and rejects when someone else is
|
||||
* actively editing, so an agent can no longer silently clobber a human's
|
||||
* in-progress edits or another concurrent agent write.
|
||||
*
|
||||
* No-op in personal mode (no workspace → no collaboration → no lock). When
|
||||
* Redis is down the underlying lock degrades to "unlocked" (fail-open), so
|
||||
* this never blocks a write.
|
||||
*/
|
||||
async runWithDocumentLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
||||
if (!this.workspaceId) {
|
||||
// Diagnostic: distinguishes "no-op because workspaceId is
|
||||
// missing at runtime" from "lock actually evaluated".
|
||||
log('runWithDocumentLock skip: no workspaceId (id=%s userId=%s)', id, this.userId);
|
||||
return fn();
|
||||
}
|
||||
|
||||
// If this user's live editor already holds the lease, ride along on the
|
||||
// same ownerId so the acquire below is a pure heartbeat. Stealing the lock
|
||||
// with a fresh `server:UUID` would silently rewrite the lease's ownerId,
|
||||
// demote the user's saves through the owner-scoped write guard, and on the
|
||||
// finally release leave a window where another member could grab the free
|
||||
// lock. When we're truly claiming a lock, mint a server-scoped owner id
|
||||
// we can identify in release.
|
||||
const holderBefore = await this.editLockService.getActiveLock('document', id);
|
||||
const heldBeforeByUser = holderBefore?.userId === this.userId;
|
||||
const ownerId =
|
||||
heldBeforeByUser && holderBefore?.ownerId ? holderBefore.ownerId : `server:${randomUUID()}`;
|
||||
|
||||
const lock = await this.acquireDocumentLockWithOwner(id, ownerId);
|
||||
// Diagnostic: surfaces workspaceId/holder/acquire for debugging lock issues.
|
||||
log(
|
||||
'runWithDocumentLock: id=%s userId=%s ws=%s holderBefore=%s acquired=%o',
|
||||
id,
|
||||
this.userId,
|
||||
this.workspaceId,
|
||||
holderBefore?.userId,
|
||||
lock,
|
||||
);
|
||||
if (lock.lockedByOther) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Document is being edited by another user',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
// Only release a lease we freshly claimed. When the same user already
|
||||
// held it, leave their session alive — releasing would briefly flip
|
||||
// their editor to read-only and let another member grab the lock in
|
||||
// the gap before the next client heartbeat.
|
||||
if (!heldBeforeByUser) await this.releaseDocumentLockWithOwner(id, ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
async listDocumentHistory(
|
||||
params: ListDocumentHistoryParams,
|
||||
options?: DocumentHistoryAccessOptions,
|
||||
@@ -386,7 +292,6 @@ export class DocumentService {
|
||||
documentId: string,
|
||||
editorData: Record<string, any>,
|
||||
saveSource: DocumentHistorySaveSource,
|
||||
lockOwnerId?: string,
|
||||
): Promise<SaveDocumentHistoryResult> {
|
||||
const currentDocument = await this.documentModel.findById(documentId);
|
||||
if (!currentDocument) {
|
||||
@@ -396,12 +301,10 @@ export class DocumentService {
|
||||
// Same collaborative edit-lock guard as updateDocument: don't record a
|
||||
// history snapshot for a workspace document another member is editing, so a
|
||||
// locked-out actor (e.g. a Copilot mutation that will itself be rejected)
|
||||
// can't pollute the version timeline. The lock holder forwards its
|
||||
// `lockOwnerId` so it can still snapshot its own page (e.g. the pre-mutation
|
||||
// snapshot a Copilot edit takes) without being blocked by its own lease.
|
||||
// can't pollute the version timeline.
|
||||
if (this.workspaceId) {
|
||||
const canWrite = await this.editLockService.canWrite('document', documentId, lockOwnerId);
|
||||
if (!canWrite) {
|
||||
const blockedBy = await this.editLockService.getBlockingHolder('document', documentId);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
@@ -546,8 +449,8 @@ export class DocumentService {
|
||||
historyAppended ||
|
||||
(params.content !== undefined && params.content !== currentDocument.content);
|
||||
if (this.workspaceId && contentChanged) {
|
||||
const canWrite = await this.editLockService.canWrite('document', id, params.lockOwnerId);
|
||||
if (!canWrite) {
|
||||
const blockedBy = await this.editLockService.getBlockingHolder('document', id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
|
||||
@@ -59,7 +59,6 @@ export interface UpdateDocumentParams {
|
||||
content?: string;
|
||||
editorData?: Record<string, any>;
|
||||
fileType?: string;
|
||||
lockOwnerId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
parentId?: string | null;
|
||||
restoreFromHistoryId?: string;
|
||||
@@ -84,6 +83,4 @@ export interface DocumentLockResult {
|
||||
holderId: string | null;
|
||||
/** True when another active user holds the lock (caller is locked out). */
|
||||
lockedByOther: boolean;
|
||||
/** The edit-session id currently holding the lock, or null when unlocked / legacy. */
|
||||
ownerId: string | null;
|
||||
}
|
||||
|
||||
@@ -5,28 +5,17 @@ import { EditLockService } from '../index';
|
||||
/**
|
||||
* Minimal in-memory fake of the ioredis calls EditLockService uses:
|
||||
* `set(k, v, 'EX', ttl[, 'NX'])`, `get(k)`, and the compare-and-delete `eval`.
|
||||
* The eval mirrors RELEASE_SCRIPT: legacy raw payloads delete when ARGV[2]
|
||||
* (userId) matches; JSON payloads require both userId and ownerId to match.
|
||||
*/
|
||||
const makeFakeRedis = () => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
eval: vi.fn(
|
||||
async (_script: string, _numKeys: number, key: string, ownerArg: string, userArg: string) => {
|
||||
const raw = store.get(key);
|
||||
if (!raw) return 0;
|
||||
let matches = raw === userArg;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
matches = matches || (parsed.userId === userArg && parsed.ownerId === ownerArg);
|
||||
} catch {}
|
||||
if (matches) {
|
||||
store.delete(key);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
),
|
||||
eval: vi.fn(async (_script: string, _numKeys: number, key: string, arg: string) => {
|
||||
if (store.get(key) === arg) {
|
||||
store.delete(key);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string, ...args: unknown[]) => {
|
||||
if (args.includes('NX') && store.has(key)) return null;
|
||||
@@ -42,106 +31,41 @@ describe('EditLockService', () => {
|
||||
const redis = makeFakeRedis();
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
|
||||
const result = await svc.acquire('document', 'doc-1', 'owner-1');
|
||||
const result = await svc.acquire('document', 'doc-1');
|
||||
|
||||
expect(result.holderId).toBe('user-1');
|
||||
expect(result.ownerId).toBe('owner-1');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
expect(JSON.parse(redis.store.get('editlock:document:doc-1')!)).toEqual(
|
||||
expect.objectContaining({ ownerId: 'owner-1', userId: 'user-1' }),
|
||||
);
|
||||
expect(redis.store.get('editlock:document:doc-1')).toBe('user-1');
|
||||
});
|
||||
|
||||
it('reports another member as holder when the lock is already taken', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1', 'owner-1');
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
const result = await new EditLockService('user-2', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ holderId: 'user-1', lockedByOther: true, ownerId: 'owner-1' }),
|
||||
);
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'user-1', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('lets the holder refresh their own lease', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
await svc.acquire('document', 'doc-1', 'owner-1');
|
||||
await svc.acquire('document', 'doc-1');
|
||||
|
||||
const result = await svc.acquire('document', 'doc-1', 'owner-1');
|
||||
const result = await svc.acquire('document', 'doc-1');
|
||||
|
||||
expect(result.holderId).toBe('user-1');
|
||||
expect(result.ownerId).toBe('owner-1');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
});
|
||||
|
||||
it('lets the same user take over their own ghost lock from another session', async () => {
|
||||
// A refresh / navigate-away whose release never reached the server leaves a
|
||||
// stale ownerId in Redis. The new session should silently take over rather
|
||||
// than report "you're editing this in another tab" — the old session is
|
||||
// almost certainly gone.
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1', 'owner-1');
|
||||
|
||||
const result = await new EditLockService('user-1', redis as any).acquire(
|
||||
'document',
|
||||
'doc-1',
|
||||
'owner-2',
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ holderId: 'user-1', lockedByOther: false, ownerId: 'owner-2' }),
|
||||
);
|
||||
expect(JSON.parse(redis.store.get('editlock:document:doc-1')!)).toEqual(
|
||||
expect.objectContaining({ ownerId: 'owner-2', userId: 'user-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('still treats a different user with a different owner as blocked (takeover is user-scoped)', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1', 'owner-1');
|
||||
|
||||
const result = await new EditLockService('user-2', redis as any).acquire(
|
||||
'document',
|
||||
'doc-1',
|
||||
'owner-2',
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ holderId: 'user-1', lockedByOther: true, ownerId: 'owner-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to refresh when a stranger replays the broadcast ownerId', async () => {
|
||||
// The ownerId is broadcast on `lock.changed`, so another workspace member can
|
||||
// learn it from a subscription. They must not be able to echo it back to
|
||||
// refresh or take over the lock — only the original holder's userId may.
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1', 'owner-1');
|
||||
|
||||
const result = await new EditLockService('user-2', redis as any).acquire(
|
||||
'document',
|
||||
'doc-1',
|
||||
'owner-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ holderId: 'user-1', lockedByOther: true, ownerId: 'owner-1' }),
|
||||
);
|
||||
// The persisted lock must still belong to user-1.
|
||||
expect(JSON.parse(redis.store.get('editlock:document:doc-1')!)).toEqual(
|
||||
expect.objectContaining({ ownerId: 'owner-1', userId: 'user-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('getActiveHolder reports the current holder, or undefined when free', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getActiveHolder('document', 'doc-1'),
|
||||
).toBeUndefined();
|
||||
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1', 'owner-1');
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getActiveHolder('document', 'doc-1'),
|
||||
).toBe('user-1');
|
||||
@@ -149,24 +73,20 @@ describe('EditLockService', () => {
|
||||
|
||||
it('keys locks per resource type, so the same id does not collide across types', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'shared-id', 'owner-1');
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'shared-id');
|
||||
|
||||
// A different resource family with the same id is independently lockable.
|
||||
const result = await new EditLockService('user-2', redis as any).acquire('agent', 'shared-id');
|
||||
|
||||
expect(result.holderId).toBe('user-2');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
expect(JSON.parse(redis.store.get('editlock:document:shared-id')!)).toEqual(
|
||||
expect.objectContaining({ userId: 'user-1' }),
|
||||
);
|
||||
expect(JSON.parse(redis.store.get('editlock:agent:shared-id')!)).toEqual(
|
||||
expect.objectContaining({ userId: 'user-2' }),
|
||||
);
|
||||
expect(redis.store.get('editlock:document:shared-id')).toBe('user-1');
|
||||
expect(redis.store.get('editlock:agent:shared-id')).toBe('user-2');
|
||||
});
|
||||
|
||||
it('getBlockingHolder returns the holder only when it is someone else', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1', 'owner-1');
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getBlockingHolder('document', 'doc-1'),
|
||||
@@ -174,82 +94,25 @@ describe('EditLockService', () => {
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getBlockingHolder('document', 'doc-1'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getBlockingHolder(
|
||||
'document',
|
||||
'doc-1',
|
||||
'owner-1',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getBlockingHolder(
|
||||
'document',
|
||||
'doc-1',
|
||||
'owner-2',
|
||||
),
|
||||
).toBe('user-1');
|
||||
// Stranger replaying the broadcast ownerId must still be blocked.
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getBlockingHolder(
|
||||
'document',
|
||||
'doc-1',
|
||||
'owner-1',
|
||||
),
|
||||
).toBe('user-1');
|
||||
});
|
||||
|
||||
it('only releases the lock for the current owner', async () => {
|
||||
it('only releases the lock for the current holder', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1', 'owner-1');
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
// A non-owner release is a no-op and reports it did not release.
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).release('document', 'doc-1', 'owner-2'),
|
||||
).toBe(false);
|
||||
expect(JSON.parse(redis.store.get('editlock:document:doc-1')!)).toEqual(
|
||||
expect.objectContaining({ ownerId: 'owner-1' }),
|
||||
// A non-holder release is a no-op and reports it did not release.
|
||||
expect(await new EditLockService('user-2', redis as any).release('document', 'doc-1')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(redis.store.get('editlock:document:doc-1')).toBe('user-1');
|
||||
|
||||
// The owner can release, and reports the lock was actually freed.
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).release('document', 'doc-1', 'owner-1'),
|
||||
).toBe(true);
|
||||
// The holder can release, and reports the lock was actually freed.
|
||||
expect(await new EditLockService('user-1', redis as any).release('document', 'doc-1')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(redis.store.has('editlock:document:doc-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to release when a stranger replays the broadcast ownerId', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1', 'owner-1');
|
||||
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).release('document', 'doc-1', 'owner-1'),
|
||||
).toBe(false);
|
||||
expect(JSON.parse(redis.store.get('editlock:document:doc-1')!)).toEqual(
|
||||
expect.objectContaining({ ownerId: 'owner-1', userId: 'user-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a matching owner id for owner-scoped writes', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
await svc.acquire('document', 'doc-1', 'owner-1');
|
||||
|
||||
await expect(svc.canWrite('document', 'doc-1', 'owner-1')).resolves.toBe(true);
|
||||
await expect(svc.canWrite('document', 'doc-1', 'owner-2')).resolves.toBe(false);
|
||||
await expect(svc.canWrite('document', 'doc-1')).resolves.toBe(false);
|
||||
redis.store.delete('editlock:document:doc-1');
|
||||
await expect(svc.canWrite('document', 'doc-1', 'owner-1')).resolves.toBe(false);
|
||||
await expect(svc.canWrite('document', 'doc-1')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('refuses canWrite when a stranger replays the broadcast ownerId', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1', 'owner-1');
|
||||
|
||||
const stranger = new EditLockService('user-2', redis as any);
|
||||
await expect(stranger.canWrite('document', 'doc-1', 'owner-1')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to unlocked / no-op when Redis is unavailable', async () => {
|
||||
const svc = new EditLockService('user-1', null);
|
||||
|
||||
@@ -257,7 +120,6 @@ describe('EditLockService', () => {
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
ownerId: null,
|
||||
});
|
||||
expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull();
|
||||
await expect(svc.release('document', 'doc-1')).resolves.toBe(false);
|
||||
@@ -278,11 +140,9 @@ describe('EditLockService', () => {
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
ownerId: null,
|
||||
});
|
||||
expect(await svc.getActiveHolder('document', 'doc-1')).toBeUndefined();
|
||||
expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull();
|
||||
await expect(svc.canWrite('document', 'doc-1', 'owner-1')).resolves.toBe(true);
|
||||
await expect(svc.release('document', 'doc-1')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,69 +18,21 @@ export interface EditLockResult {
|
||||
holderId: string | null;
|
||||
/** True when another user holds the lock (caller is locked out). */
|
||||
lockedByOther: boolean;
|
||||
/** The edit-session id currently holding the lock, or null for legacy/unlocked. */
|
||||
ownerId: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveEditLock {
|
||||
expiresAt: Date | null;
|
||||
ownerId: string | null;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const UNLOCKED: EditLockResult = {
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
ownerId: null,
|
||||
};
|
||||
const UNLOCKED: EditLockResult = { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
|
||||
const lockKey = (type: EditLockResourceType, id: string) => `editlock:${type}:${id}`;
|
||||
|
||||
// Release only if the caller still holds the lock (compare-and-delete), so a
|
||||
// stale releaser can't drop a lease another member has since taken over. The
|
||||
// ownerId is broadcast on lock.changed, so it can't be used as a capability on
|
||||
// its own — we also bind to the caller's userId (ARGV[2]) so a stranger who
|
||||
// learned the ownerId from a broadcast cannot release another member's lock.
|
||||
// stale releaser can't drop a lease another member has since taken over.
|
||||
const RELEASE_SCRIPT = `
|
||||
local raw = redis.call('get', KEYS[1])
|
||||
if not raw then
|
||||
return 0
|
||||
end
|
||||
if raw == ARGV[2] then
|
||||
return redis.call('del', KEYS[1])
|
||||
end
|
||||
local ok, decoded = pcall(cjson.decode, raw)
|
||||
if ok and decoded["userId"] == ARGV[2] and decoded["ownerId"] == ARGV[1] then
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
const parseStoredLock = (raw: string): ActiveEditLock => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
expiresAt?: unknown;
|
||||
ownerId?: unknown;
|
||||
userId?: unknown;
|
||||
};
|
||||
if (typeof parsed.userId === 'string') {
|
||||
const expiresAt = typeof parsed.expiresAt === 'string' ? new Date(parsed.expiresAt) : null;
|
||||
|
||||
return {
|
||||
expiresAt: expiresAt && !Number.isNaN(expiresAt.getTime()) ? expiresAt : null,
|
||||
ownerId: typeof parsed.ownerId === 'string' ? parsed.ownerId : null,
|
||||
userId: parsed.userId,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Existing deployments may still have raw user-id values in Redis. Treat
|
||||
// them as legacy locks so rolling deploys do not temporarily unlock pages.
|
||||
}
|
||||
|
||||
return { expiresAt: null, ownerId: null, userId: raw };
|
||||
};
|
||||
|
||||
/**
|
||||
* Redis-backed collaborative edit lock, keyed by (resourceType, resourceId).
|
||||
*
|
||||
@@ -121,49 +73,28 @@ export class EditLockService {
|
||||
* Acquire the lock when it is free (or already mine), refreshing the lease;
|
||||
* otherwise report whoever currently holds it. Doubles as the heartbeat.
|
||||
*/
|
||||
async acquire(
|
||||
type: EditLockResourceType,
|
||||
id: string,
|
||||
ownerId = this.userId,
|
||||
): Promise<EditLockResult> {
|
||||
async acquire(type: EditLockResourceType, id: string): Promise<EditLockResult> {
|
||||
const redis = this.redis;
|
||||
if (!redis) return UNLOCKED;
|
||||
const key = lockKey(type, id);
|
||||
|
||||
try {
|
||||
const nextLock = this.serialize(ownerId);
|
||||
// Claim only when the key is absent (NX). The TTL gives automatic expiry, so
|
||||
// a hard-closed tab frees the lock without any cleanup job.
|
||||
const claimed = await redis.set(key, nextLock, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
if (claimed) return this.held(ownerId);
|
||||
const claimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
if (claimed) return this.held();
|
||||
|
||||
const raw = await redis.get(key);
|
||||
if (raw) {
|
||||
const holder = parseStoredLock(raw);
|
||||
// Owner-only matches are unsafe: ownerId is fanned out on lock.changed,
|
||||
// so a workspace member could echo a stranger's ownerId back to steal
|
||||
// the lock. Bind ownership to the calling userId. When the same user
|
||||
// shows up with a different ownerId (refresh, crashed tab, HMR), the
|
||||
// old session is almost certainly a ghost — silently take over with
|
||||
// the new owner rather than telling the user they're editing in
|
||||
// another tab. Two truly concurrent tabs will keep flipping the owner
|
||||
// on their own heartbeats — that's CRDT territory, not ours to police.
|
||||
if (holder.userId === this.userId) {
|
||||
await redis.set(key, nextLock, 'EX', EDIT_LOCK_TTL_SECONDS);
|
||||
return this.held(ownerId);
|
||||
}
|
||||
|
||||
return {
|
||||
expiresAt: holder.expiresAt,
|
||||
holderId: holder.userId,
|
||||
lockedByOther: true,
|
||||
ownerId: holder.ownerId,
|
||||
};
|
||||
const holder = await redis.get(key);
|
||||
if (holder === this.userId) {
|
||||
// Already mine — refresh the lease (heartbeat).
|
||||
await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS);
|
||||
return this.held();
|
||||
}
|
||||
if (holder) return { expiresAt: null, holderId: holder, lockedByOther: true };
|
||||
|
||||
// Freed between the NX and the GET — try once more.
|
||||
const reclaimed = await redis.set(key, nextLock, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
return reclaimed ? this.held(ownerId) : UNLOCKED;
|
||||
const reclaimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
return reclaimed ? this.held() : UNLOCKED;
|
||||
} catch (error) {
|
||||
// Fail-open: a Redis outage (configured but unreachable) must never block
|
||||
// editing — report unlocked rather than surfacing the command rejection.
|
||||
@@ -174,16 +105,11 @@ export class EditLockService {
|
||||
|
||||
/** Current holder of the lock, or undefined when unlocked / Redis is down. */
|
||||
async getActiveHolder(type: EditLockResourceType, id: string): Promise<string | undefined> {
|
||||
return (await this.getActiveLock(type, id))?.userId;
|
||||
}
|
||||
|
||||
/** Current lock payload, or undefined when unlocked / Redis is down. */
|
||||
async getActiveLock(type: EditLockResourceType, id: string): Promise<ActiveEditLock | undefined> {
|
||||
const redis = this.redis;
|
||||
if (!redis) return undefined;
|
||||
try {
|
||||
const holder = await redis.get(lockKey(type, id));
|
||||
return holder ? parseStoredLock(holder) : undefined;
|
||||
return holder ?? undefined;
|
||||
} catch (error) {
|
||||
// Fail-open: a Redis outage must not turn the write guards into 500s.
|
||||
log('getActiveHolder failed for %s:%s %O', type, id, error);
|
||||
@@ -195,53 +121,9 @@ export class EditLockService {
|
||||
* The holder when someone *other* than the caller holds the lock, else null.
|
||||
* Used by write guards; returns null when Redis is down (fail-open).
|
||||
*/
|
||||
async getBlockingHolder(
|
||||
type: EditLockResourceType,
|
||||
id: string,
|
||||
ownerId?: string,
|
||||
): Promise<string | null> {
|
||||
const holder = await this.getActiveLock(type, id);
|
||||
if (!holder) return null;
|
||||
// ownerId is broadcast on lock.changed; it can't authorize on its own.
|
||||
// Bind to userId first. When callers pass an ownerId, also keep the
|
||||
// stale-tab guard (same user, different active ownerId blocks so a ghost tab
|
||||
// can't save over a newer one). Callers without owner-scoped writes only
|
||||
// need to reject other members; otherwise they would block their own generic
|
||||
// metadata updates while holding the edit lock.
|
||||
if (holder.userId !== this.userId) return holder.userId;
|
||||
if (!ownerId) return null;
|
||||
if (holder.ownerId && holder.ownerId !== ownerId) return holder.userId;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a content write against the current lease. When a caller provides
|
||||
* an owner id, the active Redis lock must still belong to that owner; otherwise
|
||||
* a stale tab whose lease expired could save over a newer editor. Without an
|
||||
* owner id, this preserves the advisory-lock behavior: writes are allowed only
|
||||
* when no modern owner-scoped lock is active (legacy same-user locks remain
|
||||
* compatible during rolling deploys).
|
||||
*/
|
||||
async canWrite(type: EditLockResourceType, id: string, ownerId?: string): Promise<boolean> {
|
||||
const redis = this.redis;
|
||||
if (!redis) return true;
|
||||
try {
|
||||
const raw = await redis.get(lockKey(type, id));
|
||||
if (!raw) return !ownerId;
|
||||
|
||||
const holder = parseStoredLock(raw);
|
||||
// ownerId is broadcast on lock.changed; matching it alone isn't proof of
|
||||
// ownership. Bind the write to the calling userId before honoring the
|
||||
// owner-scoped match.
|
||||
if (holder.userId !== this.userId) return false;
|
||||
if (holder.ownerId) return holder.ownerId === ownerId;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
log('canWrite failed for %s:%s %O', type, id, error);
|
||||
return true;
|
||||
}
|
||||
async getBlockingHolder(type: EditLockResourceType, id: string): Promise<string | null> {
|
||||
const holder = await this.getActiveHolder(type, id);
|
||||
return holder && holder !== this.userId ? holder : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,16 +132,10 @@ export class EditLockService {
|
||||
* the lease had already expired or another member has since taken it over, so
|
||||
* callers can avoid broadcasting a bogus "unlocked" event.
|
||||
*/
|
||||
async release(type: EditLockResourceType, id: string, ownerId = this.userId): Promise<boolean> {
|
||||
async release(type: EditLockResourceType, id: string): Promise<boolean> {
|
||||
if (!this.redis) return false;
|
||||
try {
|
||||
const deleted = await this.redis.eval(
|
||||
RELEASE_SCRIPT,
|
||||
1,
|
||||
lockKey(type, id),
|
||||
ownerId,
|
||||
this.userId,
|
||||
);
|
||||
const deleted = await this.redis.eval(RELEASE_SCRIPT, 1, lockKey(type, id), this.userId);
|
||||
return deleted === 1;
|
||||
} catch (error) {
|
||||
log('release failed for %s:%s %O', type, id, error);
|
||||
@@ -267,20 +143,11 @@ export class EditLockService {
|
||||
}
|
||||
}
|
||||
|
||||
private held(ownerId: string): EditLockResult {
|
||||
private held(): EditLockResult {
|
||||
return {
|
||||
expiresAt: new Date(Date.now() + EDIT_LOCK_TTL_SECONDS * 1000),
|
||||
holderId: this.userId,
|
||||
lockedByOther: false,
|
||||
ownerId,
|
||||
};
|
||||
}
|
||||
|
||||
private serialize(ownerId: string): string {
|
||||
return JSON.stringify({
|
||||
expiresAt: new Date(Date.now() + EDIT_LOCK_TTL_SECONDS * 1000).toISOString(),
|
||||
ownerId,
|
||||
userId: this.userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import type { ChatAudioItem, ChatFileItem, ChatImageItem, ChatVideoItem } from '@lobechat/types';
|
||||
import type { ChatFileItem, ChatImageItem, ChatVideoItem } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { FileModel } from '@/database/models/file';
|
||||
@@ -9,7 +9,6 @@ import { FileService } from '@/server/services/file';
|
||||
const log = debug('lobe-server:resolveAttachments');
|
||||
|
||||
export interface ResolvedAttachments {
|
||||
audioList: ChatAudioItem[];
|
||||
fileList: ChatFileItem[];
|
||||
imageList: ChatImageItem[];
|
||||
/**
|
||||
@@ -46,7 +45,6 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
workspaceId,
|
||||
}: ResolveArgs): Promise<ResolvedAttachments> => {
|
||||
const result: ResolvedAttachments = {
|
||||
audioList: [],
|
||||
fileList: [],
|
||||
imageList: [],
|
||||
orderedFileIds: [],
|
||||
@@ -78,11 +76,7 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
}
|
||||
const resolvedUrl = (await fileService.getFullFileUrl(file.url)) || file.url;
|
||||
const fileType = file.fileType || '';
|
||||
if (
|
||||
fileType.startsWith('image') ||
|
||||
fileType.startsWith('video') ||
|
||||
fileType.startsWith('audio')
|
||||
) {
|
||||
if (fileType.startsWith('image') || fileType.startsWith('video')) {
|
||||
return { file, fileType, id, resolvedUrl };
|
||||
}
|
||||
let content: string | undefined;
|
||||
@@ -112,10 +106,6 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
result.videoList.push({ alt: file.name || 'video', id: file.id, url: resolvedUrl });
|
||||
continue;
|
||||
}
|
||||
if (fileType.startsWith('audio')) {
|
||||
result.audioList.push({ alt: file.name || 'audio', id: file.id, url: resolvedUrl });
|
||||
continue;
|
||||
}
|
||||
if (entry.parseError) {
|
||||
log('parseFile failed for %s (id=%s): %O', file.name, file.id, entry.parseError);
|
||||
result.warnings.push(
|
||||
@@ -133,11 +123,10 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
}
|
||||
|
||||
log(
|
||||
'resolved %d attachment(s) (%d images, %d videos, %d audios, %d documents)',
|
||||
'resolved %d attachment(s) (%d images, %d videos, %d documents)',
|
||||
fileRecords.length,
|
||||
result.imageList.length,
|
||||
result.videoList.length,
|
||||
result.audioList.length,
|
||||
result.fileList.length,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createIoRedisState } from '@chat-adapter/state-ioredis';
|
||||
import { INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import {
|
||||
Chat,
|
||||
ConsoleLogger,
|
||||
@@ -7,14 +8,16 @@ import {
|
||||
type SlashCommandEvent,
|
||||
} from 'chat';
|
||||
import debug from 'debug';
|
||||
import { and, desc, eq, ne, or } from 'drizzle-orm';
|
||||
|
||||
import type { MessengerPlatform } from '@/config/messenger';
|
||||
import { getServerDB } from '@/database/core/db-adaptor';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import { MessengerAccountLinkModel } from '@/database/models/messengerAccountLink';
|
||||
import { WorkspaceModel } from '@/database/models/workspace';
|
||||
import type { MessengerAccountLinkItem } from '@/database/schemas';
|
||||
import { agents } from '@/database/schemas';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { buildWorkspaceWhere } from '@/database/utils/workspace';
|
||||
import { getServerFeatureFlagsStateFromRuntimeConfig } from '@/server/featureFlags';
|
||||
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
|
||||
import { AiAgentService } from '@/server/services/aiAgent';
|
||||
@@ -1508,17 +1511,33 @@ export class MessengerRouter {
|
||||
userId: string,
|
||||
workspaceId?: string | null,
|
||||
): Promise<AgentSummary[]> {
|
||||
// The filter, ordering, pinning, and title fallback all live in the model.
|
||||
// This text-only channel has no client-side i18n default, so it asks the
|
||||
// model to fill blank titles with a generic "Custom Agent" label.
|
||||
const rows = await new AgentModel(
|
||||
serverDB,
|
||||
userId,
|
||||
workspaceId ?? undefined,
|
||||
).listMessengerBindableAgents({ fallbackTitle: 'Custom Agent' });
|
||||
const rows = await serverDB
|
||||
.select({ id: agents.id, slug: agents.slug, title: agents.title })
|
||||
.from(agents)
|
||||
.where(
|
||||
and(
|
||||
buildWorkspaceWhere({ userId, workspaceId: workspaceId ?? undefined }, agents),
|
||||
or(ne(agents.virtual, true), eq(agents.slug, INBOX_SESSION_ID)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agents.updatedAt));
|
||||
|
||||
// `fallbackTitle` guarantees a non-null title for every row.
|
||||
return rows.map((row) => ({ id: row.id, title: row.title! }));
|
||||
const mapped = rows
|
||||
.filter((row) => row.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title:
|
||||
(row.title && row.title.trim()) ||
|
||||
(row.slug === INBOX_SESSION_ID ? 'LobeAI' : 'Custom Agent'),
|
||||
}));
|
||||
|
||||
const inboxIdx = mapped.findIndex((row) => row.slug === INBOX_SESSION_ID);
|
||||
if (inboxIdx > 0) {
|
||||
const [inbox] = mapped.splice(inboxIdx, 1);
|
||||
mapped.unshift(inbox);
|
||||
}
|
||||
return mapped.map(({ slug: _slug, ...rest }) => rest);
|
||||
}
|
||||
|
||||
private async dispatchToAgent(
|
||||
|
||||
@@ -1,162 +1,303 @@
|
||||
// @vitest-environment node
|
||||
import { TASK_TEMPLATE_RECOMMEND_MAX_COUNT } from '@lobechat/const';
|
||||
import type { TaskTemplate } from '@lobehub/market-sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TaskTemplate } from '@lobechat/const';
|
||||
import {
|
||||
TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES,
|
||||
TASK_TEMPLATE_RECOMMEND_COUNT,
|
||||
taskTemplates,
|
||||
} from '@lobechat/const';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TaskTemplateService } from './index';
|
||||
import { isTemplateSkillSourceEligible, TaskTemplateService } from './index';
|
||||
|
||||
const { mockGetTaskTemplateRecommendations, mockMarket } = vi.hoisted(() => {
|
||||
const market: {
|
||||
taskTemplates?: {
|
||||
getTaskTemplateRecommendations: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
} = {
|
||||
taskTemplates: {
|
||||
getTaskTemplateRecommendations: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
mockGetTaskTemplateRecommendations: vi.fn(),
|
||||
mockMarket: market,
|
||||
};
|
||||
const makeTemplate = (overrides: Partial<TaskTemplate>): TaskTemplate => ({
|
||||
category: 'engineering',
|
||||
cronPattern: '0 9 * * *',
|
||||
id: 't',
|
||||
interests: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
vi.mock('@/server/services/market', () => ({
|
||||
MarketService: vi.fn(() => ({ market: mockMarket })),
|
||||
}));
|
||||
|
||||
vi.mock('@/config/composio', () => ({
|
||||
composioEnv: { COMPOSIO_API_KEY: 'composio-key' },
|
||||
}));
|
||||
|
||||
vi.mock('@/envs/app', () => ({
|
||||
appEnv: {
|
||||
MARKET_TRUSTED_CLIENT_ID: 'client-id',
|
||||
MARKET_TRUSTED_CLIENT_SECRET: 'secret',
|
||||
},
|
||||
}));
|
||||
|
||||
const template = {
|
||||
category: 'engineering',
|
||||
connectors: [],
|
||||
createdAt: '2026-06-17T00:00:00.000Z',
|
||||
cronPattern: '0 9 * * *',
|
||||
description: 'Description',
|
||||
id: 101,
|
||||
identifier: 'daily-engineering',
|
||||
instruction: 'Instruction',
|
||||
interests: ['coding'],
|
||||
title: 'Title',
|
||||
updatedAt: '2026-06-17T00:00:00.000Z',
|
||||
version: '1.0.0',
|
||||
versionNumber: 1,
|
||||
} satisfies TaskTemplate;
|
||||
const UTC_DAY_1 = new Date('2026-04-24T10:00:00Z');
|
||||
const UTC_DAY_2 = new Date('2026-04-25T10:00:00Z');
|
||||
|
||||
describe('TaskTemplateService.listDailyRecommend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMarket.taskTemplates = {
|
||||
getTaskTemplateRecommendations: mockGetTaskTemplateRecommendations,
|
||||
};
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: [template] });
|
||||
it('returns the default recommendation count when user has matching interests', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
const codingMatches = taskTemplates.filter((t) => t.interests.includes('coding'));
|
||||
expect(picked.some((p) => codingMatches.some((m) => m.id === p.id))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns Market recommendation items', async () => {
|
||||
it('is stable for the same (userId, utcDate)', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([template]);
|
||||
});
|
||||
|
||||
it('returns an empty list when Market returns no recommendation items', async () => {
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: [] });
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes recommendation inputs to Market', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
await service.listDailyRecommend(['coding'], {
|
||||
count: 10,
|
||||
enabledConnectors: [
|
||||
{ identifier: 'github', source: 'lobehub' },
|
||||
{ identifier: 'gmail', source: 'composio' },
|
||||
],
|
||||
excludeIds: [101],
|
||||
locale: 'zh-CN',
|
||||
refreshSeed: 'refresh-1',
|
||||
const a = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
const b = await service.listDailyRecommend(['coding'], {
|
||||
now: new Date('2026-04-24T23:59:00Z'), // still same UTC day
|
||||
});
|
||||
|
||||
expect(mockGetTaskTemplateRecommendations).toHaveBeenCalledWith({
|
||||
count: 10,
|
||||
enabledConnectors: [
|
||||
{ identifier: 'github', source: 'lobehub' },
|
||||
{ identifier: 'gmail', source: 'composio' },
|
||||
],
|
||||
excludeIds: [101],
|
||||
interestKeys: ['coding'],
|
||||
locale: 'zh-CN',
|
||||
refreshSeed: 'refresh-1',
|
||||
});
|
||||
expect(a.map((t) => t.id)).toEqual(b.map((t) => t.id));
|
||||
});
|
||||
|
||||
it('clamps oversized recommendation counts before calling Market', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
it('changes across UTC days', async () => {
|
||||
let matches = 0;
|
||||
for (const suffix of ['a', 'b', 'c', 'd', 'e']) {
|
||||
const service = new TaskTemplateService(`user-${suffix}`);
|
||||
const d1 = await service.listDailyRecommend([], { now: UTC_DAY_1 });
|
||||
const d2 = await service.listDailyRecommend([], { now: UTC_DAY_2 });
|
||||
if (JSON.stringify(d1) === JSON.stringify(d2)) matches += 1;
|
||||
}
|
||||
expect(matches).toBeLessThan(5);
|
||||
});
|
||||
|
||||
await service.listDailyRecommend(['coding'], { count: 25 });
|
||||
|
||||
expect(mockGetTaskTemplateRecommendations).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ count: TASK_TEMPLATE_RECOMMEND_MAX_COUNT }),
|
||||
it('differs across users on the same day', async () => {
|
||||
const results = await Promise.all(
|
||||
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((s) =>
|
||||
new TaskTemplateService(`user-${s}`)
|
||||
.listDailyRecommend([], { now: UTC_DAY_1 })
|
||||
.then((r) => r.map((t) => t.id).join(',')),
|
||||
),
|
||||
);
|
||||
expect(new Set(results).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('returns an empty list when Market recommendations fail', async () => {
|
||||
mockGetTaskTemplateRecommendations.mockRejectedValue(new Error('market down'));
|
||||
it('falls back to fallback categories when user has no interests', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend([], { now: UTC_DAY_1 });
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
for (const p of picked) {
|
||||
expect(taskTemplates.some((t) => t.id === p.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an empty list when Market returns malformed recommendations', async () => {
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({});
|
||||
it('intersection is case-insensitive and trims whitespace', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend([' CoDing '], { now: UTC_DAY_1 });
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
const codingMatches = taskTemplates.filter((t) => t.interests.includes('coding'));
|
||||
expect(picked.some((p) => codingMatches.some((m) => m.id === p.id))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns official connector fields from Market recommendation items', async () => {
|
||||
const templateWithConnectors = {
|
||||
...template,
|
||||
connectors: [
|
||||
{ identifier: 'github', required: true, source: 'lobehub' },
|
||||
{ identifier: 'gmail', required: false, source: 'composio' },
|
||||
],
|
||||
id: 102,
|
||||
} satisfies TaskTemplate;
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: [templateWithConnectors] });
|
||||
it('unrecognized interest strings fall back to non-matched pool', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
// Freeform custom input won't match any template's interests and should still return defaults.
|
||||
const picked = await service.listDailyRecommend(['my special hobby'], { now: UTC_DAY_1 });
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([templateWithConnectors]);
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
});
|
||||
|
||||
it('returns an empty list when the SDK has no taskTemplates namespace', async () => {
|
||||
mockMarket.taskTemplates = undefined;
|
||||
it('excludes templates listed in excludeIds', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
expect(baseline.length).toBeGreaterThan(0);
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
const excludedId = baseline[0].id;
|
||||
const picked = await service.listDailyRecommend(['coding'], {
|
||||
excludeIds: [excludedId],
|
||||
now: UTC_DAY_1,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(picked.some((t) => t.id === excludedId)).toBe(false);
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
});
|
||||
|
||||
it('drops templates whose required skill sources are not all enabled', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
// Without `enabledSkillSources`, any template with `requiresSkills` is filtered out.
|
||||
// Since current catalog has none, this should match the baseline (no-op).
|
||||
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
expect(baseline).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
});
|
||||
|
||||
it('returns only non-excluded templates when most are excluded', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const allIds = taskTemplates.map((t) => t.id);
|
||||
const keepIds = allIds.slice(0, 2);
|
||||
const excludeIds = allIds.slice(2);
|
||||
|
||||
const picked = await service.listDailyRecommend(['coding'], {
|
||||
excludeIds,
|
||||
now: UTC_DAY_1,
|
||||
});
|
||||
|
||||
expect(picked.map((t) => t.id).sort()).toEqual([...keepIds].sort());
|
||||
});
|
||||
|
||||
it('matches baseline when refreshSeed is undefined', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
const withUndefined = await service.listDailyRecommend(['coding'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: undefined,
|
||||
});
|
||||
const withEmpty = await service.listDailyRecommend(['coding'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: '',
|
||||
});
|
||||
|
||||
expect(withUndefined.map((t) => t.id)).toEqual(baseline.map((t) => t.id));
|
||||
expect(withEmpty.map((t) => t.id)).toEqual(baseline.map((t) => t.id));
|
||||
});
|
||||
|
||||
it('is stable for the same (userId, utcDay, refreshSeed)', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const a = await service.listDailyRecommend(['coding'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: 'seed-x',
|
||||
});
|
||||
const b = await service.listDailyRecommend(['coding'], {
|
||||
now: new Date('2026-04-24T23:59:00Z'), // same UTC day
|
||||
refreshSeed: 'seed-x',
|
||||
});
|
||||
expect(a.map((t) => t.id)).toEqual(b.map((t) => t.id));
|
||||
});
|
||||
|
||||
it('differs when refreshSeed changes', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const seeds = ['s1', 's2', 's3', 's4', 's5'];
|
||||
const results = await Promise.all(
|
||||
seeds.map((s) =>
|
||||
service
|
||||
.listDailyRecommend([], { now: UTC_DAY_1, refreshSeed: s })
|
||||
.then((r) => r.map((t) => t.id).join(',')),
|
||||
),
|
||||
);
|
||||
expect(new Set(results).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('refreshSeed does not bypass excludeIds', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
const excludedId = baseline[0].id;
|
||||
|
||||
for (const seed of ['s1', 's2', 's3']) {
|
||||
const picked = await service.listDailyRecommend(['coding'], {
|
||||
excludeIds: [excludedId],
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: seed,
|
||||
});
|
||||
expect(picked.some((t) => t.id === excludedId)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops personal-only categories in workspace mode', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const personalCategories = new Set(TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES);
|
||||
|
||||
// Use a personal interest that would otherwise match personal-life templates.
|
||||
const picked = await service.listDailyRecommend(['personal'], {
|
||||
now: UTC_DAY_1,
|
||||
workspaceMode: true,
|
||||
});
|
||||
for (const p of picked) {
|
||||
expect(personalCategories.has(p.category), `template ${p.id} category`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('shuffles broadly across refreshSeeds in workspace mode with empty interests', async () => {
|
||||
// The original narrow workspace fallback (operations + learning-research)
|
||||
// resolved to ~4 templates after skill gating, locking "换一批" to a
|
||||
// permutation of the same 3-of-4. Workspace fallback must draw from the
|
||||
// full non-personal candidate set so refresh actually rotates.
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const seenIds = new Set<string>();
|
||||
for (const seed of ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8']) {
|
||||
const picked = await service.listDailyRecommend([], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: seed,
|
||||
workspaceMode: true,
|
||||
});
|
||||
for (const p of picked) seenIds.add(p.id);
|
||||
}
|
||||
// 8 refreshes × 3 picks = 24 slots. Across this many seeds the pool
|
||||
// should clearly exceed the old 4-template ceiling.
|
||||
expect(seenIds.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('keeps personal-only categories in personal mode (default)', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const personalCategories = new Set(TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES);
|
||||
|
||||
// Sample enough seeds so the personal fallback pool surfaces.
|
||||
const reached = new Set<string>();
|
||||
for (const seed of ['p1', 'p2', 'p3', 'p4', 'p5', 'p6']) {
|
||||
const picked = await service.listDailyRecommend(['personal'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: seed,
|
||||
});
|
||||
for (const p of picked) {
|
||||
if (personalCategories.has(p.category)) reached.add(p.id);
|
||||
}
|
||||
}
|
||||
expect(reached.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produces a different shuffle seed for workspace mode vs personal mode', async () => {
|
||||
// Seed namespaces are isolated so workspace recommendations don't mirror
|
||||
// the personal lineup for the same user/day.
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const personal = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
const workspace = await service.listDailyRecommend(['coding'], {
|
||||
now: UTC_DAY_1,
|
||||
workspaceMode: true,
|
||||
});
|
||||
expect(personal.map((t) => t.id).join(',')).not.toBe(workspace.map((t) => t.id).join(','));
|
||||
});
|
||||
|
||||
it('changes the first item across refreshSeeds when matched candidates are fewer than the default recommendation count', async () => {
|
||||
// Repro for: `health` interest matches only one template (`diet-log-companion`),
|
||||
// so the legacy "matched-first" logic locked it to position 0 regardless of seed.
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const firstItems = new Set<string>();
|
||||
for (const seed of ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8']) {
|
||||
const picked = await service.listDailyRecommend(['health'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: seed,
|
||||
});
|
||||
firstItems.add(picked[0]?.id ?? '');
|
||||
}
|
||||
expect(firstItems.size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTemplateSkillSourceEligible', () => {
|
||||
it('treats templates without requiresSkills as always eligible', () => {
|
||||
expect(isTemplateSkillSourceEligible(makeTemplate({}))).toBe(true);
|
||||
expect(isTemplateSkillSourceEligible(makeTemplate({}), new Set())).toBe(true);
|
||||
});
|
||||
|
||||
it('filters out skill-dependent templates when enabledSkillSources is undefined', () => {
|
||||
const t = makeTemplate({ requiresSkills: [{ provider: 'github', source: 'lobehub' }] });
|
||||
expect(isTemplateSkillSourceEligible(t, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps templates whose only source is enabled', () => {
|
||||
const t = makeTemplate({ requiresSkills: [{ provider: 'notion', source: 'lobehub' }] });
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops templates whose source is not in enabledSkillSources', () => {
|
||||
const t = makeTemplate({ requiresSkills: [{ provider: 'notion', source: 'lobehub' }] });
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['composio']))).toBe(false);
|
||||
});
|
||||
|
||||
it('requires every source for multi-skill templates', () => {
|
||||
const t = makeTemplate({
|
||||
requiresSkills: [
|
||||
{ provider: 'notion', source: 'lobehub' },
|
||||
{ provider: 'google-calendar', source: 'composio' },
|
||||
],
|
||||
});
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(false);
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['composio']))).toBe(false);
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub', 'composio']))).toBe(true);
|
||||
});
|
||||
|
||||
it('treats empty requiresSkills array same as undefined (always eligible)', () => {
|
||||
const t = makeTemplate({ requiresSkills: [] });
|
||||
expect(isTemplateSkillSourceEligible(t, undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,78 +1,150 @@
|
||||
import type { TaskTemplate, TaskTemplateSkillSource } from '@lobechat/const';
|
||||
import {
|
||||
COMPOSIO_APP_TYPES,
|
||||
LOBEHUB_SKILL_PROVIDERS,
|
||||
TASK_TEMPLATE_FALLBACK_CATEGORIES,
|
||||
TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES,
|
||||
TASK_TEMPLATE_RECOMMEND_COUNT,
|
||||
TASK_TEMPLATE_RECOMMEND_MAX_COUNT,
|
||||
taskTemplates,
|
||||
} from '@lobechat/const';
|
||||
import type { TaskTemplate, TaskTemplateConnectorReference } from '@lobehub/market-sdk';
|
||||
|
||||
import { composioEnv } from '@/config/composio';
|
||||
import { appEnv } from '@/envs/app';
|
||||
import { MarketService } from '@/server/services/market';
|
||||
|
||||
export const ENABLED_TASK_TEMPLATE_CONNECTORS: TaskTemplateConnectorReference[] = (() => {
|
||||
const connectors: TaskTemplateConnectorReference[] = [];
|
||||
|
||||
if (composioEnv.COMPOSIO_API_KEY) {
|
||||
connectors.push(
|
||||
...COMPOSIO_APP_TYPES.map((app) => ({
|
||||
identifier: app.identifier,
|
||||
source: 'composio' as const,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export const ENABLED_SKILL_SOURCES: ReadonlySet<TaskTemplateSkillSource> = (() => {
|
||||
const sources = new Set<TaskTemplateSkillSource>();
|
||||
if (composioEnv.COMPOSIO_API_KEY) sources.add('composio');
|
||||
if (appEnv.MARKET_TRUSTED_CLIENT_ID && appEnv.MARKET_TRUSTED_CLIENT_SECRET) {
|
||||
connectors.push(
|
||||
...LOBEHUB_SKILL_PROVIDERS.map((provider) => ({
|
||||
identifier: provider.id,
|
||||
source: 'lobehub' as const,
|
||||
})),
|
||||
);
|
||||
sources.add('lobehub');
|
||||
}
|
||||
|
||||
return connectors;
|
||||
return sources;
|
||||
})();
|
||||
|
||||
const clampRecommendationCount = (count?: number) =>
|
||||
Math.min(Math.max(1, count ?? TASK_TEMPLATE_RECOMMEND_COUNT), TASK_TEMPLATE_RECOMMEND_MAX_COUNT);
|
||||
const hashString = (str: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
/** mulberry32 — pure function of seed, used so recommendations are stable per user/day. */
|
||||
const mulberry32 = (seed: number) => {
|
||||
let t = seed >>> 0;
|
||||
return () => {
|
||||
t = (t + 0x6d_2b_79_f5) | 0;
|
||||
let r = Math.imul(t ^ (t >>> 15), 1 | t);
|
||||
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
|
||||
return ((r ^ (r >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
};
|
||||
|
||||
const seededShuffle = <T>(items: T[], seed: number): T[] => {
|
||||
const arr = [...items];
|
||||
const rand = mulberry32(seed);
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
const normalize = (s: string) => s.trim().toLowerCase();
|
||||
|
||||
const hasIntersection = (template: TaskTemplate, userInterests: string[]): boolean => {
|
||||
if (userInterests.length === 0) return false;
|
||||
const normalized = new Set(userInterests.map(normalize));
|
||||
return template.interests.some((i) => normalized.has(normalize(i)));
|
||||
};
|
||||
|
||||
const getUtcDateStr = (now: Date): string => now.toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* A template is eligible only if every `requiresSkills[].source` is enabled
|
||||
* server-side. When a template declares no skill requirement, it is always
|
||||
* eligible. When the caller passes no `enabledSkillSources` set, any template
|
||||
* with skill requirements is filtered out (conservative default).
|
||||
*/
|
||||
export const isTemplateSkillSourceEligible = (
|
||||
template: TaskTemplate,
|
||||
enabledSkillSources?: ReadonlySet<TaskTemplateSkillSource>,
|
||||
): boolean => {
|
||||
if (!template.requiresSkills || template.requiresSkills.length === 0) return true;
|
||||
if (!enabledSkillSources) return false;
|
||||
return template.requiresSkills.every((s) => enabledSkillSources.has(s.source));
|
||||
};
|
||||
|
||||
export class TaskTemplateService {
|
||||
private marketService: MarketService;
|
||||
|
||||
constructor(private userId: string) {
|
||||
this.marketService = new MarketService({ userInfo: { userId } });
|
||||
}
|
||||
constructor(private userId: string) {}
|
||||
|
||||
async listDailyRecommend(
|
||||
interestKeys: string[],
|
||||
options: {
|
||||
count?: number;
|
||||
enabledConnectors?: readonly TaskTemplateConnectorReference[];
|
||||
excludeIds?: number[];
|
||||
locale?: string;
|
||||
enabledSkillSources?: ReadonlySet<TaskTemplateSkillSource>;
|
||||
excludeIds?: string[];
|
||||
now?: Date;
|
||||
refreshSeed?: string;
|
||||
/**
|
||||
* When true, drop every template under `TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES`
|
||||
* and use the workspace-flavored fallback pool. Used by the cloud router
|
||||
* whenever the request is bound to a workspace context.
|
||||
*/
|
||||
workspaceMode?: boolean;
|
||||
} = {},
|
||||
): Promise<TaskTemplate[]> {
|
||||
try {
|
||||
const result = await this.marketService.market.taskTemplates.getTaskTemplateRecommendations({
|
||||
count: clampRecommendationCount(options.count),
|
||||
enabledConnectors: options.enabledConnectors ? [...options.enabledConnectors] : undefined,
|
||||
excludeIds: options.excludeIds,
|
||||
interestKeys,
|
||||
locale: options.locale,
|
||||
refreshSeed: options.refreshSeed,
|
||||
});
|
||||
const {
|
||||
count = TASK_TEMPLATE_RECOMMEND_COUNT,
|
||||
enabledSkillSources,
|
||||
excludeIds,
|
||||
now = new Date(),
|
||||
refreshSeed,
|
||||
workspaceMode = false,
|
||||
} = options;
|
||||
const limit = Math.max(1, count);
|
||||
const excluded = new Set(excludeIds ?? []);
|
||||
const seedBase = workspaceMode
|
||||
? `${this.userId}:ws:${getUtcDateStr(now)}`
|
||||
: `${this.userId}:${getUtcDateStr(now)}`;
|
||||
const seed = hashString(refreshSeed ? `${seedBase}:${refreshSeed}` : seedBase);
|
||||
|
||||
if (!Array.isArray(result.items)) {
|
||||
console.error('[taskTemplate:listDailyRecommend] Market recommendations returned no items');
|
||||
return [];
|
||||
}
|
||||
const personalOnly = new Set<string>(TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES);
|
||||
|
||||
return result.items;
|
||||
} catch (error) {
|
||||
console.error('[taskTemplate:listDailyRecommend] Market recommendations failed', error);
|
||||
return [];
|
||||
const candidates = taskTemplates.filter(
|
||||
(t) =>
|
||||
!excluded.has(t.id) &&
|
||||
isTemplateSkillSourceEligible(t, enabledSkillSources) &&
|
||||
(!workspaceMode || !personalOnly.has(t.category)),
|
||||
);
|
||||
const matched = candidates.filter((t) => hasIntersection(t, interestKeys));
|
||||
const result: TaskTemplate[] = [];
|
||||
|
||||
if (matched.length >= limit) {
|
||||
result.push(...seededShuffle(matched, seed).slice(0, limit));
|
||||
} else {
|
||||
// Not enough interest matches: fold the fallback pool in so refreshSeed
|
||||
// can reorder the whole batch — otherwise a single-match interest pins
|
||||
// that template to position 0 forever.
|
||||
//
|
||||
// Personal mode keeps the narrow `personal-life + learning-research`
|
||||
// fallback (it's the existing vibe). Workspace mode uses the full
|
||||
// non-personal candidate set — the original 2-category workspace
|
||||
// fallback resolved to ~4 templates after skill gating and made
|
||||
// "换一批" a no-op.
|
||||
const matchedIds = new Set(matched.map((t) => t.id));
|
||||
const fallback = workspaceMode
|
||||
? candidates.filter((t) => !matchedIds.has(t.id))
|
||||
: candidates.filter(
|
||||
(t) => TASK_TEMPLATE_FALLBACK_CATEGORIES.includes(t.category) && !matchedIds.has(t.id),
|
||||
);
|
||||
const pool = [...matched, ...fallback];
|
||||
result.push(...seededShuffle(pool, seed).slice(0, limit));
|
||||
}
|
||||
|
||||
if (result.length < limit) {
|
||||
const seen = new Set(result.map((t) => t.id));
|
||||
const remaining = candidates.filter((t) => !seen.has(t.id));
|
||||
result.push(...seededShuffle(remaining, seed).slice(0, limit - result.length));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-65
@@ -2,7 +2,6 @@ import { AgentDocumentsExecutionRuntime } from '@lobechat/builtin-tool-agent-doc
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { WorkspaceModel } from '@/database/models/workspace';
|
||||
import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
|
||||
import { agentDocumentsRuntime } from '../agentDocuments';
|
||||
@@ -13,11 +12,7 @@ const agentDocumentToolOutcomeMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@/server/services/agentDocuments');
|
||||
vi.mock('@/database/models/task');
|
||||
vi.mock('@/database/models/workspace');
|
||||
vi.mock('@/server/services/agentDocuments/toolOutcome', () => agentDocumentToolOutcomeMocks);
|
||||
vi.mock('@/envs/app', () => ({
|
||||
appEnv: { APP_URL: 'https://app.example.com' },
|
||||
}));
|
||||
|
||||
describe('agentDocumentsRuntime', () => {
|
||||
it('should have correct identifier', () => {
|
||||
@@ -53,7 +48,6 @@ describe('agentDocumentsRuntime auto-pin to task', () => {
|
||||
renameDocumentById: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let pinDocument: ReturnType<typeof vi.fn>;
|
||||
let findWorkspaceById: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
agentDocumentToolOutcomeMocks.emitAgentDocumentToolOutcomeSafely.mockClear();
|
||||
@@ -65,14 +59,12 @@ describe('agentDocumentsRuntime auto-pin to task', () => {
|
||||
renameDocumentById: vi.fn().mockResolvedValue(newDoc),
|
||||
};
|
||||
pinDocument = vi.fn().mockResolvedValue(undefined);
|
||||
findWorkspaceById = vi.fn().mockResolvedValue({ slug: 'lobe-team' });
|
||||
|
||||
vi.mocked(AgentDocumentsService).mockImplementation(() => serviceImpl as any);
|
||||
vi.mocked(TaskModel).mockImplementation(() => ({ pinDocument }) as any);
|
||||
vi.mocked(WorkspaceModel).mockImplementation(() => ({ findById: findWorkspaceById }) as any);
|
||||
});
|
||||
|
||||
const buildContext = (taskId?: string, workspaceId?: string) => {
|
||||
const buildContext = (taskId?: string) => {
|
||||
// Mock the workspace lookup chain that `pinToTask` runs against the task
|
||||
// row. Returning `workspaceId: null` reproduces personal-mode behavior.
|
||||
const limit = vi.fn().mockResolvedValue([{ workspaceId: null }]);
|
||||
@@ -84,7 +76,6 @@ describe('agentDocumentsRuntime auto-pin to task', () => {
|
||||
taskId,
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
workspaceId,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -196,34 +187,6 @@ describe('agentDocumentsRuntime auto-pin to task', () => {
|
||||
|
||||
expect(pinDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes the workspace slug in generated document URLs', async () => {
|
||||
const runtime = agentDocumentsRuntime.factory(buildContext(undefined, 'workspace-1'));
|
||||
|
||||
const result = await runtime.createDocument(
|
||||
{ content: 'body', title: 'Daily Brief' },
|
||||
{ agentId: 'agent-1' },
|
||||
);
|
||||
|
||||
expect(findWorkspaceById).toHaveBeenCalledWith('workspace-1');
|
||||
expect(result.content).toContain(
|
||||
'https://app.example.com/lobe-team/agent/agent-1/docs/documents-row-id',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits document URLs for workspace-scoped runs when the workspace slug cannot be resolved', async () => {
|
||||
findWorkspaceById.mockResolvedValueOnce(undefined);
|
||||
const runtime = agentDocumentsRuntime.factory(buildContext(undefined, 'workspace-1'));
|
||||
|
||||
const result = await runtime.createDocument(
|
||||
{ content: 'body', title: 'Daily Brief' },
|
||||
{ agentId: 'agent-1' },
|
||||
);
|
||||
|
||||
expect(result.content).toBe(
|
||||
'Created document "Daily Brief" (internal id: agent-doc-assoc-id).',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentDocumentsExecutionRuntime.createDocument', () => {
|
||||
@@ -263,33 +226,6 @@ describe('AgentDocumentsExecutionRuntime.createDocument', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('includes a document URL when a URL builder is configured', async () => {
|
||||
const stub = makeStub();
|
||||
stub.createDocument.mockResolvedValue({
|
||||
documentId: 'docs_document-row-id',
|
||||
filename: 'daily-brief',
|
||||
id: 'agent-doc-assoc-id',
|
||||
title: 'Daily Brief',
|
||||
});
|
||||
|
||||
const runtime = new AgentDocumentsExecutionRuntime(stub, {
|
||||
getDocumentUrl: ({ agentId, documentId }) =>
|
||||
`https://app.example.com/agent/${agentId}/docs/${documentId}`,
|
||||
});
|
||||
const result = await runtime.createDocument(
|
||||
{ content: 'body', title: 'Daily Brief' },
|
||||
{ agentId: 'agent-1' },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain(
|
||||
'https://app.example.com/agent/agent-1/docs/docs_document-row-id',
|
||||
);
|
||||
expect(result.content).toContain('clickable markdown link');
|
||||
expect(result.content).toContain('Internal id agent-doc-assoc-id');
|
||||
expect(result.content).toContain('never show it to the user');
|
||||
});
|
||||
|
||||
it('refuses to run without agentId', async () => {
|
||||
const stub = makeStub();
|
||||
const runtime = new AgentDocumentsExecutionRuntime(stub);
|
||||
|
||||
@@ -26,10 +26,7 @@ describe('agentDocumentsRuntime', () => {
|
||||
});
|
||||
const result = await runtime.listDocuments({}, { agentId: 'agent-1' });
|
||||
|
||||
// The agent runtime opts into seeing the archived `.tool-results`.
|
||||
expect(listDocuments).toHaveBeenCalledWith('agent-1', 'all', {
|
||||
includeArchivedToolResults: true,
|
||||
});
|
||||
expect(listDocuments).toHaveBeenCalledWith('agent-1', 'all');
|
||||
expect(result).toEqual({
|
||||
content: JSON.stringify([
|
||||
{ filename: 'rules.md', id: 'doc-1', title: 'Rules' },
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import type { DocumentLoadRule } from '@lobechat/agent-templates';
|
||||
import {
|
||||
AgentDocumentsIdentifier,
|
||||
buildAgentDocumentUrl,
|
||||
} from '@lobechat/builtin-tool-agent-documents';
|
||||
import { AgentDocumentsIdentifier } from '@lobechat/builtin-tool-agent-documents';
|
||||
import { AgentDocumentsExecutionRuntime } from '@lobechat/builtin-tool-agent-documents/executionRuntime';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { WorkspaceModel } from '@/database/models/workspace';
|
||||
import { tasks } from '@/database/schemas';
|
||||
import { appEnv } from '@/envs/app';
|
||||
import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
import { emitAgentDocumentToolOutcomeSafely } from '@/server/services/agentDocuments/toolOutcome';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
const getAgentDocumentAppUrl = (): string | undefined => {
|
||||
try {
|
||||
return appEnv.APP_URL;
|
||||
} catch {
|
||||
return process.env.APP_URL;
|
||||
}
|
||||
};
|
||||
|
||||
export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
factory: (context) => {
|
||||
if (!context.userId || !context.serverDB) {
|
||||
@@ -33,7 +20,6 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
const userId = context.userId;
|
||||
const service = new AgentDocumentsService(db, userId, context.workspaceId);
|
||||
const { taskId } = context;
|
||||
let workspaceSlugPromise: Promise<string | undefined> | undefined;
|
||||
const emitDocumentOutcome = async (input: {
|
||||
agentId?: string;
|
||||
agentDocumentId?: string;
|
||||
@@ -123,168 +109,136 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
|
||||
return doc;
|
||||
};
|
||||
|
||||
const resolveWorkspaceSlugForUrl = async (): Promise<string | undefined> => {
|
||||
if (!context.workspaceId) return undefined;
|
||||
|
||||
workspaceSlugPromise ??= new WorkspaceModel(db, userId)
|
||||
.findById(context.workspaceId)
|
||||
.then((workspace) => workspace?.slug)
|
||||
.catch((error) => {
|
||||
console.error('[agentDocumentsRuntime] Failed to resolve workspace slug:', error);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return workspaceSlugPromise;
|
||||
};
|
||||
|
||||
return new AgentDocumentsExecutionRuntime(
|
||||
{
|
||||
copyDocument: async ({ agentId, id, newTitle }) =>
|
||||
pinToTask(
|
||||
await withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'copyDocument',
|
||||
getAgentDocumentId: (result) => result?.id,
|
||||
relation: 'created',
|
||||
summary: 'Agent documents copied a document.',
|
||||
toolAction: 'copy',
|
||||
},
|
||||
() => service.copyDocumentById(id, newTitle, agentId),
|
||||
),
|
||||
),
|
||||
createDocument: async ({ agentId, content, hintIsSkill, title }) =>
|
||||
pinToTask(
|
||||
await withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'createDocument',
|
||||
getAgentDocumentId: (result) => result?.id,
|
||||
hintIsSkill,
|
||||
relation: 'created',
|
||||
summary: 'Agent documents created a document.',
|
||||
toolAction: 'create',
|
||||
},
|
||||
() => service.createDocument(agentId, title, content, { hintIsSkill }),
|
||||
),
|
||||
),
|
||||
createTopicDocument: async ({ agentId, content, hintIsSkill, title, topicId }) =>
|
||||
pinToTask(
|
||||
await withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'createTopicDocument',
|
||||
getAgentDocumentId: (result) => result?.id,
|
||||
hintIsSkill,
|
||||
relation: 'created',
|
||||
summary: 'Agent documents created a topic document.',
|
||||
toolAction: 'create',
|
||||
},
|
||||
() => service.createForTopic(agentId, title, content, topicId, { hintIsSkill }),
|
||||
),
|
||||
),
|
||||
listDocuments: async ({ agentId, sourceType }) => {
|
||||
// Agents discover archived tool results via this path (see
|
||||
// `excludeArchivedToolResults`), so keep the `.tool-results` archive visible.
|
||||
const docs = await service.listDocuments(agentId, sourceType, {
|
||||
includeArchivedToolResults: true,
|
||||
});
|
||||
return docs.map((d) => ({
|
||||
documentId: d.documentId,
|
||||
filename: d.filename,
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
}));
|
||||
},
|
||||
listTopicDocuments: async ({ agentId, sourceType, topicId }) => {
|
||||
const docs = await service.listDocumentsForTopic(agentId, topicId, sourceType, {
|
||||
includeArchivedToolResults: true,
|
||||
});
|
||||
return docs.map((d) => ({
|
||||
documentId: d.documentId,
|
||||
filename: d.filename,
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
}));
|
||||
},
|
||||
modifyNodes: ({ agentId, id, operations }) =>
|
||||
withDocumentOutcome(
|
||||
return new AgentDocumentsExecutionRuntime({
|
||||
copyDocument: async ({ agentId, id, newTitle }) =>
|
||||
pinToTask(
|
||||
await withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'modifyNodes',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents modified document nodes.',
|
||||
toolAction: 'edit',
|
||||
apiName: 'copyDocument',
|
||||
getAgentDocumentId: (result) => result?.id,
|
||||
relation: 'created',
|
||||
summary: 'Agent documents copied a document.',
|
||||
toolAction: 'copy',
|
||||
},
|
||||
() => service.modifyDocumentNodesById(id, operations, agentId),
|
||||
() => service.copyDocumentById(id, newTitle, agentId),
|
||||
),
|
||||
readDocument: ({ agentId, id }) => service.getDocumentSnapshotById(id, agentId),
|
||||
removeDocument: ({ agentId, id }) =>
|
||||
withDocumentOutcome(
|
||||
),
|
||||
createDocument: async ({ agentId, content, hintIsSkill, title }) =>
|
||||
pinToTask(
|
||||
await withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'removeDocument',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'removed',
|
||||
summary: 'Agent documents removed a document.',
|
||||
toolAction: 'remove',
|
||||
apiName: 'createDocument',
|
||||
getAgentDocumentId: (result) => result?.id,
|
||||
hintIsSkill,
|
||||
relation: 'created',
|
||||
summary: 'Agent documents created a document.',
|
||||
toolAction: 'create',
|
||||
},
|
||||
() => service.removeDocumentById(id, agentId),
|
||||
() => service.createDocument(agentId, title, content, { hintIsSkill }),
|
||||
),
|
||||
renameDocument: ({ agentId, id, newTitle }) =>
|
||||
withDocumentOutcome(
|
||||
),
|
||||
createTopicDocument: async ({ agentId, content, hintIsSkill, title, topicId }) =>
|
||||
pinToTask(
|
||||
await withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'renameDocument',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents renamed a document.',
|
||||
toolAction: 'rename',
|
||||
apiName: 'createTopicDocument',
|
||||
getAgentDocumentId: (result) => result?.id,
|
||||
hintIsSkill,
|
||||
relation: 'created',
|
||||
summary: 'Agent documents created a topic document.',
|
||||
toolAction: 'create',
|
||||
},
|
||||
() => service.renameDocumentById(id, newTitle, agentId),
|
||||
),
|
||||
replaceDocumentContent: ({ agentId, content, id }) =>
|
||||
withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'replaceDocumentContent',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents replaced document content.',
|
||||
toolAction: 'replace',
|
||||
},
|
||||
() => service.replaceDocumentContentById(id, content, agentId),
|
||||
),
|
||||
updateLoadRule: ({ agentId, id, rule }) =>
|
||||
withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'updateLoadRule',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents updated a load rule.',
|
||||
toolAction: 'update',
|
||||
},
|
||||
() =>
|
||||
service.updateLoadRuleById(
|
||||
id,
|
||||
{ ...rule, rule: rule.rule as DocumentLoadRule | undefined },
|
||||
agentId,
|
||||
),
|
||||
() => service.createForTopic(agentId, title, content, topicId, { hintIsSkill }),
|
||||
),
|
||||
),
|
||||
listDocuments: async ({ agentId, sourceType }) => {
|
||||
const docs = await service.listDocuments(agentId, sourceType);
|
||||
return docs.map((d) => ({
|
||||
documentId: d.documentId,
|
||||
filename: d.filename,
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
}));
|
||||
},
|
||||
{
|
||||
getDocumentUrl: async ({ agentId, documentId }) => {
|
||||
const workspaceSlug = await resolveWorkspaceSlugForUrl();
|
||||
if (context.workspaceId && !workspaceSlug) return undefined;
|
||||
|
||||
return buildAgentDocumentUrl(getAgentDocumentAppUrl(), agentId, documentId, {
|
||||
workspaceSlug,
|
||||
});
|
||||
},
|
||||
listTopicDocuments: async ({ agentId, sourceType, topicId }) => {
|
||||
const docs = await service.listDocumentsForTopic(agentId, topicId, sourceType);
|
||||
return docs.map((d) => ({
|
||||
documentId: d.documentId,
|
||||
filename: d.filename,
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
}));
|
||||
},
|
||||
);
|
||||
modifyNodes: ({ agentId, id, operations }) =>
|
||||
withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'modifyNodes',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents modified document nodes.',
|
||||
toolAction: 'edit',
|
||||
},
|
||||
() => service.modifyDocumentNodesById(id, operations, agentId),
|
||||
),
|
||||
readDocument: ({ agentId, id }) => service.getDocumentSnapshotById(id, agentId),
|
||||
removeDocument: ({ agentId, id }) =>
|
||||
withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'removeDocument',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'removed',
|
||||
summary: 'Agent documents removed a document.',
|
||||
toolAction: 'remove',
|
||||
},
|
||||
() => service.removeDocumentById(id, agentId),
|
||||
),
|
||||
renameDocument: ({ agentId, id, newTitle }) =>
|
||||
withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'renameDocument',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents renamed a document.',
|
||||
toolAction: 'rename',
|
||||
},
|
||||
() => service.renameDocumentById(id, newTitle, agentId),
|
||||
),
|
||||
replaceDocumentContent: ({ agentId, content, id }) =>
|
||||
withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'replaceDocumentContent',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents replaced document content.',
|
||||
toolAction: 'replace',
|
||||
},
|
||||
() => service.replaceDocumentContentById(id, content, agentId),
|
||||
),
|
||||
updateLoadRule: ({ agentId, id, rule }) =>
|
||||
withDocumentOutcome(
|
||||
{
|
||||
agentId,
|
||||
apiName: 'updateLoadRule',
|
||||
getAgentDocumentId: () => id,
|
||||
relation: 'updated',
|
||||
summary: 'Agent documents updated a load rule.',
|
||||
toolAction: 'update',
|
||||
},
|
||||
() =>
|
||||
service.updateLoadRuleById(
|
||||
id,
|
||||
{ ...rule, rule: rule.rule as DocumentLoadRule | undefined },
|
||||
agentId,
|
||||
),
|
||||
),
|
||||
});
|
||||
},
|
||||
identifier: AgentDocumentsIdentifier,
|
||||
};
|
||||
|
||||
@@ -195,95 +195,80 @@ const withEditor = async (
|
||||
throw new Error('documentId is required');
|
||||
}
|
||||
|
||||
const snapshot = await loadSnapshot(documentModel, documentId);
|
||||
const env = buildEnv(snapshot, documentId);
|
||||
const exportEditorData = options.exportEditorData !== false;
|
||||
const persist = options.persist !== false;
|
||||
const invariantCheck = options.invariantCheck !== false;
|
||||
|
||||
// Acquire the collaborative edit lock around the entire read-modify-write so
|
||||
// the agent reads, mutates and persists atomically: serialized against other
|
||||
// workspace members and rejected (CONFLICT) when someone else is actively
|
||||
// editing, instead of silently clobbering their work. Read-only invocations
|
||||
// (persist: false) never write, so they skip the lock.
|
||||
const run = async (): Promise<HandlerOutput> => {
|
||||
const snapshot = await loadSnapshot(documentModel, documentId);
|
||||
const env = buildEnv(snapshot, documentId);
|
||||
try {
|
||||
const beforeHash = exportEditorData
|
||||
? hashEditorData(env.headless.export().editorData)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const beforeHash = exportEditorData
|
||||
? hashEditorData(env.headless.export().editorData)
|
||||
: undefined;
|
||||
const handlerResult = await handler(env);
|
||||
|
||||
const handlerResult = await handler(env);
|
||||
const exported = exportEditorData ? env.headless.export() : undefined;
|
||||
const afterHash = exported ? hashEditorData(exported.editorData) : undefined;
|
||||
const titleChanged = env.getTitle() !== snapshot.title;
|
||||
const editorChanged = exportEditorData && beforeHash !== undefined && beforeHash !== afterHash;
|
||||
|
||||
const exported = exportEditorData ? env.headless.export() : undefined;
|
||||
const afterHash = exported ? hashEditorData(exported.editorData) : undefined;
|
||||
const titleChanged = env.getTitle() !== snapshot.title;
|
||||
const editorChanged =
|
||||
exportEditorData && beforeHash !== undefined && beforeHash !== afterHash;
|
||||
const invariantViolation = invariantCheck
|
||||
? detectInvariantViolation(apiName, {
|
||||
editorChanged,
|
||||
handlerReportedChange: detectHandlerReportedChange(apiName, handlerResult.state),
|
||||
titleChanged,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const invariantViolation = invariantCheck
|
||||
? detectInvariantViolation(apiName, {
|
||||
editorChanged,
|
||||
handlerReportedChange: detectHandlerReportedChange(apiName, handlerResult.state),
|
||||
titleChanged,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (invariantViolation) {
|
||||
console.warn(
|
||||
`[PageAgentServerRuntime] invariant violation in ${apiName}:`,
|
||||
invariantViolation,
|
||||
{ documentId, operationId: ctx.operationId, toolCallId: ctx.toolCallId },
|
||||
);
|
||||
}
|
||||
|
||||
const patch: {
|
||||
content?: string;
|
||||
editorData?: Record<string, unknown>;
|
||||
title?: string;
|
||||
} = {};
|
||||
if (exported) {
|
||||
patch.content = exported.markdown;
|
||||
patch.editorData = exported.editorData as unknown as Record<string, unknown>;
|
||||
}
|
||||
if (titleChanged) {
|
||||
patch.title = env.getTitle();
|
||||
}
|
||||
|
||||
if (persist && Object.keys(patch).length > 0) {
|
||||
await documentService.updateDocument(documentId, {
|
||||
content: patch.content,
|
||||
editorData: patch.editorData,
|
||||
saveSource: 'llm_call',
|
||||
title: patch.title,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: handlerResult.content,
|
||||
state: {
|
||||
...handlerResult.state,
|
||||
documentContent: patch.content,
|
||||
documentEditorData: patch.editorData,
|
||||
documentTitle: env.getTitle(),
|
||||
...(invariantViolation ? { invariantViolation } : {}),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
env.headless.destroy();
|
||||
if (invariantViolation) {
|
||||
console.warn(
|
||||
`[PageAgentServerRuntime] invariant violation in ${apiName}:`,
|
||||
invariantViolation,
|
||||
{ documentId, operationId: ctx.operationId, toolCallId: ctx.toolCallId },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return persist ? documentService.runWithDocumentLock(documentId, run) : run();
|
||||
const patch: {
|
||||
content?: string;
|
||||
editorData?: Record<string, unknown>;
|
||||
title?: string;
|
||||
} = {};
|
||||
if (exported) {
|
||||
patch.content = exported.markdown;
|
||||
patch.editorData = exported.editorData as unknown as Record<string, unknown>;
|
||||
}
|
||||
if (titleChanged) {
|
||||
patch.title = env.getTitle();
|
||||
}
|
||||
|
||||
if (persist && Object.keys(patch).length > 0) {
|
||||
await documentService.updateDocument(documentId, {
|
||||
content: patch.content,
|
||||
editorData: patch.editorData,
|
||||
saveSource: 'llm_call',
|
||||
title: patch.title,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: handlerResult.content,
|
||||
state: {
|
||||
...handlerResult.state,
|
||||
documentContent: patch.content,
|
||||
documentEditorData: patch.editorData,
|
||||
documentTitle: env.getTitle(),
|
||||
...(invariantViolation ? { invariantViolation } : {}),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
env.headless.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const buildService = (
|
||||
db: LobeChatDatabase,
|
||||
userId: string,
|
||||
workspaceId?: string,
|
||||
): PageAgentRuntimeService => {
|
||||
const documentModel = new DocumentModel(db, userId, workspaceId);
|
||||
const documentService = new DocumentService(db, userId, workspaceId);
|
||||
const buildService = (db: LobeChatDatabase, userId: string): PageAgentRuntimeService => {
|
||||
const documentModel = new DocumentModel(db, userId);
|
||||
const documentService = new DocumentService(db, userId);
|
||||
const serviceCtx: PageAgentServiceContext = { documentModel, documentService };
|
||||
|
||||
return {
|
||||
@@ -408,9 +393,7 @@ export const pageAgentRuntime: ServerRuntimeRegistration = {
|
||||
if (!context.userId || !context.serverDB) {
|
||||
throw new Error('userId and serverDB are required for Page Agent execution');
|
||||
}
|
||||
return new PageAgentExecutionRuntime(
|
||||
buildService(context.serverDB, context.userId, context.workspaceId),
|
||||
);
|
||||
return new PageAgentExecutionRuntime(buildService(context.serverDB, context.userId));
|
||||
},
|
||||
identifier: PageAgentIdentifier,
|
||||
};
|
||||
|
||||
+170
-342
@@ -1,384 +1,212 @@
|
||||
/**
|
||||
* Mock data for Discover/Community module.
|
||||
*
|
||||
* Community E2E tests should not depend on the live marketplace service. These
|
||||
* fixtures mirror the data shape returned by the app's tRPC market router.
|
||||
* Mock data for Discover/Community module
|
||||
*/
|
||||
import type {
|
||||
AssistantListResponse,
|
||||
DiscoverAssistantItem,
|
||||
DiscoverMcpItem,
|
||||
DiscoverModelItem,
|
||||
DiscoverProviderItem,
|
||||
McpListResponse,
|
||||
ModelListResponse,
|
||||
ProviderListResponse,
|
||||
} from './types';
|
||||
|
||||
const CREATED_AT = '2026-01-01T00:00:00.000Z';
|
||||
const UPDATED_AT = '2026-01-10T00:00:00.000Z';
|
||||
|
||||
// ============================================
|
||||
// Assistant Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockAssistantItems: DiscoverAssistantItem[] = [
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🤖',
|
||||
backgroundColor: '#1890ff',
|
||||
category: 'general',
|
||||
config: {
|
||||
openingMessage: 'Hello, I am your general assistant.',
|
||||
openingQuestions: ['What can you do?'],
|
||||
params: {},
|
||||
plugins: [],
|
||||
systemRole: 'You are a helpful general-purpose assistant for E2E tests.',
|
||||
},
|
||||
createdAt: CREATED_AT,
|
||||
description: 'A versatile AI assistant for general tasks and conversations.',
|
||||
identifier: 'general-assistant',
|
||||
installCount: 1000,
|
||||
knowledgeCount: 1,
|
||||
pluginCount: 0,
|
||||
summary: 'General-purpose assistant fixture.',
|
||||
tags: ['general', 'fixture'],
|
||||
title: 'General Assistant',
|
||||
tokenUsage: 4096,
|
||||
type: 'agent',
|
||||
updatedAt: UPDATED_AT,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '💻',
|
||||
backgroundColor: '#52c41a',
|
||||
category: 'programming',
|
||||
config: {
|
||||
openingMessage: 'Ready to help with development tasks.',
|
||||
openingQuestions: ['Review this function'],
|
||||
params: {},
|
||||
plugins: [],
|
||||
systemRole: 'You are an expert coding assistant for E2E tests.',
|
||||
},
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Developer and coding assistant for software engineering workflows.',
|
||||
identifier: 'code-assistant',
|
||||
installCount: 800,
|
||||
knowledgeCount: 2,
|
||||
pluginCount: 1,
|
||||
summary: 'Developer assistant fixture.',
|
||||
tags: ['developer', 'programming'],
|
||||
title: 'Code Assistant',
|
||||
tokenUsage: 8192,
|
||||
type: 'agent',
|
||||
updatedAt: UPDATED_AT,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🎓',
|
||||
backgroundColor: '#faad14',
|
||||
category: 'academic',
|
||||
config: {
|
||||
openingMessage: 'Let us study together.',
|
||||
openingQuestions: ['Explain this concept'],
|
||||
params: {},
|
||||
plugins: [],
|
||||
systemRole: 'You are an academic tutor for E2E tests.',
|
||||
},
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Academic research and study assistant for reliable category filtering.',
|
||||
identifier: 'academic-tutor',
|
||||
installCount: 640,
|
||||
knowledgeCount: 3,
|
||||
pluginCount: 0,
|
||||
summary: 'Academic assistant fixture.',
|
||||
tags: ['academic', 'education'],
|
||||
title: 'Academic Tutor',
|
||||
tokenUsage: 4096,
|
||||
type: 'agent',
|
||||
updatedAt: UPDATED_AT,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '✍️',
|
||||
backgroundColor: '#722ed1',
|
||||
category: 'copywriting',
|
||||
config: {
|
||||
openingMessage: 'Tell me what you want to write.',
|
||||
openingQuestions: ['Draft a product intro'],
|
||||
params: {},
|
||||
plugins: [],
|
||||
systemRole: 'You are a writing assistant for E2E tests.',
|
||||
},
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Professional writing assistant for content creation.',
|
||||
identifier: 'writing-assistant',
|
||||
installCount: 600,
|
||||
knowledgeCount: 1,
|
||||
pluginCount: 0,
|
||||
summary: 'Writing assistant fixture.',
|
||||
tags: ['copywriting'],
|
||||
title: 'Writing Assistant',
|
||||
tokenUsage: 4096,
|
||||
type: 'agent',
|
||||
updatedAt: UPDATED_AT,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockAssistantList: AssistantListResponse = {
|
||||
currentPage: 1,
|
||||
items: mockAssistantItems,
|
||||
pageSize: 21,
|
||||
totalCount: 42,
|
||||
totalPages: 2,
|
||||
items: [
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🤖',
|
||||
backgroundColor: '#1890ff',
|
||||
category: 'general',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
description: 'A versatile AI assistant for general tasks and conversations.',
|
||||
identifier: 'general-assistant',
|
||||
installCount: 1000,
|
||||
knowledgeCount: 5,
|
||||
pluginCount: 3,
|
||||
title: 'General Assistant',
|
||||
tokenUsage: 4096,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '💻',
|
||||
backgroundColor: '#52c41a',
|
||||
category: 'programming',
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
description: 'Expert coding assistant for software development.',
|
||||
identifier: 'code-assistant',
|
||||
installCount: 800,
|
||||
knowledgeCount: 10,
|
||||
pluginCount: 5,
|
||||
title: 'Code Assistant',
|
||||
tokenUsage: 8192,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '✍️',
|
||||
backgroundColor: '#722ed1',
|
||||
category: 'copywriting',
|
||||
createdAt: '2024-01-03T00:00:00.000Z',
|
||||
description: 'Professional writing assistant for content creation.',
|
||||
identifier: 'writing-assistant',
|
||||
installCount: 600,
|
||||
knowledgeCount: 3,
|
||||
pluginCount: 2,
|
||||
title: 'Writing Assistant',
|
||||
tokenUsage: 4096,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockAssistantCategories = [
|
||||
{ category: 'general', count: 12 },
|
||||
{ category: 'programming', count: 10 },
|
||||
{ category: 'academic', count: 8 },
|
||||
{ category: 'copywriting', count: 6 },
|
||||
{ id: 'general', name: 'General' },
|
||||
{ id: 'programming', name: 'Programming' },
|
||||
{ id: 'copywriting', name: 'Copywriting' },
|
||||
{ id: 'education', name: 'Education' },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// Model Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockModelItems: DiscoverModelItem[] = [
|
||||
{
|
||||
abilities: { functionCall: true, reasoning: true, vision: true },
|
||||
contextWindowTokens: 128_000,
|
||||
description: 'Most capable fixture model for complex tasks.',
|
||||
displayName: 'GPT-4o',
|
||||
id: 'gpt-4o',
|
||||
identifier: 'gpt-4o',
|
||||
providerCount: 2,
|
||||
providers: ['openai', 'lobehub'],
|
||||
releasedAt: CREATED_AT,
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: { functionCall: true, reasoning: true, vision: false },
|
||||
contextWindowTokens: 200_000,
|
||||
description: 'Advanced AI assistant fixture by Anthropic.',
|
||||
displayName: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
identifier: 'claude-3-5-sonnet-20241022',
|
||||
providerCount: 1,
|
||||
providers: ['anthropic'],
|
||||
releasedAt: CREATED_AT,
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: { functionCall: false, reasoning: false, vision: false },
|
||||
contextWindowTokens: 32_768,
|
||||
description: 'Open source language model fixture.',
|
||||
displayName: 'Llama 3.1 70B',
|
||||
id: 'llama-3.1-70b',
|
||||
identifier: 'llama-3.1-70b',
|
||||
providerCount: 1,
|
||||
providers: ['meta'],
|
||||
releasedAt: CREATED_AT,
|
||||
type: 'chat',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockModelList: ModelListResponse = {
|
||||
currentPage: 1,
|
||||
items: mockModelItems,
|
||||
pageSize: 21,
|
||||
totalCount: mockModelItems.length,
|
||||
totalPages: 1,
|
||||
items: [
|
||||
{
|
||||
abilities: { functionCall: true, reasoning: true, vision: true },
|
||||
contextWindowTokens: 128_000,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
description: 'Most capable model for complex tasks',
|
||||
displayName: 'GPT-4o',
|
||||
id: 'gpt-4o',
|
||||
providerId: 'openai',
|
||||
providerName: 'OpenAI',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: { functionCall: true, reasoning: true, vision: false },
|
||||
contextWindowTokens: 200_000,
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
description: 'Advanced AI assistant by Anthropic',
|
||||
displayName: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
providerId: 'anthropic',
|
||||
providerName: 'Anthropic',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: { functionCall: false, reasoning: false, vision: false },
|
||||
contextWindowTokens: 32_768,
|
||||
createdAt: '2024-01-03T00:00:00.000Z',
|
||||
description: 'Open source language model',
|
||||
displayName: 'Llama 3.1 70B',
|
||||
id: 'llama-3.1-70b',
|
||||
providerId: 'meta',
|
||||
providerName: 'Meta',
|
||||
type: 'chat',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Provider Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockProviderItems: DiscoverProviderItem[] = [
|
||||
{
|
||||
description: 'Leading AI research company fixture.',
|
||||
identifier: 'openai',
|
||||
modelCount: 2,
|
||||
models: ['gpt-4o', 'gpt-4o-mini'],
|
||||
name: 'OpenAI',
|
||||
url: 'https://openai.com',
|
||||
},
|
||||
{
|
||||
description: 'AI safety focused research company fixture.',
|
||||
identifier: 'anthropic',
|
||||
modelCount: 1,
|
||||
models: ['claude-3-5-sonnet-20241022'],
|
||||
name: 'Anthropic',
|
||||
url: 'https://anthropic.com',
|
||||
},
|
||||
{
|
||||
description: 'Open source AI leader fixture.',
|
||||
identifier: 'meta',
|
||||
modelCount: 1,
|
||||
models: ['llama-3.1-70b'],
|
||||
name: 'Meta',
|
||||
url: 'https://ai.meta.com',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockProviderList: ProviderListResponse = {
|
||||
currentPage: 1,
|
||||
items: mockProviderItems,
|
||||
pageSize: 21,
|
||||
totalCount: mockProviderItems.length,
|
||||
totalPages: 1,
|
||||
items: [
|
||||
{
|
||||
description: 'Leading AI research company',
|
||||
id: 'openai',
|
||||
logo: 'https://example.com/openai.png',
|
||||
modelCount: 10,
|
||||
name: 'OpenAI',
|
||||
},
|
||||
{
|
||||
description: 'AI safety focused research company',
|
||||
id: 'anthropic',
|
||||
logo: 'https://example.com/anthropic.png',
|
||||
modelCount: 5,
|
||||
name: 'Anthropic',
|
||||
},
|
||||
{
|
||||
description: 'Open source AI leader',
|
||||
id: 'meta',
|
||||
logo: 'https://example.com/meta.png',
|
||||
modelCount: 8,
|
||||
name: 'Meta',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MCP Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockMcpItems: DiscoverMcpItem[] = [
|
||||
{
|
||||
author: 'LobeHub',
|
||||
capabilities: { prompts: false, resources: false, tools: true },
|
||||
category: 'business',
|
||||
connectionType: 'stdio',
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Business automation MCP tool fixture.',
|
||||
github: { stars: 1200, url: 'https://github.com/lobehub/e2e-business-mcp' },
|
||||
icon: '📊',
|
||||
identifier: 'business-automation',
|
||||
installCount: 500,
|
||||
installationMethods: 'npm',
|
||||
isClaimed: true,
|
||||
isFeatured: true,
|
||||
isOfficial: true,
|
||||
isValidated: true,
|
||||
manifestUrl: 'https://example.com/business-automation/manifest.json',
|
||||
name: 'Business Automation',
|
||||
toolsCount: 3,
|
||||
updatedAt: UPDATED_AT,
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
capabilities: { prompts: false, resources: true, tools: true },
|
||||
category: 'developer',
|
||||
connectionType: 'stdio',
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Developer file-system MCP fixture.',
|
||||
github: { stars: 900, url: 'https://github.com/lobehub/e2e-file-mcp' },
|
||||
icon: '📁',
|
||||
identifier: 'file-manager',
|
||||
installCount: 300,
|
||||
installationMethods: 'npm',
|
||||
isClaimed: true,
|
||||
isFeatured: false,
|
||||
isOfficial: false,
|
||||
isValidated: true,
|
||||
manifestUrl: 'https://example.com/file-manager/manifest.json',
|
||||
name: 'File Manager',
|
||||
resourcesCount: 2,
|
||||
toolsCount: 5,
|
||||
updatedAt: UPDATED_AT,
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
capabilities: { prompts: true, resources: false, tools: true },
|
||||
category: 'productivity',
|
||||
connectionType: 'http',
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Productivity search MCP fixture.',
|
||||
github: { stars: 600, url: 'https://github.com/lobehub/e2e-search-mcp' },
|
||||
icon: '🔍',
|
||||
identifier: 'web-search',
|
||||
installCount: 260,
|
||||
installationMethods: 'docker',
|
||||
isClaimed: false,
|
||||
isFeatured: false,
|
||||
isOfficial: false,
|
||||
isValidated: true,
|
||||
manifestUrl: 'https://example.com/web-search/manifest.json',
|
||||
name: 'Web Search',
|
||||
promptsCount: 1,
|
||||
toolsCount: 2,
|
||||
updatedAt: UPDATED_AT,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockMcpList: McpListResponse = {
|
||||
categories: ['business', 'developer', 'productivity'],
|
||||
currentPage: 1,
|
||||
items: mockMcpItems,
|
||||
pageSize: 21,
|
||||
totalCount: mockMcpItems.length,
|
||||
totalPages: 1,
|
||||
items: [
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🔍',
|
||||
category: 'search',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
description: 'Web search capabilities for AI assistants',
|
||||
identifier: 'web-search',
|
||||
installCount: 500,
|
||||
title: 'Web Search',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '📁',
|
||||
category: 'file',
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
description: 'File system operations and management',
|
||||
identifier: 'file-manager',
|
||||
installCount: 300,
|
||||
title: 'File Manager',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🗄️',
|
||||
category: 'database',
|
||||
createdAt: '2024-01-03T00:00:00.000Z',
|
||||
description: 'Database query and management tools',
|
||||
identifier: 'db-tools',
|
||||
installCount: 200,
|
||||
title: 'Database Tools',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockMcpCategories = [
|
||||
{ category: 'business', count: 7 },
|
||||
{ category: 'developer', count: 5 },
|
||||
{ category: 'productivity', count: 3 },
|
||||
{ id: 'search', name: 'Search' },
|
||||
{ id: 'file', name: 'File' },
|
||||
{ id: 'database', name: 'Database' },
|
||||
{ id: 'utility', name: 'Utility' },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// Detail Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockAssistantDetails = mockAssistantItems.map((item) => ({
|
||||
...item,
|
||||
currentVersion: '1.0.0',
|
||||
related: mockAssistantItems
|
||||
.filter((related) => related.identifier !== item.identifier)
|
||||
.slice(0, 3),
|
||||
versions: [
|
||||
{
|
||||
createdAt: item.createdAt,
|
||||
isLatest: true,
|
||||
isValidated: true,
|
||||
status: 'published',
|
||||
version: '1.0.0',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
export const mockMcpDetails = mockMcpItems.map((item) => ({
|
||||
...item,
|
||||
author: { name: item.author ?? 'LobeHub', url: 'https://lobehub.com' },
|
||||
deploymentOptions: [
|
||||
{
|
||||
connection: { command: 'npx', type: item.connectionType ?? 'stdio' },
|
||||
installationMethod: item.installationMethods ?? 'npm',
|
||||
title: 'E2E recommended deployment',
|
||||
},
|
||||
],
|
||||
overview: {
|
||||
readme: `# ${item.name}\n\n${item.description}`,
|
||||
summary: item.description,
|
||||
},
|
||||
related: mockMcpItems.filter((related) => related.identifier !== item.identifier).slice(0, 2),
|
||||
tools: [{ description: 'Fixture tool for E2E tests', name: 'fixtureTool' }],
|
||||
version: '1.0.0',
|
||||
versions: [{ isLatest: true, version: '1.0.0' }],
|
||||
}));
|
||||
|
||||
export const mockModelDetails = mockModelItems.map((item) => ({
|
||||
...item,
|
||||
providers: mockProviderItems.map((provider) => ({
|
||||
...provider,
|
||||
id: provider.identifier,
|
||||
model: item,
|
||||
})),
|
||||
related: mockModelItems.filter((related) => related.identifier !== item.identifier).slice(0, 2),
|
||||
}));
|
||||
|
||||
export const mockProviderDetails = mockProviderItems.map((item) => ({
|
||||
...item,
|
||||
models: mockModelItems
|
||||
.filter((model) => item.models.includes(model.identifier))
|
||||
.map((model) => ({ ...model, maxOutput: 4096 })),
|
||||
readme: `# ${item.name}\n\n${item.description}`,
|
||||
related: mockProviderItems
|
||||
.filter((related) => related.identifier !== item.identifier)
|
||||
.slice(0, 2),
|
||||
}));
|
||||
|
||||
+152
-319
@@ -1,346 +1,179 @@
|
||||
/**
|
||||
* Mock handlers for Discover/Community API endpoints.
|
||||
* Mock handlers for Discover/Community API endpoints
|
||||
*/
|
||||
import type { Request, Route } from 'playwright';
|
||||
import superjson from 'superjson';
|
||||
import type { Route } from 'playwright';
|
||||
|
||||
import type { MockHandler } from '../index';
|
||||
import { type MockHandler, createTrpcResponse } from '../index';
|
||||
import {
|
||||
mockAssistantCategories,
|
||||
mockAssistantDetails,
|
||||
mockAssistantItems,
|
||||
mockAssistantList,
|
||||
mockMcpCategories,
|
||||
mockMcpDetails,
|
||||
mockMcpItems,
|
||||
mockMcpList,
|
||||
mockModelDetails,
|
||||
mockModelItems,
|
||||
mockModelList,
|
||||
mockProviderDetails,
|
||||
mockProviderItems,
|
||||
mockProviderList,
|
||||
} from './data';
|
||||
|
||||
interface IdentifierEntry {
|
||||
identifier: string;
|
||||
lastModified: string;
|
||||
// ============================================
|
||||
// Helper to parse tRPC batch requests
|
||||
// ============================================
|
||||
|
||||
function parseTrpcUrl(url: string): { input?: Record<string, unknown>; procedure: string } {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
|
||||
// Extract procedure name from path like /trpc/lambda.market.getAssistantList
|
||||
const procedureMatch = pathname.match(/lambda\.market\.(\w+)/);
|
||||
const procedure = procedureMatch ? procedureMatch[1] : '';
|
||||
|
||||
// Parse input from query string
|
||||
let input: Record<string, unknown> | undefined;
|
||||
const inputParam = urlObj.searchParams.get('input');
|
||||
if (inputParam) {
|
||||
try {
|
||||
input = JSON.parse(inputParam);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return { input, procedure };
|
||||
}
|
||||
|
||||
const SUCCESS_RESPONSE = { success: true };
|
||||
// ============================================
|
||||
// Mock Handlers
|
||||
// ============================================
|
||||
|
||||
const createTrpcResult = <T>(data: T) => ({
|
||||
result: {
|
||||
data: superjson.serialize(data),
|
||||
},
|
||||
});
|
||||
|
||||
const createTrpcResponse = <T>(data: T): string => JSON.stringify(createTrpcResult(data));
|
||||
|
||||
const createTrpcBatchResponse = <T>(data: T[]): string =>
|
||||
JSON.stringify(data.map((item) => createTrpcResult(item)));
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const getStringInput = (input: unknown, key: string): string | undefined => {
|
||||
if (!isRecord(input)) return undefined;
|
||||
const value = input[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
};
|
||||
|
||||
const getNumberInput = (input: unknown, key: string): number | undefined => {
|
||||
if (!isRecord(input)) return undefined;
|
||||
const value = input[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : undefined;
|
||||
};
|
||||
|
||||
const createIdentifiers = (
|
||||
items: { identifier: string; updatedAt?: string }[],
|
||||
): IdentifierEntry[] =>
|
||||
items.map((item) => ({ identifier: item.identifier, lastModified: item.updatedAt ?? '' }));
|
||||
|
||||
const unwrapTrpcInput = (input: unknown): unknown => {
|
||||
if (!isRecord(input)) return input;
|
||||
|
||||
if ('json' in input) return input.json;
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
const parseRequestInput = (request: Request, url: URL): unknown => {
|
||||
const input = url.searchParams.get('input');
|
||||
|
||||
if (input) {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return request.postDataJSON();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getProcedureInputs = (request: Request, url: URL, count: number): unknown[] => {
|
||||
const rawInput = parseRequestInput(request, url);
|
||||
const isBatch = url.searchParams.get('batch') === '1' || count > 1;
|
||||
|
||||
if (isBatch && isRecord(rawInput)) {
|
||||
return Array.from({ length: count }, (_, index) => unwrapTrpcInput(rawInput[String(index)]));
|
||||
}
|
||||
|
||||
return [unwrapTrpcInput(rawInput)];
|
||||
};
|
||||
|
||||
const getProcedures = (url: URL): string[] => {
|
||||
const marker = '/trpc/lambda/';
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
const markerIndex = pathname.indexOf(marker);
|
||||
|
||||
if (markerIndex === -1) return [];
|
||||
|
||||
const procedureSegment = pathname.slice(markerIndex + marker.length);
|
||||
return procedureSegment.split(',').filter(Boolean);
|
||||
};
|
||||
|
||||
const isMarketProcedure = (procedure: string): boolean => procedure.startsWith('market.');
|
||||
|
||||
const matchesText = (value: string | undefined, query: string) =>
|
||||
value?.toLowerCase().includes(query.toLowerCase()) ?? false;
|
||||
|
||||
const paginate = <T>(items: T[], input: unknown, fallbackTotal = items.length) => {
|
||||
const page = getNumberInput(input, 'page') ?? 1;
|
||||
const pageSize = getNumberInput(input, 'pageSize') ?? 21;
|
||||
|
||||
return {
|
||||
currentPage: page,
|
||||
items,
|
||||
pageSize,
|
||||
totalCount: Math.max(fallbackTotal, items.length),
|
||||
totalPages: Math.max(1, Math.ceil(Math.max(fallbackTotal, items.length) / pageSize)),
|
||||
};
|
||||
};
|
||||
|
||||
const getAssistantList = (input: unknown) => {
|
||||
const category = getStringInput(input, 'category');
|
||||
const query = getStringInput(input, 'q');
|
||||
|
||||
let items = mockAssistantItems;
|
||||
|
||||
if (category && !['all', 'discover'].includes(category)) {
|
||||
const filtered = items.filter((item) => item.category === category);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
matchesText(item.title, query) ||
|
||||
matchesText(item.description, query) ||
|
||||
matchesText(item.identifier, query) ||
|
||||
matchesText(item.tags?.join(' '), query),
|
||||
);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
return { ...mockAssistantList, ...paginate(items, input, 42) };
|
||||
};
|
||||
|
||||
const getMcpList = (input: unknown) => {
|
||||
const category = getStringInput(input, 'category');
|
||||
const query = getStringInput(input, 'q');
|
||||
|
||||
let items = mockMcpItems;
|
||||
|
||||
if (category && !['all', 'discover'].includes(category)) {
|
||||
const filtered = items.filter((item) => item.category === category);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
matchesText(item.name, query) ||
|
||||
matchesText(item.description, query) ||
|
||||
matchesText(item.identifier, query),
|
||||
);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
return { ...mockMcpList, ...paginate(items, input), categories: mockMcpList.categories };
|
||||
};
|
||||
|
||||
const getModelList = (input: unknown) => {
|
||||
const query = getStringInput(input, 'q');
|
||||
|
||||
let items = mockModelItems;
|
||||
if (query) {
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
matchesText(item.displayName, query) ||
|
||||
matchesText(item.description, query) ||
|
||||
matchesText(item.identifier, query),
|
||||
);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
return { ...mockModelList, ...paginate(items, input) };
|
||||
};
|
||||
|
||||
const getProviderList = (input: unknown) => {
|
||||
const query = getStringInput(input, 'q');
|
||||
|
||||
let items = mockProviderItems;
|
||||
if (query) {
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
matchesText(item.name, query) ||
|
||||
matchesText(item.description, query) ||
|
||||
matchesText(item.identifier, query),
|
||||
);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
return { ...mockProviderList, ...paginate(items, input) };
|
||||
};
|
||||
|
||||
const findByIdentifier = <T extends { identifier: string }>(items: T[], input: unknown): T => {
|
||||
const identifier = getStringInput(input, 'identifier');
|
||||
return items.find((item) => item.identifier === identifier) ?? items[0];
|
||||
};
|
||||
|
||||
const getMockResponse = (procedure: string, input: unknown): unknown => {
|
||||
switch (procedure) {
|
||||
case 'market.getAssistantCategories': {
|
||||
return mockAssistantCategories;
|
||||
}
|
||||
|
||||
case 'market.getAssistantDetail': {
|
||||
return findByIdentifier(mockAssistantDetails, input);
|
||||
}
|
||||
|
||||
case 'market.getAssistantIdentifiers': {
|
||||
return createIdentifiers(mockAssistantItems);
|
||||
}
|
||||
|
||||
case 'market.getAssistantList': {
|
||||
return getAssistantList(input);
|
||||
}
|
||||
|
||||
case 'market.getMcpCategories': {
|
||||
return mockMcpCategories;
|
||||
}
|
||||
|
||||
case 'market.getMcpDetail': {
|
||||
return findByIdentifier(mockMcpDetails, input);
|
||||
}
|
||||
|
||||
case 'market.getMcpList': {
|
||||
return getMcpList(input);
|
||||
}
|
||||
|
||||
case 'market.getModelCategories': {
|
||||
return [];
|
||||
}
|
||||
|
||||
case 'market.getModelDetail': {
|
||||
return findByIdentifier(mockModelDetails, input);
|
||||
}
|
||||
|
||||
case 'market.getModelIdentifiers': {
|
||||
return createIdentifiers(mockModelItems);
|
||||
}
|
||||
|
||||
case 'market.getModelList': {
|
||||
return getModelList(input);
|
||||
}
|
||||
|
||||
case 'market.getProviderDetail': {
|
||||
return findByIdentifier(mockProviderDetails, input);
|
||||
}
|
||||
|
||||
case 'market.getProviderIdentifiers': {
|
||||
return createIdentifiers(mockProviderItems);
|
||||
}
|
||||
|
||||
case 'market.getProviderList': {
|
||||
return getProviderList(input);
|
||||
}
|
||||
|
||||
case 'market.registerClientInMarketplace': {
|
||||
return { clientId: 'e2e-market-client', clientSecret: 'e2e-market-secret' };
|
||||
}
|
||||
|
||||
case 'market.registerM2MToken': {
|
||||
return SUCCESS_RESPONSE;
|
||||
}
|
||||
|
||||
case 'market.reportAgentEvent':
|
||||
case 'market.reportAgentInstall':
|
||||
case 'market.reportCall':
|
||||
case 'market.reportGroupAgentEvent':
|
||||
case 'market.reportGroupAgentInstall':
|
||||
case 'market.reportMcpEvent':
|
||||
case 'market.reportMcpInstallResult': {
|
||||
return SUCCESS_RESPONSE;
|
||||
}
|
||||
|
||||
case 'plugin.getPlugins': {
|
||||
return [];
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log(` ⚠️ Unhandled mocked lambda endpoint: ${procedure}`);
|
||||
return SUCCESS_RESPONSE;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const marketHandler: MockHandler = {
|
||||
/**
|
||||
* Handler for assistant list endpoint
|
||||
*/
|
||||
const assistantListHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const procedures = getProcedures(url);
|
||||
|
||||
if (!procedures.some(isMarketProcedure)) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
const inputs = getProcedureInputs(request, url, procedures.length);
|
||||
|
||||
// Keep tRPC batch positions intact. Community pages can batch mocked
|
||||
// market.* calls with normal app calls, such as plugin.getPlugins on the MCP
|
||||
// detail page; returning only market responses would make the batch client
|
||||
// read the wrong result for subsequent procedures.
|
||||
const responses = procedures.map((procedure, index) =>
|
||||
getMockResponse(procedure, inputs[index]),
|
||||
);
|
||||
const isBatch = url.searchParams.get('batch') === '1' || procedures.length > 1;
|
||||
|
||||
await route.fulfill({
|
||||
body: isBatch ? createTrpcBatchResponse(responses) : createTrpcResponse(responses[0]),
|
||||
body: createTrpcResponse(mockAssistantList),
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Set-Cookie': 'mp_token_status=active; Path=/; SameSite=Lax',
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/**',
|
||||
pattern: '**/trpc/lambda/market.getAssistantList**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for assistant categories endpoint
|
||||
*/
|
||||
const assistantCategoriesHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockAssistantCategories),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getAssistantCategories**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for model list endpoint
|
||||
*/
|
||||
const modelListHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockModelList),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getModelList**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for provider list endpoint
|
||||
*/
|
||||
const providerListHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockProviderList),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getProviderList**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for MCP list endpoint
|
||||
*/
|
||||
const mcpListHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockMcpList),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getMcpList**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for MCP categories endpoint
|
||||
*/
|
||||
const mcpCategoriesHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockMcpCategories),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getMcpCategories**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Debug handler to log all trpc requests
|
||||
*/
|
||||
const trpcDebugHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
const url = route.request().url();
|
||||
console.log(` 🔍 TRPC Request: ${url}`);
|
||||
await route.continue();
|
||||
},
|
||||
pattern: '**/trpc/**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fallback handler for any unhandled market endpoints
|
||||
* Returns empty data to prevent hanging requests
|
||||
*/
|
||||
const marketFallbackHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const { procedure } = parseTrpcUrl(url);
|
||||
|
||||
console.log(` ⚠️ Unhandled market endpoint: ${procedure}`);
|
||||
|
||||
// Return empty response to prevent timeout
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse({ items: [], pagination: { page: 1, pageSize: 12, total: 0 } }),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.**',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Export all handlers
|
||||
// ============================================
|
||||
|
||||
export const discoverHandlers: MockHandler[] = [marketHandler];
|
||||
export const discoverHandlers: MockHandler[] = [
|
||||
// Debug handler first to log all requests
|
||||
trpcDebugHandler,
|
||||
// Specific handlers (order matters - more specific first)
|
||||
assistantListHandler,
|
||||
assistantCategoriesHandler,
|
||||
modelListHandler,
|
||||
providerListHandler,
|
||||
mcpListHandler,
|
||||
mcpCategoriesHandler,
|
||||
// Fallback handler (should be last)
|
||||
marketFallbackHandler,
|
||||
];
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
/**
|
||||
* Type definitions for Discover mock data.
|
||||
*
|
||||
* Keep these small and E2E-focused: they only include fields the Community UI
|
||||
* reads while rendering list and detail pages.
|
||||
* Type definitions for Discover mock data
|
||||
* These mirror the actual types from the application
|
||||
*/
|
||||
|
||||
export interface ListResponse<T> {
|
||||
currentPage: number;
|
||||
items: T[];
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@@ -22,25 +19,21 @@ export interface DiscoverAssistantItem {
|
||||
avatar: string;
|
||||
backgroundColor?: string;
|
||||
category: string;
|
||||
config?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
examples?: Record<string, unknown>[];
|
||||
identifier: string;
|
||||
installCount?: number;
|
||||
knowledgeCount?: number;
|
||||
pluginCount?: number;
|
||||
related?: DiscoverAssistantItem[];
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
title: string;
|
||||
tokenUsage?: number;
|
||||
type?: 'agent' | 'agent-group';
|
||||
updatedAt?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export type AssistantListResponse = ListResponse<DiscoverAssistantItem>;
|
||||
export interface AssistantListResponse {
|
||||
items: DiscoverAssistantItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Model Types
|
||||
@@ -53,17 +46,19 @@ export interface DiscoverModelItem {
|
||||
vision?: boolean;
|
||||
};
|
||||
contextWindowTokens: number;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
displayName: string;
|
||||
id: string;
|
||||
identifier: string;
|
||||
providerCount: number;
|
||||
providers: string[];
|
||||
releasedAt?: string;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type ModelListResponse = ListResponse<DiscoverModelItem>;
|
||||
export interface ModelListResponse {
|
||||
items: DiscoverModelItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Provider Types
|
||||
@@ -71,50 +66,33 @@ export type ModelListResponse = ListResponse<DiscoverModelItem>;
|
||||
|
||||
export interface DiscoverProviderItem {
|
||||
description: string;
|
||||
identifier: string;
|
||||
id: string;
|
||||
logo?: string;
|
||||
modelCount: number;
|
||||
models: string[];
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type ProviderListResponse = ListResponse<DiscoverProviderItem>;
|
||||
export interface ProviderListResponse {
|
||||
items: DiscoverProviderItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MCP Types
|
||||
// ============================================
|
||||
|
||||
export interface DiscoverMcpItem {
|
||||
author?: string;
|
||||
capabilities: {
|
||||
prompts: boolean;
|
||||
resources: boolean;
|
||||
tools: boolean;
|
||||
};
|
||||
author: string;
|
||||
avatar: string;
|
||||
category: string;
|
||||
connectionType?: 'http' | 'stdio';
|
||||
createdAt: string;
|
||||
description: string;
|
||||
github?: {
|
||||
stars?: number;
|
||||
url: string;
|
||||
};
|
||||
icon?: string;
|
||||
identifier: string;
|
||||
installationMethods?: string;
|
||||
installCount?: number;
|
||||
isClaimed?: boolean;
|
||||
isFeatured?: boolean;
|
||||
isOfficial?: boolean;
|
||||
isValidated?: boolean;
|
||||
manifestUrl: string;
|
||||
name: string;
|
||||
promptsCount?: number;
|
||||
resourcesCount?: number;
|
||||
toolsCount?: number;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface McpListResponse extends ListResponse<DiscoverMcpItem> {
|
||||
categories: string[];
|
||||
export interface McpListResponse {
|
||||
items: DiscoverMcpItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
+5
-17
@@ -5,7 +5,6 @@
|
||||
* It uses Playwright's route interception to mock tRPC and REST API calls.
|
||||
*/
|
||||
import type { Page, Route } from 'playwright';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { discoverMocks } from './community';
|
||||
|
||||
@@ -125,23 +124,12 @@ export class MockManager {
|
||||
/**
|
||||
* Create a JSON response for tRPC endpoints
|
||||
*/
|
||||
export function createTrpcResult<T>(data: T) {
|
||||
return {
|
||||
result: {
|
||||
data: superjson.serialize(data),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTrpcResponse<T>(data: T): string {
|
||||
return JSON.stringify(createTrpcResult(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSON response for batched tRPC endpoints
|
||||
*/
|
||||
export function createTrpcBatchResponse<T>(data: T[]): string {
|
||||
return JSON.stringify(data.map((item) => createTrpcResult(item)));
|
||||
return JSON.stringify({
|
||||
result: {
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-11
@@ -1,7 +1,6 @@
|
||||
import { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber';
|
||||
import { chromium, type Cookie } from 'playwright';
|
||||
|
||||
import { mockManager } from '../mocks';
|
||||
import { seedTestUser, TEST_USER } from '../support/seedTestUser';
|
||||
import { startWebServer, stopWebServer } from '../support/webServer';
|
||||
import type { CustomWorld } from '../support/world';
|
||||
@@ -107,16 +106,8 @@ Before(async function (this: CustomWorld, { pickle }) {
|
||||
);
|
||||
console.log(`\n📝 Running: ${pickle.name}${testId ? ` (${testId.name.replace('@', '')})` : ''}`);
|
||||
|
||||
// Setup Community API mocks before any page navigation. These PR E2E scenarios
|
||||
// are the user-experience baseline for Community UI flows (list/search/filter/
|
||||
// detail navigation), not a live marketplace availability check. The live
|
||||
// marketplace rate-limits anonymous CI traffic, so Community scenarios use
|
||||
// deterministic fixtures while the rest of the E2E suite keeps real app APIs.
|
||||
// If we need to validate the real marketplace contract, cover that in a
|
||||
// separate integration/nightly suite with dedicated credentials and SLA.
|
||||
if (pickle.tags.some((tag) => tag.name === '@community')) {
|
||||
await mockManager.setup(this.page);
|
||||
}
|
||||
// Setup API mocks before any page navigation
|
||||
// await mockManager.setup(this.page);
|
||||
|
||||
// Set cached session cookies to skip login
|
||||
if (sessionCookies.length > 0) {
|
||||
|
||||
+2
-25
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي.\n\nهل ترغب في أن أتناسب مع سير عملك بشكل أفضل؟ انتقل إلى [إعدادات الوكيل]({{url}}) واملأ ملف تعريف الوكيل (يمكنك تعديله في أي وقت).",
|
||||
"agentDefaultMessageWithSystemRole": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي — أنت المتحكم.",
|
||||
"agentDefaultMessageWithoutEdit": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي — أنت المتحكم.",
|
||||
"agentDocument.backToChat": "العودة إلى الدردشة",
|
||||
"agentDocument.linkCopied": "تم نسخ الرابط",
|
||||
"agentDocument.openAsPage": "افتح كصفحة كاملة",
|
||||
"agentProfile.files_one": "{{count}} ملف",
|
||||
"agentProfile.files_other": "{{count}} ملفات",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} قاعدة معرفة",
|
||||
@@ -47,7 +44,6 @@
|
||||
"builtinCopilot": "المساعد المدمج",
|
||||
"chatList.expandMessage": "توسيع الرسالة",
|
||||
"chatList.longMessageDetail": "عرض التفاصيل",
|
||||
"chatList.refreshing": "جلب أحدث الرسائل...",
|
||||
"chatMode.agent": "وكيل",
|
||||
"chatMode.agentCap.env": "بيئة التشغيل",
|
||||
"chatMode.agentCap.files": "الوصول إلى الملفات",
|
||||
@@ -168,9 +164,6 @@
|
||||
"extendParams.urlContext.title": "استخراج محتوى رابط الويب",
|
||||
"followUpPlaceholder": "متابعة. @ لإسناد مهام لوكلاء آخرين.",
|
||||
"followUpPlaceholderHeterogeneous": "تابع.",
|
||||
"gatewayMode.beta": "تجريبي",
|
||||
"gatewayMode.cardTitle": "وضع بوابة الوكيل",
|
||||
"gatewayMode.desc": "قم بتشغيل الوكلاء في السحابة من خلال بوابة الوكلاء الخاصة بـ LobeHub. تستمر المهام في العمل حتى بعد إغلاق الصفحة.",
|
||||
"group.desc": "ادفع المهمة للأمام مع عدة وكلاء في مساحة مشتركة واحدة.",
|
||||
"group.memberTooltip": "يوجد {{count}} عضو في المجموعة",
|
||||
"group.orchestratorThinking": "المنسق يفكر...",
|
||||
@@ -252,7 +245,6 @@
|
||||
"inbox.title": "Lobe AI",
|
||||
"input.addAi": "إضافة رسالة من الذكاء الاصطناعي",
|
||||
"input.addUser": "إضافة رسالة من المستخدم",
|
||||
"input.agentModeUnsupportedModel": "النموذج الحالي لا يدعم استدعاء الأدوات الوكيلية. قم بالتبديل إلى نموذج يدعم الوكيل للحصول على أفضل تجربة.",
|
||||
"input.costEstimate.creditsPerMillionTokens": "{{credits}} ائتمان/مليون رموز",
|
||||
"input.costEstimate.hint": "التكلفة المقدرة: ~{{credits}} ائتمان",
|
||||
"input.costEstimate.inputLabel": "الإدخال",
|
||||
@@ -340,7 +332,6 @@
|
||||
"messages.modelCard.pricing.outputTokens": "المخرجات {{amount}} أرصدة · ${{amount}}/مليون",
|
||||
"messages.modelCard.pricing.writeCacheInputTokens": "كتابة إلى التخزين المؤقت {{amount}} أرصدة · ${{amount}}/مليون",
|
||||
"messages.tokenDetails.average": "متوسط السعر للوحدة",
|
||||
"messages.tokenDetails.cacheRate": "معدل التخزين المؤقت",
|
||||
"messages.tokenDetails.input": "المدخلات",
|
||||
"messages.tokenDetails.inputAudio": "مدخل صوتي",
|
||||
"messages.tokenDetails.inputCached": "مدخل مخزن مؤقتًا",
|
||||
@@ -882,7 +873,6 @@
|
||||
"toolAuth.authorize": "تفويض",
|
||||
"toolAuth.authorizing": "جارٍ التفويض...",
|
||||
"toolAuth.hint": "بدون التفويض أو الإعداد، قد لا تعمل المهارات. قد يؤدي ذلك إلى تقييد الوكيل أو حدوث أخطاء.",
|
||||
"toolAuth.remove": "إزالة",
|
||||
"toolAuth.signIn": "تسجيل الدخول",
|
||||
"toolAuth.title": "تفويض المهارات لهذا الوكيل",
|
||||
"topic.checkOpenNewTopic": "هل تريد بدء موضوع جديد؟",
|
||||
@@ -1103,26 +1093,13 @@
|
||||
"workingPanel.review.viewMode.unified": "التبديل إلى العرض الموحد",
|
||||
"workingPanel.review.wordWrap.disable": "تعطيل التفاف النص",
|
||||
"workingPanel.review.wordWrap.enable": "تمكين التفاف النص",
|
||||
"workingPanel.skills.actions.comingSoon": "قريبًا",
|
||||
"workingPanel.skills.actions.delete": "حذف",
|
||||
"workingPanel.skills.actions.rename": "إعادة تسمية",
|
||||
"workingPanel.skills.actions.view": "عرض",
|
||||
"workingPanel.skills.delete.agentConfirm": "إزالة المهارة “{{name}}” من هذا الوكيل؟ لا يمكن التراجع عن ذلك.",
|
||||
"workingPanel.skills.delete.error": "فشل في حذف المهارة",
|
||||
"workingPanel.skills.delete.success": "تم حذف المهارة",
|
||||
"workingPanel.skills.delete.title": "حذف المهارة؟",
|
||||
"workingPanel.skills.delete.userConfirm": "إلغاء تثبيت المهارة “{{name}}”؟ لا يمكن التراجع عن ذلك.",
|
||||
"workingPanel.skills.detail.title": "تفاصيل المهارة",
|
||||
"workingPanel.skills.empty": "لم يتم العثور على مهارات في هذا المشروع",
|
||||
"workingPanel.skills.rename.action": "إعادة تسمية",
|
||||
"workingPanel.skills.rename.error": "فشل في إعادة تسمية المهارة",
|
||||
"workingPanel.skills.rename.placeholder": "اسم المهارة",
|
||||
"workingPanel.skills.rename.title": "إعادة تسمية المهارة",
|
||||
"workingPanel.skills.section.agent": "مهارات الوكيل",
|
||||
"workingPanel.skills.section.project": "مهارات المشروع",
|
||||
"workingPanel.skills.section.user": "مهارات المستخدم",
|
||||
"workingPanel.skills.title": "المهارات",
|
||||
"workingPanel.space": "مسافة",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "أنت"
|
||||
"you": "أنت",
|
||||
"zenMode": "وضع التركيز"
|
||||
}
|
||||
|
||||
@@ -1,28 +1,4 @@
|
||||
{
|
||||
"fleet.addColumn": "إضافة عمود",
|
||||
"fleet.allShown": "تم عرض جميع المهام الجارية",
|
||||
"fleet.backToHome": "العودة إلى الصفحة الرئيسية",
|
||||
"fleet.closeColumn": "إغلاق العمود",
|
||||
"fleet.closeIdleColumns": "إغلاق الأعمدة الخاملة",
|
||||
"fleet.closeIdleColumnsCount": "إغلاق {{count}} من الأعمدة الخاملة",
|
||||
"fleet.collapseReply": "طي",
|
||||
"fleet.createTask": "إنشاء مهمة",
|
||||
"fleet.dragHint": "اسحب لإعادة الترتيب",
|
||||
"fleet.empty": "لا توجد مهام مفتوحة",
|
||||
"fleet.emptyDesc": "اختر مهمة جارية على اليسار، أو استخدم + لإضافة عمود.",
|
||||
"fleet.noRunningTasks": "لا توجد مهام جارية",
|
||||
"fleet.openInChat": "فتح في الدردشة",
|
||||
"fleet.pin": "تثبيت العمود",
|
||||
"fleet.reply": "رد",
|
||||
"fleet.rows.one": "صف واحد",
|
||||
"fleet.rows.two": "صفان",
|
||||
"fleet.runningBoard": "لوحة التشغيل",
|
||||
"fleet.status.idle": "خامل",
|
||||
"fleet.status.paused": "متوقف مؤقتًا",
|
||||
"fleet.status.running": "قيد التشغيل",
|
||||
"fleet.status.scheduled": "مجدول",
|
||||
"fleet.tooltip": "عرض جميع الوكلاء جنبًا إلى جنب",
|
||||
"fleet.unpin": "إلغاء تثبيت العمود",
|
||||
"gateway.description": "الوصف",
|
||||
"gateway.descriptionPlaceholder": "اختياري",
|
||||
"gateway.deviceName": "اسم الجهاز",
|
||||
@@ -38,7 +14,6 @@
|
||||
"navigation.discoverMcp": "اكتشف MCP",
|
||||
"navigation.discoverModels": "اكتشف النماذج",
|
||||
"navigation.discoverProviders": "اكتشف المزودين",
|
||||
"navigation.document": "مستند",
|
||||
"navigation.group": "مجموعة",
|
||||
"navigation.groupChat": "محادثة جماعية",
|
||||
"navigation.home": "الرئيسية",
|
||||
@@ -51,7 +26,6 @@
|
||||
"navigation.memoryIdentities": "الذاكرة - الهويات",
|
||||
"navigation.memoryPreferences": "الذاكرة - التفضيلات",
|
||||
"navigation.noPages": "لا توجد صفحات بعد",
|
||||
"navigation.observation": "وضع المراقبة",
|
||||
"navigation.onboarding": "البدء",
|
||||
"navigation.page": "صفحة",
|
||||
"navigation.pages": "الصفحات",
|
||||
|
||||
@@ -95,18 +95,8 @@
|
||||
"pageEditor.duplicateError": "فشل في تكرار الصفحة",
|
||||
"pageEditor.duplicateSuccess": "تم تكرار الصفحة بنجاح",
|
||||
"pageEditor.editMode.checking": "جارٍ التحقق من توفر التعديل…",
|
||||
"pageEditor.editMode.draftRestoreCancel": "تجاهل",
|
||||
"pageEditor.editMode.draftRestoreContent": "تم العثور على تغييرات محلية غير محفوظة من جلستك الأخيرة. هل تريد استعادتها؟",
|
||||
"pageEditor.editMode.draftRestoreOk": "استعادة",
|
||||
"pageEditor.editMode.draftRestoreTitle": "استعادة المسودة غير المحفوظة",
|
||||
"pageEditor.editMode.lockLostDescription": "لم تتم مزامنة التعديلات الأخيرة بعد. ستستأنف الحفظ بمجرد استعادة الاتصال.",
|
||||
"pageEditor.editMode.lockLostTitle": "تم فقدان قفل التعديل مؤقتًا",
|
||||
"pageEditor.editMode.lockUnstable": "إعادة الاتصال بقفل التعديل...",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} يقوم بتعديل هذا المستند",
|
||||
"pageEditor.editMode.lockedBySelf": "أنت تقوم بتعديل هذا المستند في علامة تبويب أخرى",
|
||||
"pageEditor.editMode.lockedBySelfDescription": "سيستأنف الحفظ بعد إغلاق الجلسة الأخرى أو انتهاء صلاحية قفلها (~30 ثانية).",
|
||||
"pageEditor.editMode.lockedBySomeone": "شخص آخر يقوم بتعديل هذا المستند",
|
||||
"pageEditor.editMode.lockedDescription": "الصفحة للقراءة فقط أثناء تعديلهم. لن يتم حفظ تغييراتك حتى ينتهوا.",
|
||||
"pageEditor.editedAt": "آخر تعديل في {{time}}",
|
||||
"pageEditor.editedBy": "آخر تعديل بواسطة {{name}}",
|
||||
"pageEditor.editorPlaceholder": "اضغط \"/\" للوصول إلى الذكاء الاصطناعي والأوامر",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"features.agentSelfIteration.title": "التكرار الذاتي للوكيل",
|
||||
"features.assistantMessageGroup.desc": "تجميع رسائل الوكيل ونتائج استدعاء الأدوات معًا للعرض",
|
||||
"features.assistantMessageGroup.title": "تجميع رسائل الوكيل",
|
||||
"features.fleet.desc": "عرض إدخال الأسطول في شريط العنوان — لوحة معلومات جنبًا إلى جنب لجميع المهام الجارية عبر وكلائك.",
|
||||
"features.fleet.title": "عرض الأسطول",
|
||||
"features.gatewayMode.desc": "تنفيذ مهام الوكيل على الخادم عبر بوابة WebSocket بدلًا من التشغيل محليًا، مما يتيح تنفيذًا أسرع ويقلل من استهلاك موارد العميل.",
|
||||
"features.gatewayMode.title": "تنفيذ الوكيل من جانب الخادم (البوابة)",
|
||||
"features.groupChat.desc": "تمكين تنسيق الدردشة الجماعية متعددة الوكلاء.",
|
||||
"features.groupChat.title": "دردشة جماعية (متعددة الوكلاء)",
|
||||
"features.imessage.desc": "ربط الوكلاء بـ iMessage من خلال جسر BlueBubbles المحلي لتطبيق LobeHub Desktop.",
|
||||
|
||||
@@ -890,7 +890,7 @@
|
||||
"skillStore.wantMore.feedback.title": "[طلب مهارة] لخّص المهارة التي تحتاجها في جملة واحدة",
|
||||
"skillStore.wantMore.reachedEnd": "لقد وصلت إلى النهاية. لم تجد ما تبحث عنه؟",
|
||||
"startConversation": "ابدأ المحادثة",
|
||||
"storage.actions.copyAgentGroups.button": "نسخ إلى...",
|
||||
"storage.actions.copyAgentGroups.button": "نسخ إلى",
|
||||
"storage.actions.copyAgentGroups.desc": "انسخ مجموعات الوكلاء وأعضائها إلى مساحة عمل أخرى أو حساب شخصي.",
|
||||
"storage.actions.copyAgentGroups.title": "نسخ مجموعات الوكلاء",
|
||||
"storage.actions.copyLobeAI.button": "نسخ إلى",
|
||||
@@ -1032,8 +1032,6 @@
|
||||
"tab.addCustomSkill": "إضافة مهارة مخصصة",
|
||||
"tab.advanced": "متقدم",
|
||||
"tab.advanced.appUpdates.title": "تحديثات التطبيق",
|
||||
"tab.advanced.gatewayMode.desc": "تشغيل مهام الوكيل المدعومة عبر بوابة السحابة افتراضيًا. يمكن للوكلاء الفرديين تجاوز هذا من قائمة الدردشة.",
|
||||
"tab.advanced.gatewayMode.title": "وضع البوابة",
|
||||
"tab.advanced.toolsAndDiagnostics.title": "الأدوات والتشخيصات",
|
||||
"tab.advanced.updateChannel.canary": "كناري",
|
||||
"tab.advanced.updateChannel.canaryDesc": "يتم تشغيله عند كل دمج PR، مع عدة إصدارات يومياً. الأكثر عدم استقراراً.",
|
||||
@@ -1171,6 +1169,7 @@
|
||||
"tools.builtins.uninstallConfirm.desc": "هل أنت متأكد أنك تريد إلغاء تثبيت {{name}}؟ سيتم إزالة هذه المهارة من الوكيل الحالي.",
|
||||
"tools.builtins.uninstallConfirm.title": "إلغاء تثبيت {{name}}",
|
||||
"tools.builtins.uninstalled": "تم إلغاء التثبيت",
|
||||
"tools.disabled": "النموذج الحالي لا يدعم استدعاء الوظائف ولا يمكنه استخدام المهارة",
|
||||
"tools.composio.addServer": "إضافة خادم",
|
||||
"tools.composio.authCompleted": "تم التحقق من الهوية",
|
||||
"tools.composio.authFailed": "فشل التحقق من الهوية",
|
||||
@@ -1187,10 +1186,6 @@
|
||||
"tools.composio.notEnabled": "خدمة Composio غير مفعلة",
|
||||
"tools.composio.oauthRequired": "يرجى إكمال التحقق من OAuth في النافذة الجديدة",
|
||||
"tools.composio.pendingAuth": "في انتظار التحقق",
|
||||
"tools.composio.reauthorize": "إعادة التفويض",
|
||||
"tools.composio.remove": "إزالة",
|
||||
"tools.composio.removeConfirm.desc": "{{name}} سيتم إزالته نهائيًا من الخدمات المتصلة بك. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"tools.composio.removeConfirm.title": "إزالة {{name}}؟",
|
||||
"tools.composio.serverCreated": "تم إنشاء الخادم بنجاح",
|
||||
"tools.composio.serverCreatedFailed": "فشل في إنشاء الخادم",
|
||||
"tools.composio.serverRemoved": "تمت إزالة الخادم",
|
||||
@@ -1243,7 +1238,6 @@
|
||||
"tools.composio.servers.zendesk.readme": "تكامل مع Zendesk لإدارة تذاكر الدعم وتفاعلات العملاء. أنشئ الطلبات، وحدثها، وتتبعها، وادخل إلى بيانات العملاء، وسهّل عمليات الدعم.",
|
||||
"tools.composio.tools": "الأدوات",
|
||||
"tools.composio.verifyAuth": "لقد أكملت التحقق",
|
||||
"tools.disabled": "النموذج الحالي لا يدعم استدعاء الوظائف ولا يمكنه استخدام المهارة",
|
||||
"tools.lobehubSkill.authorize": "تفويض",
|
||||
"tools.lobehubSkill.connect": "اتصال",
|
||||
"tools.lobehubSkill.connected": "متصل",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "المنتهية الصلاحية",
|
||||
"credits.packages.tabs.expiredCount": "منتهية الصلاحية ({{count}})",
|
||||
"credits.packages.title": "حزم الأرصدة الخاصة بي",
|
||||
"credits.topUp.bestValue.cta": "عرض السنوي النهائي",
|
||||
"credits.topUp.bestValue.savings": "وفر ${{savings}} على هذا الشراء",
|
||||
"credits.topUp.bestValue.title": "الخطة السنوية {{plan}} تتيح أقل معدل شحن: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "إلغاء",
|
||||
"credits.topUp.custom": "مخصص",
|
||||
"credits.topUp.freeFeeHint": "تشمل تعبئة الخطة المجانية رسوم خدمة بقيمة {{fee}} لكل مليون رصيد.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "معالجة المكافآت: سيتم توزيع الأرصدة خلال ساعة واحدة بعد أن يكمل المدعو الدفع ويجتاز التحقق",
|
||||
"referral.rules.title": "قواعد البرنامج",
|
||||
"referral.rules.validInvitation": "دعوة صالحة: يسجل المدعو باستخدام رمز الإحالة الخاص بك، وينفذ إجراءً صالحًا، ويكمل الدفع (اشتراك أو شحن أرصدة)",
|
||||
"referral.rules.validOperation": "معايير الإجراء الصالح: إرسال رسالة واحدة أو إنشاء صورة واحدة",
|
||||
"referral.stats.availableBalance": "الرصيد المتاح",
|
||||
"referral.stats.description": "عرض إحصائيات الإحالة الخاصة بك",
|
||||
"referral.stats.title": "نظرة عامة على الإحالة",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"actions.favorite": "المفضلة",
|
||||
"actions.import": "استيراد المحادثة",
|
||||
"actions.markCompleted": "وضع علامة كمكتمل",
|
||||
"actions.moveToAgent": "نقل إلى مساعد آخر",
|
||||
"actions.openInNewTab": "افتح في علامة تبويب جديدة",
|
||||
"actions.openInNewWindow": "فتح في نافذة جديدة",
|
||||
"actions.removeAll": "حذف جميع المواضيع",
|
||||
@@ -81,9 +80,6 @@
|
||||
"management.bulk.deleteConfirm": "أنت على وشك حذف {{count}} موضوعًا. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"management.bulk.deleteTitle": "حذف المواضيع؟",
|
||||
"management.bulk.favorite": "مفضلة",
|
||||
"management.bulk.move": "نقل إلى مساعد",
|
||||
"management.bulk.moveEmpty": "لا يوجد مساعدون آخرون",
|
||||
"management.bulk.moveSearchPlaceholder": "ابحث عن المساعدين…",
|
||||
"management.bulk.selectedCount_one": "{{count}} محدد",
|
||||
"management.bulk.selectedCount_other": "{{count}} محددة",
|
||||
"management.card.noPreview": "لا توجد معاينة متاحة",
|
||||
@@ -122,17 +118,6 @@
|
||||
"management.group.noProject": "لا يوجد مشروع",
|
||||
"management.group.none": "لا شيء",
|
||||
"management.loadingMore": "جارٍ تحميل المزيد من المواضيع…",
|
||||
"management.moveModal.back": "رجوع",
|
||||
"management.moveModal.confirmContent_one": "هل تريد نقل {{count}} موضوع إلى “{{title}}”؟",
|
||||
"management.moveModal.confirmContent_other": "هل تريد نقل {{count}} مواضيع إلى “{{title}}”؟",
|
||||
"management.moveModal.confirmOk": "نقل",
|
||||
"management.moveModal.doneOk": "تم",
|
||||
"management.moveModal.done_one": "تم نقل {{count}} موضوع",
|
||||
"management.moveModal.done_other": "تم نقل {{count}} مواضيع",
|
||||
"management.moveModal.error": "فشل النقل، يرجى المحاولة مرة أخرى",
|
||||
"management.moveModal.goToTarget": "انتقل إلى “{{title}}”",
|
||||
"management.moveModal.moving": "جارٍ النقل…",
|
||||
"management.moveModal.title": "نقل المواضيع",
|
||||
"management.searchPlaceholder": "ابحث في مواضيع هذا الوكيل…",
|
||||
"management.sidebarEntry": "المواضيع",
|
||||
"management.sort.createdAt": "وقت الإنشاء",
|
||||
@@ -143,7 +128,6 @@
|
||||
"management.status.archived": "مؤرشف",
|
||||
"management.status.completed": "مكتمل",
|
||||
"management.status.failed": "فشل",
|
||||
"management.status.idle": "خامل",
|
||||
"management.status.paused": "متوقف مؤقتًا",
|
||||
"management.status.running": "قيد التشغيل",
|
||||
"management.status.waitingForHuman": "في انتظار الإدخال",
|
||||
@@ -155,8 +139,6 @@
|
||||
"projectStatus.failed_other": "{{count}} مواضيع فشلت",
|
||||
"projectStatus.loading_one": "{{count}} موضوع قيد التحميل",
|
||||
"projectStatus.loading_other": "{{count}} مواضيع قيد التحميل",
|
||||
"projectStatus.unread_one": "{{count}} موضوع يحتوي على رد غير مقروء",
|
||||
"projectStatus.unread_other": "{{count}} مواضيع تحتوي على ردود غير مقروءة",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} موضوع ينتظر الإدخال",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} مواضيع تنتظر الإدخال",
|
||||
"renameModal.description": "يُفضَّل أن يكون قصيرًا وسهل التعرّف.",
|
||||
|
||||
+2
-25
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно.\n\nИскате да се адаптирам по-добре към вашия работен процес? Отидете в [Настройки на Агента]({{url}}) и попълнете Профила на Агента (можете да го редактирате по всяко време).",
|
||||
"agentDefaultMessageWithSystemRole": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно — вие контролирате.",
|
||||
"agentDefaultMessageWithoutEdit": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно — вие контролирате.",
|
||||
"agentDocument.backToChat": "Обратно към чата",
|
||||
"agentDocument.linkCopied": "Връзката е копирана",
|
||||
"agentDocument.openAsPage": "Отвори като цяла страница",
|
||||
"agentProfile.files_one": "{{count}} файл",
|
||||
"agentProfile.files_other": "{{count}} файла",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} база знания",
|
||||
@@ -47,7 +44,6 @@
|
||||
"builtinCopilot": "Вграден Копилот",
|
||||
"chatList.expandMessage": "Разгъни съобщението",
|
||||
"chatList.longMessageDetail": "Прегледай подробности",
|
||||
"chatList.refreshing": "Извличане на най-новите съобщения...",
|
||||
"chatMode.agent": "Агент",
|
||||
"chatMode.agentCap.env": "Работна среда",
|
||||
"chatMode.agentCap.files": "Достъп до файлове",
|
||||
@@ -168,9 +164,6 @@
|
||||
"extendParams.urlContext.title": "Извличане на съдържание от уеб връзки",
|
||||
"followUpPlaceholder": "Последващо действие. Използвайте @, за да възлагате задачи на други агенти.",
|
||||
"followUpPlaceholderHeterogeneous": "Последващ въпрос.",
|
||||
"gatewayMode.beta": "Бета",
|
||||
"gatewayMode.cardTitle": "Режим на шлюза за агенти",
|
||||
"gatewayMode.desc": "Стартирайте агенти в облака чрез шлюза за агенти на LobeHub. Задачите продължават да се изпълняват дори след като затворите страницата.",
|
||||
"group.desc": "Придвижете задача напред с няколко Агента в едно споделено пространство.",
|
||||
"group.memberTooltip": "Групата има {{count}} член(а)",
|
||||
"group.orchestratorThinking": "Оркестраторът мисли...",
|
||||
@@ -252,7 +245,6 @@
|
||||
"inbox.title": "Lobe AI",
|
||||
"input.addAi": "Добави AI съобщение",
|
||||
"input.addUser": "Добави потребителско съобщение",
|
||||
"input.agentModeUnsupportedModel": "Текущият модел не поддържа агентски инструменти. Превключете към модел с агентски възможности за най-добро изживяване.",
|
||||
"input.costEstimate.creditsPerMillionTokens": "{{credits}} кредита/М токена",
|
||||
"input.costEstimate.hint": "Оценена цена: ~{{credits}} кредита",
|
||||
"input.costEstimate.inputLabel": "Вход",
|
||||
@@ -340,7 +332,6 @@
|
||||
"messages.modelCard.pricing.outputTokens": "Изход {{amount}} кредита · ${{amount}}/M",
|
||||
"messages.modelCard.pricing.writeCacheInputTokens": "Кеш запис {{amount}} кредита · ${{amount}}/M",
|
||||
"messages.tokenDetails.average": "Средна единична цена",
|
||||
"messages.tokenDetails.cacheRate": "Скорост на кеширане",
|
||||
"messages.tokenDetails.input": "Вход",
|
||||
"messages.tokenDetails.inputAudio": "Аудио вход",
|
||||
"messages.tokenDetails.inputCached": "Кеширан вход",
|
||||
@@ -882,7 +873,6 @@
|
||||
"toolAuth.authorize": "Упълномощи",
|
||||
"toolAuth.authorizing": "Упълномощаване...",
|
||||
"toolAuth.hint": "Без упълномощаване или конфигурация, уменията може да не работят. Това може да ограничи агента или да доведе до грешки.",
|
||||
"toolAuth.remove": "Премахни",
|
||||
"toolAuth.signIn": "Вход",
|
||||
"toolAuth.title": "Упълномощи уменията за този агент",
|
||||
"topic.checkOpenNewTopic": "Да започнем нова тема?",
|
||||
@@ -1103,26 +1093,13 @@
|
||||
"workingPanel.review.viewMode.unified": "Превключете към обединен изглед",
|
||||
"workingPanel.review.wordWrap.disable": "Деактивирай пренасяне на думи",
|
||||
"workingPanel.review.wordWrap.enable": "Активирай пренасяне на думи",
|
||||
"workingPanel.skills.actions.comingSoon": "Очаквайте скоро",
|
||||
"workingPanel.skills.actions.delete": "Изтриване",
|
||||
"workingPanel.skills.actions.rename": "Преименуване",
|
||||
"workingPanel.skills.actions.view": "Преглед",
|
||||
"workingPanel.skills.delete.agentConfirm": "Да премахна ли умението „{{name}}“ от този агент? Това действие не може да бъде отменено.",
|
||||
"workingPanel.skills.delete.error": "Неуспешно изтриване на умение",
|
||||
"workingPanel.skills.delete.success": "Умението е изтрито",
|
||||
"workingPanel.skills.delete.title": "Изтриване на умение?",
|
||||
"workingPanel.skills.delete.userConfirm": "Да деинсталирам ли умението „{{name}}“? Това действие не може да бъде отменено.",
|
||||
"workingPanel.skills.detail.title": "Детайли за умението",
|
||||
"workingPanel.skills.empty": "Няма намерени умения в този проект",
|
||||
"workingPanel.skills.rename.action": "Преименуване",
|
||||
"workingPanel.skills.rename.error": "Неуспешно преименуване на умение",
|
||||
"workingPanel.skills.rename.placeholder": "Име на умението",
|
||||
"workingPanel.skills.rename.title": "Преименуване на умение",
|
||||
"workingPanel.skills.section.agent": "Умения на агента",
|
||||
"workingPanel.skills.section.project": "Умения на проекта",
|
||||
"workingPanel.skills.section.user": "Умения на потребителя",
|
||||
"workingPanel.skills.title": "Умения",
|
||||
"workingPanel.space": "Пространство",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "Вие"
|
||||
"you": "Вие",
|
||||
"zenMode": "Режим Зен"
|
||||
}
|
||||
|
||||
@@ -1,28 +1,4 @@
|
||||
{
|
||||
"fleet.addColumn": "Добавяне на колона",
|
||||
"fleet.allShown": "Всички текущи задачи са показани",
|
||||
"fleet.backToHome": "Обратно към началната страница",
|
||||
"fleet.closeColumn": "Затваряне на колона",
|
||||
"fleet.closeIdleColumns": "Затвори неактивни колони",
|
||||
"fleet.closeIdleColumnsCount": "Затвори {{count}} неактивни колони",
|
||||
"fleet.collapseReply": "Свий",
|
||||
"fleet.createTask": "Създаване на задача",
|
||||
"fleet.dragHint": "Плъзнете, за да пренаредите",
|
||||
"fleet.empty": "Няма отворени задачи",
|
||||
"fleet.emptyDesc": "Изберете текуща задача отляво или използвайте +, за да добавите колона.",
|
||||
"fleet.noRunningTasks": "Няма текущи задачи",
|
||||
"fleet.openInChat": "Отваряне в чата",
|
||||
"fleet.pin": "Закачи колона",
|
||||
"fleet.reply": "Отговор",
|
||||
"fleet.rows.one": "Един ред",
|
||||
"fleet.rows.two": "Два реда",
|
||||
"fleet.runningBoard": "Текуща дъска",
|
||||
"fleet.status.idle": "Неактивен",
|
||||
"fleet.status.paused": "Пауза",
|
||||
"fleet.status.running": "В процес на изпълнение",
|
||||
"fleet.status.scheduled": "Планирано",
|
||||
"fleet.tooltip": "Преглед на всички агенти един до друг",
|
||||
"fleet.unpin": "Откачи колона",
|
||||
"gateway.description": "Описание",
|
||||
"gateway.descriptionPlaceholder": "По избор",
|
||||
"gateway.deviceName": "Име на устройството",
|
||||
@@ -38,7 +14,6 @@
|
||||
"navigation.discoverMcp": "Откриване на MCP",
|
||||
"navigation.discoverModels": "Откриване на Модели",
|
||||
"navigation.discoverProviders": "Откриване на Доставчици",
|
||||
"navigation.document": "Документ",
|
||||
"navigation.group": "Група",
|
||||
"navigation.groupChat": "Групов Чат",
|
||||
"navigation.home": "Начало",
|
||||
@@ -51,7 +26,6 @@
|
||||
"navigation.memoryIdentities": "Памят - Идентичности",
|
||||
"navigation.memoryPreferences": "Памят - Предпочитания",
|
||||
"navigation.noPages": "Все още няма страници",
|
||||
"navigation.observation": "Режим на наблюдение",
|
||||
"navigation.onboarding": "Въведение",
|
||||
"navigation.page": "Страница",
|
||||
"navigation.pages": "Страници",
|
||||
|
||||
@@ -95,18 +95,8 @@
|
||||
"pageEditor.duplicateError": "Неуспешно дублиране на страницата",
|
||||
"pageEditor.duplicateSuccess": "Страницата е дублирана успешно",
|
||||
"pageEditor.editMode.checking": "Проверка на наличността за редактиране…",
|
||||
"pageEditor.editMode.draftRestoreCancel": "Отхвърли",
|
||||
"pageEditor.editMode.draftRestoreContent": "Намерени са незапазени локални промени от последната ви сесия. Да ги възстановя ли?",
|
||||
"pageEditor.editMode.draftRestoreOk": "Възстанови",
|
||||
"pageEditor.editMode.draftRestoreTitle": "Възстановяване на незапазен чернова",
|
||||
"pageEditor.editMode.lockLostDescription": "Последните редакции все още не са синхронизирани. Те ще продължат да се запазват, когато връзката се възстанови.",
|
||||
"pageEditor.editMode.lockLostTitle": "Временно загубено заключване за редакция",
|
||||
"pageEditor.editMode.lockUnstable": "Възстановяване на заключването за редакция...",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} редактира този документ",
|
||||
"pageEditor.editMode.lockedBySelf": "Редактирате този документ в друг раздел",
|
||||
"pageEditor.editMode.lockedBySelfDescription": "Запазването ще продължи, след като другата сесия се затвори или нейното заключване изтече (~30 секунди).",
|
||||
"pageEditor.editMode.lockedBySomeone": "Някой друг редактира този документ",
|
||||
"pageEditor.editMode.lockedDescription": "Страницата е само за четене, докато те редактират. Вашите промени няма да бъдат запазени, докато не приключат.",
|
||||
"pageEditor.editedAt": "Последна редакция на {{time}}",
|
||||
"pageEditor.editedBy": "Последна редакция от {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Натиснете \"/\" за ИИ и команди",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Само-итерация на агента",
|
||||
"features.assistantMessageGroup.desc": "Групиране на съобщенията от агента и резултатите от извикванията на инструменти заедно за показване",
|
||||
"features.assistantMessageGroup.title": "Групиране на съобщения от агент",
|
||||
"features.fleet.desc": "Показване на записа Fleet в заглавната лента — табло за управление, което показва всички текущи задачи на вашите агенти едновременно.",
|
||||
"features.fleet.title": "Изглед Fleet",
|
||||
"features.gatewayMode.desc": "Изпълнявайте задачите на агента на сървъра чрез Gateway WebSocket вместо локално. Осигурява по-бързо изпълнение и намалява използването на ресурси от клиента.",
|
||||
"features.gatewayMode.title": "Изпълнение на агента от страна на сървъра (Gateway)",
|
||||
"features.groupChat.desc": "Активиране на координация в групов чат с множество агенти.",
|
||||
"features.groupChat.title": "Групов чат (многоагентен)",
|
||||
"features.imessage.desc": "Свързване на агентите с iMessage чрез локалния LobeHub Desktop BlueBubbles мост.",
|
||||
|
||||
@@ -890,7 +890,7 @@
|
||||
"skillStore.wantMore.feedback.title": "[Заявка за умение] Обобщете умението, от което се нуждаете, в едно изречение",
|
||||
"skillStore.wantMore.reachedEnd": "Стигнахте до края. Не намирате това, което търсите?",
|
||||
"startConversation": "Започни разговор",
|
||||
"storage.actions.copyAgentGroups.button": "Копирай в...",
|
||||
"storage.actions.copyAgentGroups.button": "Копиране в",
|
||||
"storage.actions.copyAgentGroups.desc": "Копирайте групи агенти и техните членове в друго работно пространство или личен акаунт.",
|
||||
"storage.actions.copyAgentGroups.title": "Копиране на групи агенти",
|
||||
"storage.actions.copyLobeAI.button": "Копиране в",
|
||||
@@ -1032,8 +1032,6 @@
|
||||
"tab.addCustomSkill": "Добавяне на персонализирано умение",
|
||||
"tab.advanced": "Разширени",
|
||||
"tab.advanced.appUpdates.title": "Актуализации на приложението",
|
||||
"tab.advanced.gatewayMode.desc": "Изпълнявайте поддържаните задачи на агентите през облачния Gateway по подразбиране. Индивидуалните агенти могат да променят това от менюто за чат.",
|
||||
"tab.advanced.gatewayMode.title": "Режим Gateway",
|
||||
"tab.advanced.toolsAndDiagnostics.title": "Инструменти и диагностика",
|
||||
"tab.advanced.updateChannel.canary": "Канарче",
|
||||
"tab.advanced.updateChannel.canaryDesc": "Задейства се при всяко сливане на PR, множество компилации на ден. Най-нестабилната версия.",
|
||||
@@ -1171,6 +1169,7 @@
|
||||
"tools.builtins.uninstallConfirm.desc": "Сигурни ли сте, че искате да деинсталирате {{name}}? Това умение ще бъде премахнато от текущия агент.",
|
||||
"tools.builtins.uninstallConfirm.title": "Деинсталиране на {{name}}",
|
||||
"tools.builtins.uninstalled": "Деинсталирано",
|
||||
"tools.disabled": "Текущият модел не поддържа извикване на функции и не може да използва умението",
|
||||
"tools.composio.addServer": "Добави сървър",
|
||||
"tools.composio.authCompleted": "Удостоверяването е завършено",
|
||||
"tools.composio.authFailed": "Удостоверяването не бе успешно",
|
||||
@@ -1187,10 +1186,6 @@
|
||||
"tools.composio.notEnabled": "Услугата Composio не е активирана",
|
||||
"tools.composio.oauthRequired": "Моля, завършете OAuth удостоверяването в нов прозорец",
|
||||
"tools.composio.pendingAuth": "Изчаква удостоверяване",
|
||||
"tools.composio.reauthorize": "Повторно упълномощаване",
|
||||
"tools.composio.remove": "Премахване",
|
||||
"tools.composio.removeConfirm.desc": "{{name}} ще бъде окончателно премахнат от свързаните ви услуги. Това действие не може да бъде отменено.",
|
||||
"tools.composio.removeConfirm.title": "Премахване на {{name}}?",
|
||||
"tools.composio.serverCreated": "Сървърът е създаден успешно",
|
||||
"tools.composio.serverCreatedFailed": "Неуспешно създаване на сървър",
|
||||
"tools.composio.serverRemoved": "Сървърът е премахнат",
|
||||
@@ -1243,7 +1238,6 @@
|
||||
"tools.composio.servers.zendesk.readme": "Интегрирайте се със Zendesk за управление на клиентски запитвания и поддръжка. Създавайте, актуализирайте и проследявайте тикети, достъпвайте клиентски данни и оптимизирайте обслужването си.",
|
||||
"tools.composio.tools": "инструменти",
|
||||
"tools.composio.verifyAuth": "Завърших удостоверяването",
|
||||
"tools.disabled": "Текущият модел не поддържа извикване на функции и не може да използва умението",
|
||||
"tools.lobehubSkill.authorize": "Упълномощи",
|
||||
"tools.lobehubSkill.connect": "Свържи",
|
||||
"tools.lobehubSkill.connected": "Свързано",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "Изтекли",
|
||||
"credits.packages.tabs.expiredCount": "Изтекли ({{count}})",
|
||||
"credits.packages.title": "Моите пакети с кредити",
|
||||
"credits.topUp.bestValue.cta": "Вижте Ultimate годишен",
|
||||
"credits.topUp.bestValue.savings": "Спестете ${{savings}} при тази покупка",
|
||||
"credits.topUp.bestValue.title": "{{plan}} годишен отключва най-ниската ставка за зареждане: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "Отказ",
|
||||
"credits.topUp.custom": "По избор",
|
||||
"credits.topUp.freeFeeHint": "Зарежданията на безплатния план включват такса за услуга от {{fee}} на 1M кредити.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "Обработка на наградите: Кредитите ще бъдат разпределени в рамките на 1 час след като поканеният завърши плащане и премине проверка.",
|
||||
"referral.rules.title": "Правила на програмата",
|
||||
"referral.rules.validInvitation": "Валидна покана: Поканеният се регистрира с вашия код за препоръка, извършва едно валидно действие и завършва плащане (абонамент или зареждане на кредити).",
|
||||
"referral.rules.validOperation": "Критерии за валидно действие: Изпращане на съобщение в Chat страницата или генериране на изображение",
|
||||
"referral.stats.availableBalance": "Налично салдо",
|
||||
"referral.stats.description": "Вижте статистиката на вашите покани",
|
||||
"referral.stats.title": "Обзор на поканите",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"actions.favorite": "Любимо",
|
||||
"actions.import": "Импортирай разговор",
|
||||
"actions.markCompleted": "Отбележи като завършена",
|
||||
"actions.moveToAgent": "Премести към друг асистент",
|
||||
"actions.openInNewTab": "Отвори в нов раздел",
|
||||
"actions.openInNewWindow": "Отвори в нов прозорец",
|
||||
"actions.removeAll": "Изтрий всички теми",
|
||||
@@ -81,9 +80,6 @@
|
||||
"management.bulk.deleteConfirm": "Ще изтриете {{count}} теми. Това действие не може да бъде отменено.",
|
||||
"management.bulk.deleteTitle": "Изтриване на теми?",
|
||||
"management.bulk.favorite": "Любими",
|
||||
"management.bulk.move": "Премести към асистент",
|
||||
"management.bulk.moveEmpty": "Няма други асистенти",
|
||||
"management.bulk.moveSearchPlaceholder": "Търсене на асистенти…",
|
||||
"management.bulk.selectedCount_one": "{{count}} избрана",
|
||||
"management.bulk.selectedCount_other": "{{count}} избрани",
|
||||
"management.card.noPreview": "Няма наличен преглед",
|
||||
@@ -122,17 +118,6 @@
|
||||
"management.group.noProject": "Без проект",
|
||||
"management.group.none": "Няма",
|
||||
"management.loadingMore": "Зареждане на още теми...",
|
||||
"management.moveModal.back": "Назад",
|
||||
"management.moveModal.confirmContent_one": "Да преместя {{count}} тема в „{{title}}“?",
|
||||
"management.moveModal.confirmContent_other": "Да преместя {{count}} теми в „{{title}}“?",
|
||||
"management.moveModal.confirmOk": "Премести",
|
||||
"management.moveModal.doneOk": "Готово",
|
||||
"management.moveModal.done_one": "{{count}} тема преместена",
|
||||
"management.moveModal.done_other": "{{count}} теми преместени",
|
||||
"management.moveModal.error": "Преместването не бе успешно, моля опитайте отново",
|
||||
"management.moveModal.goToTarget": "Отиди на „{{title}}“",
|
||||
"management.moveModal.moving": "Преместване…",
|
||||
"management.moveModal.title": "Преместване на теми",
|
||||
"management.searchPlaceholder": "Търсене на теми на този агент...",
|
||||
"management.sidebarEntry": "Теми",
|
||||
"management.sort.createdAt": "Време на създаване",
|
||||
@@ -143,7 +128,6 @@
|
||||
"management.status.archived": "Архивирани",
|
||||
"management.status.completed": "Завършени",
|
||||
"management.status.failed": "Неуспешни",
|
||||
"management.status.idle": "Неактивен",
|
||||
"management.status.paused": "Паузирани",
|
||||
"management.status.running": "В процес",
|
||||
"management.status.waitingForHuman": "Очаква въвеждане",
|
||||
@@ -155,8 +139,6 @@
|
||||
"projectStatus.failed_other": "{{count}} неуспешни теми",
|
||||
"projectStatus.loading_one": "{{count}} зареждаща се тема",
|
||||
"projectStatus.loading_other": "{{count}} зареждащи се теми",
|
||||
"projectStatus.unread_one": "{{count}} тема с непрочетен отговор",
|
||||
"projectStatus.unread_other": "{{count}} теми с непрочетени отговори",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} тема, очакваща въвеждане",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} теми, очакващи въвеждане",
|
||||
"renameModal.description": "Поддържайте го кратко и лесно за разпознаване.",
|
||||
|
||||
+2
-25
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "Hallo, ich bin **{{name}}**. Ein Satz genügt.\n\nMöchten Sie, dass ich besser zu Ihrem Arbeitsablauf passe? Gehen Sie zu [Agenteneinstellungen]({{url}}) und füllen Sie das Agentenprofil aus (Sie können es jederzeit bearbeiten).",
|
||||
"agentDefaultMessageWithSystemRole": "Hallo, ich bin **{{name}}**. Ein Satz genügt – Sie haben die Kontrolle.",
|
||||
"agentDefaultMessageWithoutEdit": "Hallo, ich bin **{{name}}**. Ein Satz genügt – Sie haben die Kontrolle.",
|
||||
"agentDocument.backToChat": "Zurück zum Chat",
|
||||
"agentDocument.linkCopied": "Link kopiert",
|
||||
"agentDocument.openAsPage": "Als vollständige Seite öffnen",
|
||||
"agentProfile.files_one": "{{count}} Datei",
|
||||
"agentProfile.files_other": "{{count}} Dateien",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} Wissensbasis",
|
||||
@@ -47,7 +44,6 @@
|
||||
"builtinCopilot": "Integrierter Copilot",
|
||||
"chatList.expandMessage": "Nachricht erweitern",
|
||||
"chatList.longMessageDetail": "Details anzeigen",
|
||||
"chatList.refreshing": "Neueste Nachrichten werden abgerufen...",
|
||||
"chatMode.agent": "Agent",
|
||||
"chatMode.agentCap.env": "Laufzeitumgebung",
|
||||
"chatMode.agentCap.files": "Dateizugriff",
|
||||
@@ -168,9 +164,6 @@
|
||||
"extendParams.urlContext.title": "Webseiteninhalte extrahieren",
|
||||
"followUpPlaceholder": "Folgen Sie nach. @, um Aufgaben anderen Agenten zuzuweisen.",
|
||||
"followUpPlaceholderHeterogeneous": "Weiter ausführen.",
|
||||
"gatewayMode.beta": "Beta",
|
||||
"gatewayMode.cardTitle": "Agent-Gateway-Modus",
|
||||
"gatewayMode.desc": "Führen Sie Agenten in der Cloud über LobeHubs Agent-Gateway aus. Aufgaben laufen weiter, auch nachdem Sie die Seite geschlossen haben.",
|
||||
"group.desc": "Bringen Sie eine Aufgabe mit mehreren Agenten in einem gemeinsamen Raum voran.",
|
||||
"group.memberTooltip": "Es gibt {{count}} Mitglieder in der Gruppe",
|
||||
"group.orchestratorThinking": "Orchestrator denkt nach...",
|
||||
@@ -252,7 +245,6 @@
|
||||
"inbox.title": "Lobe AI",
|
||||
"input.addAi": "KI-Nachricht hinzufügen",
|
||||
"input.addUser": "Benutzernachricht hinzufügen",
|
||||
"input.agentModeUnsupportedModel": "Das aktuelle Modell unterstützt keine agentischen Werkzeugaufrufe. Wechseln Sie zu einem Modell mit Agentenfähigkeit für die beste Erfahrung.",
|
||||
"input.costEstimate.creditsPerMillionTokens": "{{credits}} Credits/M Tokens",
|
||||
"input.costEstimate.hint": "Geschätzte Kosten: ~{{credits}} Credits",
|
||||
"input.costEstimate.inputLabel": "Eingabe",
|
||||
@@ -340,7 +332,6 @@
|
||||
"messages.modelCard.pricing.outputTokens": "Output {{amount}} Credits · ${{amount}}/M",
|
||||
"messages.modelCard.pricing.writeCacheInputTokens": "Cache-Schreiben {{amount}} Credits · ${{amount}}/M",
|
||||
"messages.tokenDetails.average": "Durchschnittspreis pro Einheit",
|
||||
"messages.tokenDetails.cacheRate": "Cache-Rate",
|
||||
"messages.tokenDetails.input": "Input",
|
||||
"messages.tokenDetails.inputAudio": "Audio-Input",
|
||||
"messages.tokenDetails.inputCached": "Gecachter Input",
|
||||
@@ -882,7 +873,6 @@
|
||||
"toolAuth.authorize": "Autorisieren",
|
||||
"toolAuth.authorizing": "Autorisierung läuft...",
|
||||
"toolAuth.hint": "Ohne Autorisierung oder Konfiguration funktionieren Skills möglicherweise nicht. Dies kann den Agenten einschränken oder zu Fehlern führen.",
|
||||
"toolAuth.remove": "Entfernen",
|
||||
"toolAuth.signIn": "Anmelden",
|
||||
"toolAuth.title": "Skills für diesen Agenten autorisieren",
|
||||
"topic.checkOpenNewTopic": "Neues Thema starten?",
|
||||
@@ -1103,26 +1093,13 @@
|
||||
"workingPanel.review.viewMode.unified": "Zur einheitlichen Ansicht wechseln",
|
||||
"workingPanel.review.wordWrap.disable": "Zeilenumbruch deaktivieren",
|
||||
"workingPanel.review.wordWrap.enable": "Zeilenumbruch aktivieren",
|
||||
"workingPanel.skills.actions.comingSoon": "Demnächst verfügbar",
|
||||
"workingPanel.skills.actions.delete": "Löschen",
|
||||
"workingPanel.skills.actions.rename": "Umbenennen",
|
||||
"workingPanel.skills.actions.view": "Ansehen",
|
||||
"workingPanel.skills.delete.agentConfirm": "Die Fähigkeit „{{name}}“ von diesem Agenten entfernen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"workingPanel.skills.delete.error": "Fähigkeit konnte nicht gelöscht werden",
|
||||
"workingPanel.skills.delete.success": "Fähigkeit gelöscht",
|
||||
"workingPanel.skills.delete.title": "Fähigkeit löschen?",
|
||||
"workingPanel.skills.delete.userConfirm": "Die Fähigkeit „{{name}}“ deinstallieren? Dies kann nicht rückgängig gemacht werden.",
|
||||
"workingPanel.skills.detail.title": "Fähigkeitsdetails",
|
||||
"workingPanel.skills.empty": "Keine Fähigkeiten in diesem Projekt gefunden",
|
||||
"workingPanel.skills.rename.action": "Umbenennen",
|
||||
"workingPanel.skills.rename.error": "Fähigkeit konnte nicht umbenannt werden",
|
||||
"workingPanel.skills.rename.placeholder": "Fähigkeitsname",
|
||||
"workingPanel.skills.rename.title": "Fähigkeit umbenennen",
|
||||
"workingPanel.skills.section.agent": "Agentenfähigkeiten",
|
||||
"workingPanel.skills.section.project": "Projektfähigkeiten",
|
||||
"workingPanel.skills.section.user": "Benutzerfähigkeiten",
|
||||
"workingPanel.skills.title": "Fähigkeiten",
|
||||
"workingPanel.space": "Leerzeichen",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "Du"
|
||||
"you": "Du",
|
||||
"zenMode": "Zen-Modus"
|
||||
}
|
||||
|
||||
@@ -1,28 +1,4 @@
|
||||
{
|
||||
"fleet.addColumn": "Spalte hinzufügen",
|
||||
"fleet.allShown": "Alle laufenden Aufgaben werden angezeigt",
|
||||
"fleet.backToHome": "Zurück zur Startseite",
|
||||
"fleet.closeColumn": "Spalte schließen",
|
||||
"fleet.closeIdleColumns": "Leere Spalten schließen",
|
||||
"fleet.closeIdleColumnsCount": "{{count}} leere Spalten schließen",
|
||||
"fleet.collapseReply": "Einklappen",
|
||||
"fleet.createTask": "Aufgabe erstellen",
|
||||
"fleet.dragHint": "Ziehen, um neu anzuordnen",
|
||||
"fleet.empty": "Keine offenen Aufgaben",
|
||||
"fleet.emptyDesc": "Wählen Sie eine laufende Aufgabe links aus oder verwenden Sie +, um eine Spalte hinzuzufügen.",
|
||||
"fleet.noRunningTasks": "Keine laufenden Aufgaben",
|
||||
"fleet.openInChat": "Im Chat öffnen",
|
||||
"fleet.pin": "Spalte anheften",
|
||||
"fleet.reply": "Antworten",
|
||||
"fleet.rows.one": "Einzelne Zeile",
|
||||
"fleet.rows.two": "Zwei Zeilen",
|
||||
"fleet.runningBoard": "Laufendes Board",
|
||||
"fleet.status.idle": "Leerlauf",
|
||||
"fleet.status.paused": "Pausiert",
|
||||
"fleet.status.running": "Läuft",
|
||||
"fleet.status.scheduled": "Geplant",
|
||||
"fleet.tooltip": "Alle Agenten nebeneinander anzeigen",
|
||||
"fleet.unpin": "Spalte lösen",
|
||||
"gateway.description": "Beschreibung",
|
||||
"gateway.descriptionPlaceholder": "Optional",
|
||||
"gateway.deviceName": "Gerätename",
|
||||
@@ -38,7 +14,6 @@
|
||||
"navigation.discoverMcp": "MCP entdecken",
|
||||
"navigation.discoverModels": "Modelle entdecken",
|
||||
"navigation.discoverProviders": "Anbieter entdecken",
|
||||
"navigation.document": "Dokument",
|
||||
"navigation.group": "Gruppe",
|
||||
"navigation.groupChat": "Gruppen-Chat",
|
||||
"navigation.home": "Startseite",
|
||||
@@ -51,7 +26,6 @@
|
||||
"navigation.memoryIdentities": "Speicher - Identitäten",
|
||||
"navigation.memoryPreferences": "Speicher - Präferenzen",
|
||||
"navigation.noPages": "Noch keine Seiten",
|
||||
"navigation.observation": "Beobachtungsmodus",
|
||||
"navigation.onboarding": "Einführung",
|
||||
"navigation.page": "Seite",
|
||||
"navigation.pages": "Seiten",
|
||||
|
||||
@@ -95,18 +95,8 @@
|
||||
"pageEditor.duplicateError": "Fehler beim Duplizieren der Seite",
|
||||
"pageEditor.duplicateSuccess": "Seite erfolgreich dupliziert",
|
||||
"pageEditor.editMode.checking": "Bearbeitsverfügbarkeit wird überprüft…",
|
||||
"pageEditor.editMode.draftRestoreCancel": "Verwerfen",
|
||||
"pageEditor.editMode.draftRestoreContent": "Es wurden nicht gespeicherte lokale Änderungen aus Ihrer letzten Sitzung gefunden. Möchten Sie diese wiederherstellen?",
|
||||
"pageEditor.editMode.draftRestoreOk": "Wiederherstellen",
|
||||
"pageEditor.editMode.draftRestoreTitle": "Nicht gespeicherten Entwurf wiederherstellen",
|
||||
"pageEditor.editMode.lockLostDescription": "Die letzten Änderungen wurden noch nicht synchronisiert. Die Speicherung wird fortgesetzt, sobald die Verbindung wiederhergestellt ist.",
|
||||
"pageEditor.editMode.lockLostTitle": "Bearbeitungssperre vorübergehend verloren",
|
||||
"pageEditor.editMode.lockUnstable": "Bearbeitungssperre wird wiederhergestellt…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} bearbeitet dieses Dokument",
|
||||
"pageEditor.editMode.lockedBySelf": "Sie bearbeiten dieses Dokument in einem anderen Tab",
|
||||
"pageEditor.editMode.lockedBySelfDescription": "Die Speicherung wird fortgesetzt, nachdem die andere Sitzung geschlossen wurde oder deren Sperre abläuft (~30 Sekunden).",
|
||||
"pageEditor.editMode.lockedBySomeone": "Jemand anderes bearbeitet dieses Dokument",
|
||||
"pageEditor.editMode.lockedDescription": "Die Seite ist schreibgeschützt, während sie bearbeitet wird. Ihre Änderungen werden erst gespeichert, wenn die Bearbeitung abgeschlossen ist.",
|
||||
"pageEditor.editedAt": "Zuletzt bearbeitet am {{time}}",
|
||||
"pageEditor.editedBy": "Zuletzt bearbeitet von {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Drücken Sie \"/\" für KI und Befehle",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"features.agentSelfIteration.title": "Agenten-Selbstiteration",
|
||||
"features.assistantMessageGroup.desc": "Agenten-Nachrichten und deren Tool-Ergebnisse gemeinsam anzeigen",
|
||||
"features.assistantMessageGroup.title": "Agenten-Nachrichten gruppieren",
|
||||
"features.fleet.desc": "Zeigt den Fleet-Eintrag in der Titelleiste an – ein nebeneinander angeordnetes Dashboard aller laufenden Aufgaben über Ihre Agenten hinweg.",
|
||||
"features.fleet.title": "Flottenansicht",
|
||||
"features.gatewayMode.desc": "Führt Agentenaufgaben über die Gateway-WebSocket-Verbindung auf dem Server aus, statt sie lokal auszuführen. Ermöglicht eine schnellere Ausführung und verringert die Client-Ressourcennutzung.",
|
||||
"features.gatewayMode.title": "Serverseitige Agentenausführung (Gateway)",
|
||||
"features.groupChat.desc": "Koordination von Gruppenchats mit mehreren Agenten aktivieren.",
|
||||
"features.groupChat.title": "Gruppenchat (Multi-Agenten)",
|
||||
"features.imessage.desc": "Verbinden Sie Agenten mit iMessage über die lokale LobeHub Desktop BlueBubbles-Bridge.",
|
||||
|
||||
@@ -890,7 +890,7 @@
|
||||
"skillStore.wantMore.feedback.title": "[Skill-Anfrage] Fassen Sie den benötigten Skill in einem Satz zusammen",
|
||||
"skillStore.wantMore.reachedEnd": "Sie haben das Ende erreicht. Nicht gefunden, was Sie suchen?",
|
||||
"startConversation": "Konversation starten",
|
||||
"storage.actions.copyAgentGroups.button": "Kopieren nach...",
|
||||
"storage.actions.copyAgentGroups.button": "Kopieren nach",
|
||||
"storage.actions.copyAgentGroups.desc": "Agentengruppen und ihre Mitglieder in einen anderen Arbeitsbereich oder persönlichen Account kopieren.",
|
||||
"storage.actions.copyAgentGroups.title": "Agentengruppen kopieren",
|
||||
"storage.actions.copyLobeAI.button": "Kopieren nach",
|
||||
@@ -1032,8 +1032,6 @@
|
||||
"tab.addCustomSkill": "Benutzerdefinierten Skill hinzufügen",
|
||||
"tab.advanced": "Erweitert",
|
||||
"tab.advanced.appUpdates.title": "App-Updates",
|
||||
"tab.advanced.gatewayMode.desc": "Führen Sie unterstützte Agentenaufgaben standardmäßig über das Cloud-Gateway aus. Einzelne Agenten können dies im Chat-Menü überschreiben.",
|
||||
"tab.advanced.gatewayMode.title": "Gateway-Modus",
|
||||
"tab.advanced.toolsAndDiagnostics.title": "Tools und Diagnosen",
|
||||
"tab.advanced.updateChannel.canary": "Canary",
|
||||
"tab.advanced.updateChannel.canaryDesc": "Ausgelöst bei jedem PR-Merge, mehrere Builds pro Tag. Am instabilsten.",
|
||||
@@ -1171,6 +1169,7 @@
|
||||
"tools.builtins.uninstallConfirm.desc": "Möchten Sie {{name}} wirklich deinstallieren? Diese Fähigkeit wird vom aktuellen Agenten entfernt.",
|
||||
"tools.builtins.uninstallConfirm.title": "{{name}} deinstallieren",
|
||||
"tools.builtins.uninstalled": "Deinstalliert",
|
||||
"tools.disabled": "Das aktuelle Modell unterstützt keine Funktionsaufrufe und kann die Fähigkeit nicht nutzen",
|
||||
"tools.composio.addServer": "Server hinzufügen",
|
||||
"tools.composio.authCompleted": "Authentifizierung abgeschlossen",
|
||||
"tools.composio.authFailed": "Authentifizierung fehlgeschlagen",
|
||||
@@ -1187,10 +1186,6 @@
|
||||
"tools.composio.notEnabled": "Composio-Dienst nicht aktiviert",
|
||||
"tools.composio.oauthRequired": "Bitte schließen Sie die OAuth-Authentifizierung im neuen Fenster ab",
|
||||
"tools.composio.pendingAuth": "Authentifizierung ausstehend",
|
||||
"tools.composio.reauthorize": "Erneut autorisieren",
|
||||
"tools.composio.remove": "Entfernen",
|
||||
"tools.composio.removeConfirm.desc": "{{name}} wird dauerhaft aus Ihren verbundenen Diensten entfernt. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"tools.composio.removeConfirm.title": "{{name}} entfernen?",
|
||||
"tools.composio.serverCreated": "Server erfolgreich erstellt",
|
||||
"tools.composio.serverCreatedFailed": "Servererstellung fehlgeschlagen",
|
||||
"tools.composio.serverRemoved": "Server entfernt",
|
||||
@@ -1243,7 +1238,6 @@
|
||||
"tools.composio.servers.zendesk.readme": "Integrieren Sie Zendesk, um Support-Tickets und Kundeninteraktionen zu verwalten. Anfragen erstellen, aktualisieren und verfolgen, Kundendaten abrufen und Ihre Supportprozesse optimieren.",
|
||||
"tools.composio.tools": "Werkzeuge",
|
||||
"tools.composio.verifyAuth": "Ich habe die Authentifizierung abgeschlossen",
|
||||
"tools.disabled": "Das aktuelle Modell unterstützt keine Funktionsaufrufe und kann die Fähigkeit nicht nutzen",
|
||||
"tools.lobehubSkill.authorize": "Autorisieren",
|
||||
"tools.lobehubSkill.connect": "Verbinden",
|
||||
"tools.lobehubSkill.connected": "Verbunden",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "Abgelaufen",
|
||||
"credits.packages.tabs.expiredCount": "Abgelaufen ({{count}})",
|
||||
"credits.packages.title": "Meine Guthabenpakete",
|
||||
"credits.topUp.bestValue.cta": "Ultimatives Jahresabo ansehen",
|
||||
"credits.topUp.bestValue.savings": "Sparen Sie ${{savings}} bei diesem Kauf",
|
||||
"credits.topUp.bestValue.title": "{{plan}} Jahresabo bietet den niedrigsten Aufladepreis: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "Abbrechen",
|
||||
"credits.topUp.custom": "Benutzerdefiniert",
|
||||
"credits.topUp.freeFeeHint": "Aufladungen im kostenlosen Tarif beinhalten eine {{fee}} Servicegebühr pro 1M Credits.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "Belohnungsverarbeitung: Credits werden innerhalb von 1 Stunde verteilt, nachdem der Eingeladene eine Zahlung abgeschlossen und die Verifizierung bestanden hat.",
|
||||
"referral.rules.title": "Programmregeln",
|
||||
"referral.rules.validInvitation": "Gültige Einladung: Der Eingeladene registriert sich mit Ihrem Einladungscode, führt eine gültige Aktion aus und schließt eine Zahlung ab (Abonnement oder Credit-Aufladung).",
|
||||
"referral.rules.validOperation": "Gültige Aktion: Eine Nachricht senden oder ein Bild generieren",
|
||||
"referral.stats.availableBalance": "Verfügbares Guthaben",
|
||||
"referral.stats.description": "Sehen Sie Ihre Empfehlungsstatistiken",
|
||||
"referral.stats.title": "Empfehlungsübersicht",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"actions.favorite": "Favorit",
|
||||
"actions.import": "Konversation importieren",
|
||||
"actions.markCompleted": "Als abgeschlossen markieren",
|
||||
"actions.moveToAgent": "Zu einem anderen Assistenten wechseln",
|
||||
"actions.openInNewTab": "In neuem Tab öffnen",
|
||||
"actions.openInNewWindow": "In neuem Fenster öffnen",
|
||||
"actions.removeAll": "Alle Themen löschen",
|
||||
@@ -81,9 +80,6 @@
|
||||
"management.bulk.deleteConfirm": "Sie sind dabei, {{count}} Themen zu löschen. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"management.bulk.deleteTitle": "Themen löschen?",
|
||||
"management.bulk.favorite": "Favorisieren",
|
||||
"management.bulk.move": "Zum Assistenten wechseln",
|
||||
"management.bulk.moveEmpty": "Keine anderen Assistenten",
|
||||
"management.bulk.moveSearchPlaceholder": "Assistenten suchen…",
|
||||
"management.bulk.selectedCount_one": "{{count}} ausgewählt",
|
||||
"management.bulk.selectedCount_other": "{{count}} ausgewählt",
|
||||
"management.card.noPreview": "Keine Vorschau verfügbar",
|
||||
@@ -122,17 +118,6 @@
|
||||
"management.group.noProject": "Kein Projekt",
|
||||
"management.group.none": "Keine",
|
||||
"management.loadingMore": "Weitere Themen werden geladen…",
|
||||
"management.moveModal.back": "Zurück",
|
||||
"management.moveModal.confirmContent_one": "{{count}} Thema zu „{{title}}“ verschieben?",
|
||||
"management.moveModal.confirmContent_other": "{{count}} Themen zu „{{title}}“ verschieben?",
|
||||
"management.moveModal.confirmOk": "Verschieben",
|
||||
"management.moveModal.doneOk": "Fertig",
|
||||
"management.moveModal.done_one": "{{count}} Thema verschoben",
|
||||
"management.moveModal.done_other": "{{count}} Themen verschoben",
|
||||
"management.moveModal.error": "Verschieben fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||
"management.moveModal.goToTarget": "Zu „{{title}}“ wechseln",
|
||||
"management.moveModal.moving": "Verschiebe…",
|
||||
"management.moveModal.title": "Themen verschieben",
|
||||
"management.searchPlaceholder": "Themen dieses Agenten durchsuchen…",
|
||||
"management.sidebarEntry": "Themen",
|
||||
"management.sort.createdAt": "Erstellungszeit",
|
||||
@@ -143,7 +128,6 @@
|
||||
"management.status.archived": "Archiviert",
|
||||
"management.status.completed": "Abgeschlossen",
|
||||
"management.status.failed": "Fehlgeschlagen",
|
||||
"management.status.idle": "Leerlauf",
|
||||
"management.status.paused": "Pausiert",
|
||||
"management.status.running": "Laufend",
|
||||
"management.status.waitingForHuman": "Wartet auf Eingabe",
|
||||
@@ -155,8 +139,6 @@
|
||||
"projectStatus.failed_other": "{{count}} fehlgeschlagene Themen",
|
||||
"projectStatus.loading_one": "{{count}} ladendes Thema",
|
||||
"projectStatus.loading_other": "{{count}} ladende Themen",
|
||||
"projectStatus.unread_one": "{{count}} Thema mit ungelesener Antwort",
|
||||
"projectStatus.unread_other": "{{count}} Themen mit ungelesenen Antworten",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} Thema wartet auf Eingabe",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} Themen warten auf Eingabe",
|
||||
"renameModal.description": "Kurz und leicht erkennbar halten.",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"channel.devWebhookProxyUrl": "HTTPS Tunnel URL",
|
||||
"channel.devWebhookProxyUrlHint": "Optional. HTTPS tunnel URL for forwarding webhook requests to local dev server.",
|
||||
"channel.disabled": "Disabled",
|
||||
"channel.discord.description": "Connect this agent to Discord server for channel chat and direct messages.",
|
||||
"channel.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.",
|
||||
"channel.displayToolCalls": "Display Tool Calls",
|
||||
"channel.displayToolCallsHint": "Show tool call details during AI responses. When disabled, only the final response is displayed for a cleaner experience.",
|
||||
"channel.dm": "Direct Messages",
|
||||
@@ -79,7 +79,7 @@
|
||||
"channel.endpointUrl": "Webhook URL",
|
||||
"channel.endpointUrlHint": "Please copy this URL and paste it into the <bold>{{fieldName}}</bold> field in the {{name}} Developer Portal.",
|
||||
"channel.exportConfig": "Export Configuration",
|
||||
"channel.feishu.description": "Connect this agent to Feishu for private and group chats.",
|
||||
"channel.feishu.description": "Connect this assistant to Feishu for private and group chats.",
|
||||
"channel.feishu.webhookMigrationDesc": "WebSocket mode provides real-time event delivery without needing a public callback URL. To migrate, switch the Connection Mode to WebSocket in Advanced Settings. No additional configuration is needed on the Feishu/Lark Open Platform.",
|
||||
"channel.feishu.webhookMigrationTitle": "Consider migrating to WebSocket mode",
|
||||
"channel.groupAllowFrom": "Allowed Channels",
|
||||
@@ -135,7 +135,7 @@
|
||||
"channel.imessage.bridgeTestDisabledHint": "Enable the bridge service first.",
|
||||
"channel.imessage.bridgeTestFailed": "BlueBubbles test failed",
|
||||
"channel.imessage.bridgeTestSuccess": "BlueBubbles connection passed",
|
||||
"channel.imessage.description": "Connect this agent to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
|
||||
"channel.imessage.description": "Connect this assistant to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
|
||||
"channel.imessage.desktopBridge": "Desktop Bridge",
|
||||
"channel.imessage.desktopDeviceId": "Desktop Device ID",
|
||||
"channel.imessage.desktopDeviceIdHint": "The LobeHub Desktop device that runs the local BlueBubbles bridge. Find it in Desktop Gateway settings.",
|
||||
@@ -145,12 +145,12 @@
|
||||
"channel.importFailed": "Failed to import configuration",
|
||||
"channel.importInvalidFormat": "Invalid configuration file format",
|
||||
"channel.importSuccess": "Configuration imported successfully",
|
||||
"channel.lark.description": "Connect this agent to Lark for private and group chats.",
|
||||
"channel.lark.description": "Connect this assistant to Lark for private and group chats.",
|
||||
"channel.line.channelAccessToken": "Channel Access Token",
|
||||
"channel.line.channelAccessTokenHint": "Long-lived token issued under the Messaging API tab. Token will be encrypted and stored securely.",
|
||||
"channel.line.channelSecret": "Channel Secret",
|
||||
"channel.line.channelSecretHint": "From the Basic settings tab. Required — used to verify X-Line-Signature on every inbound webhook.",
|
||||
"channel.line.description": "Connect this agent to LINE Messaging API for direct and group chats.",
|
||||
"channel.line.description": "Connect this assistant to LINE Messaging API for direct and group chats.",
|
||||
"channel.line.destinationUserId": "Destination User ID",
|
||||
"channel.line.destinationUserIdHint": "The bot's own user ID (`U` + 32 chars) — click \"Fetch from LINE\" below to auto-fill. Not the personal \"Your user ID\" shown in LINE's Basic settings.",
|
||||
"channel.line.destinationUserIdPlaceholder": "e.g. U1234567890abcdef1234567890abcdef",
|
||||
@@ -169,7 +169,7 @@
|
||||
"channel.publicKeyHint": "Optional. Used to verify interaction requests from Discord.",
|
||||
"channel.publicKeyPlaceholder": "Required for interaction verification",
|
||||
"channel.qq.appIdHint": "Your QQ Bot App ID from QQ Open Platform",
|
||||
"channel.qq.description": "Connect this agent to QQ for group chats and direct messages.",
|
||||
"channel.qq.description": "Connect this assistant to QQ for group chats and direct messages.",
|
||||
"channel.qq.webhookMigrationDesc": "WebSocket mode provides real-time event delivery and automatic reconnection without needing a callback URL. To migrate, create a new bot on QQ Open Platform without configuring a callback URL, then switch the Connection Mode to WebSocket in Advanced Settings.",
|
||||
"channel.qq.webhookMigrationTitle": "Consider migrating to WebSocket mode",
|
||||
"channel.refreshStatus": "Refresh status",
|
||||
@@ -199,7 +199,7 @@
|
||||
"channel.slack.appIdHint": "Your Slack App ID from the Slack API dashboard (starts with A).",
|
||||
"channel.slack.appToken": "App-Level Token",
|
||||
"channel.slack.appTokenHint": "Required for Socket Mode (WebSocket). Generate an app-level token (xapp-...) under Basic Information in your Slack app settings.",
|
||||
"channel.slack.description": "Connect this agent to Slack for channel conversations and direct messages.",
|
||||
"channel.slack.description": "Connect this assistant to Slack for channel conversations and direct messages.",
|
||||
"channel.slack.webhookMigrationDesc": "Socket Mode provides real-time event delivery via WebSocket without exposing a public HTTP endpoint. To migrate, enable Socket Mode in your Slack app settings, generate an App-Level Token, then switch the Connection Mode to WebSocket in Advanced Settings.",
|
||||
"channel.slack.webhookMigrationTitle": "Consider migrating to Socket Mode (WebSocket)",
|
||||
"channel.statusConnected": "Connected",
|
||||
@@ -208,7 +208,7 @@
|
||||
"channel.statusFailed": "Failed",
|
||||
"channel.statusQueued": "Queued",
|
||||
"channel.statusStarting": "Starting",
|
||||
"channel.telegram.description": "Connect this agent to Telegram for private and group chats.",
|
||||
"channel.telegram.description": "Connect this assistant to Telegram for private and group chats.",
|
||||
"channel.testConnection": "Test Connection",
|
||||
"channel.testFailed": "Connection test failed",
|
||||
"channel.testSuccess": "Connection test passed",
|
||||
@@ -236,7 +236,7 @@
|
||||
"channel.watchKeywordsAdd": "Add keyword",
|
||||
"channel.watchKeywordsEmpty": "No keywords added yet — bot only wakes on @mention or DM in subscribed channels.",
|
||||
"channel.watchKeywordsHint": "A keyword match wakes the bot without an @mention; its instruction is prepended to the user message. Whole-word, case-insensitive.",
|
||||
"channel.wechat.description": "Connect this agent to WeChat via iLink Bot for private and group chats.",
|
||||
"channel.wechat.description": "Connect this assistant to WeChat via iLink Bot for private and group chats.",
|
||||
"channel.wechatBotId": "Bot ID",
|
||||
"channel.wechatBotIdHint": "Bot identifier assigned after QR code authorization.",
|
||||
"channel.wechatConnectedInfo": "Connected WeChat Account",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"apikey.list.columns.status": "Enabled Status",
|
||||
"apikey.list.title": "API Key List",
|
||||
"apikey.validation.required": "This field cannot be empty",
|
||||
"authModal.description": "Your sign-in session has expired. Please sign in again to continue using cloud sync features.",
|
||||
"authModal.description": "Your login session has expired. Please sign in again to continue using cloud sync features.",
|
||||
"authModal.later": "Later",
|
||||
"authModal.signIn": "Sign In Again",
|
||||
"authModal.signingIn": "Signing in...",
|
||||
@@ -45,7 +45,7 @@
|
||||
"betterAuth.errors.emailRequired": "Please enter your email address or username",
|
||||
"betterAuth.errors.firstNameRequired": "Please enter your first name",
|
||||
"betterAuth.errors.lastNameRequired": "Please enter your last name",
|
||||
"betterAuth.errors.loginFailed": "Sign in failed, please check your email and password",
|
||||
"betterAuth.errors.loginFailed": "Login failed, please check your email and password",
|
||||
"betterAuth.errors.passwordFormat": "Password must contain both letters and numbers",
|
||||
"betterAuth.errors.passwordMaxLength": "Password must not exceed 64 characters",
|
||||
"betterAuth.errors.passwordMinLength": "Password must be at least 8 characters",
|
||||
@@ -155,13 +155,13 @@
|
||||
"heatmaps.tooltipTokens": "{{count}} tokens were used on {{date}}",
|
||||
"heatmaps.totalCount": "A total of {{count}} messages sent in the past year",
|
||||
"heatmaps.totalCountTokens": "A total of {{count}} tokens used in the past year",
|
||||
"login": "Sign In",
|
||||
"login": "Log In",
|
||||
"loginGuide.f1": "Get free usage",
|
||||
"loginGuide.f2": "Sync messages across devices",
|
||||
"loginGuide.f3": "Access a wealth of agents",
|
||||
"loginGuide.f4": "Explore powerful plugins",
|
||||
"loginGuide.title": "After signing in, you can:",
|
||||
"loginOrSignup": "Sign In / Sign Up",
|
||||
"loginGuide.title": "After logging in, you can:",
|
||||
"loginOrSignup": "Log In / Sign Up",
|
||||
"profile.account": "Account",
|
||||
"profile.authorizations.actions.revoke": "Revoke",
|
||||
"profile.authorizations.revoke.description": "After revoking, the tool will no longer have access to your data. Re-authorization is required to use it again.",
|
||||
@@ -186,7 +186,7 @@
|
||||
"profile.sso.loading": "Loading linked third-party accounts",
|
||||
"profile.sso.providers": "Connected Accounts",
|
||||
"profile.sso.unlink.description": "Re-authorization or re-linking is required to sign in with {{provider}} again after unlinking.",
|
||||
"profile.sso.unlink.forbidden": "You must retain at least one sign-in method.",
|
||||
"profile.sso.unlink.forbidden": "You must retain at least one login method.",
|
||||
"profile.sso.unlink.title": "Unlink {{provider}} account?",
|
||||
"profile.title": "Profile",
|
||||
"profile.updateAvatar": "Update avatar",
|
||||
@@ -201,9 +201,9 @@
|
||||
"profile.usernameRule": "Username can only contain letters, numbers, or underscores",
|
||||
"profile.usernameTooLong": "Username cannot exceed 64 characters",
|
||||
"profile.usernameUpdateFailed": "Failed to update username, please try again later",
|
||||
"signin.subtitle": "Sign up or sign in to your {{appName}} account",
|
||||
"signin.subtitle": "Sign up or log in to your {{appName}} account",
|
||||
"signin.title": "Agent teammates that grow with you",
|
||||
"signout": "Sign Out",
|
||||
"signout": "Log Out",
|
||||
"signup": "Sign Up",
|
||||
"stats.aiheatmaps": "Activity Index",
|
||||
"stats.assistants": "Agents",
|
||||
@@ -224,7 +224,7 @@
|
||||
"stats.loginGuide.f2": "Sync messages across devices",
|
||||
"stats.loginGuide.f3": "Access a wealth of agents",
|
||||
"stats.loginGuide.f4": "Explore powerful skills",
|
||||
"stats.loginGuide.title": "After signing in, you can:",
|
||||
"stats.loginGuide.title": "After logging in, you can:",
|
||||
"stats.messages": "Messages",
|
||||
"stats.modelsRank.left": "Model",
|
||||
"stats.modelsRank.right": "Messages",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions.discord": "Go to Discord for feedback",
|
||||
"actions.home": "Return to Home",
|
||||
"actions.retry": "Sign in Again",
|
||||
"actions.retry": "Log in Again",
|
||||
"codes.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER": "This account is already linked to another user",
|
||||
"codes.ACCOUNT_NOT_FOUND": "Account not found",
|
||||
"codes.CREDENTIAL_ACCOUNT_NOT_FOUND": "Credential account does not exist",
|
||||
@@ -25,7 +25,7 @@
|
||||
"codes.PASSWORD_TOO_SHORT": "Password is too short",
|
||||
"codes.PROVIDER_NOT_FOUND": "Identity provider configuration not found",
|
||||
"codes.RATE_LIMIT_EXCEEDED": "Too many requests, please try again later",
|
||||
"codes.SESSION_EXPIRED": "Session has expired, please sign in again",
|
||||
"codes.SESSION_EXPIRED": "Session has expired, please log in again",
|
||||
"codes.SOCIAL_ACCOUNT_ALREADY_LINKED": "This social account is already linked to another user",
|
||||
"codes.TEMPORARY_EMAIL_NOT_ALLOWED": "Temporary email addresses are not supported. Please use a regular email address. Repeated attempts may block this network.",
|
||||
"codes.UNEXPECTED_ERROR": "An unexpected error occurred, please try again",
|
||||
|
||||
+13
-19
@@ -20,13 +20,10 @@
|
||||
"agentDefaultMessage": "Hi, I’m **{{name}}**. One sentence is enough.\n\nWant me to match your workflow better? Go to [Agent Settings]({{url}}) and fill in the Agent Profile (you can edit it anytime).",
|
||||
"agentDefaultMessageWithSystemRole": "Hi, I’m **{{name}}**. One sentence is enough—you're in control.",
|
||||
"agentDefaultMessageWithoutEdit": "Hi, I’m **{{name}}**. One sentence is enough—you're in control.",
|
||||
"agentDocument.backToChat": "Back to chat",
|
||||
"agentDocument.linkCopied": "Link copied",
|
||||
"agentDocument.openAsPage": "Open as full page",
|
||||
"agentProfile.files_one": "{{count}} file",
|
||||
"agentProfile.files_other": "{{count}} files",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} library",
|
||||
"agentProfile.knowledgeBases_other": "{{count}} libraries",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} knowledge base",
|
||||
"agentProfile.knowledgeBases_other": "{{count}} knowledge bases",
|
||||
"agentProfile.skills_one": "{{count}} skill",
|
||||
"agentProfile.skills_other": "{{count}} skills",
|
||||
"agentSignal.receipts.agentSignalLabel": "Agent Signal",
|
||||
@@ -67,7 +64,7 @@
|
||||
"claudeCodeInstallGuide.menuNotification.title": "Claude Code CLI not found",
|
||||
"claudeCodeInstallGuide.reason": "LobeHub could not start Claude Code: {{message}}",
|
||||
"claudeCodeInstallGuide.title": "Install Claude Code CLI",
|
||||
"clearCurrentMessages": "Clear current conversation messages",
|
||||
"clearCurrentMessages": "Clear current session messages",
|
||||
"cliAuthGuide.actions.openDocs": "Open Sign-in Guide",
|
||||
"cliAuthGuide.actions.openSystemTools": "Open System Tools",
|
||||
"cliAuthGuide.afterLogin": "After signing in again or refreshing credentials, retry your message. You can also re-detect in System Tools.",
|
||||
@@ -110,7 +107,7 @@
|
||||
"compression.cancelConfirm": "Are you sure you want to uncompress? This will restore the original messages.",
|
||||
"compression.history": "History",
|
||||
"compression.summary": "Summary",
|
||||
"confirmClearCurrentMessages": "You are about to clear the current conversation messages. Once cleared, they cannot be retrieved. Please confirm your action.",
|
||||
"confirmClearCurrentMessages": "You are about to clear the current session messages. Once cleared, they cannot be retrieved. Please confirm your action.",
|
||||
"confirmRemoveChatGroupItemAlert": "This Group will be deleted. Group-specific assistants will also be deleted, while external assistants will not be affected.",
|
||||
"confirmRemoveGroupItemAlert": "You are about to delete this category. After deletion, its agents will be moved to the default list. Please confirm your action.",
|
||||
"confirmRemoveGroupSuccess": "Group deleted successfully",
|
||||
@@ -168,9 +165,7 @@
|
||||
"extendParams.urlContext.title": "Extract Webpage Link Content",
|
||||
"followUpPlaceholder": "Follow up.",
|
||||
"followUpPlaceholderHeterogeneous": "Follow up.",
|
||||
"gatewayMode.beta": "Beta",
|
||||
"gatewayMode.cardTitle": "Agent Gateway Mode",
|
||||
"gatewayMode.desc": "Run agents in the cloud through LobeHub's Agent Gateway. Tasks keep running even after you close the page.",
|
||||
"gatewayMode.title": "Gateway Mode",
|
||||
"group.desc": "Move a task forward with multiple Agents in one shared space.",
|
||||
"group.memberTooltip": "There are {{count}} members in the group",
|
||||
"group.orchestratorThinking": "Orchestrator is thinking...",
|
||||
@@ -178,8 +173,8 @@
|
||||
"group.profile.external": "External",
|
||||
"group.profile.externalAgentWarning": "This is an external agent. Changes made here will directly modify the original agent configuration.",
|
||||
"group.profile.groupSettings": "Group Settings",
|
||||
"group.profile.supervisor": "Orchestrator",
|
||||
"group.profile.supervisorPlaceholder": "The Orchestrator coordinates different agents. Setting Orchestrator information here enables more precise workflow coordination.",
|
||||
"group.profile.supervisor": "Supervisor",
|
||||
"group.profile.supervisorPlaceholder": "The supervisor coordinates different agents. Setting supervisor information here enables more precise workflow coordination.",
|
||||
"group.removeMember": "Remove Member",
|
||||
"group.title": "Group",
|
||||
"groupDescription": "Group description",
|
||||
@@ -586,7 +581,7 @@
|
||||
"stt.action": "Voice Input",
|
||||
"stt.loading": "Recognizing...",
|
||||
"stt.prettifying": "Polishing...",
|
||||
"supervisor.label": "Orchestrator",
|
||||
"supervisor.label": "Supervisor",
|
||||
"supervisor.todoList.allComplete": "All tasks completed",
|
||||
"supervisor.todoList.title": "Tasks Completed",
|
||||
"tab.groupProfile": "Group Profile",
|
||||
@@ -839,7 +834,7 @@
|
||||
"tokenDetails.chats": "Chat Messages",
|
||||
"tokenDetails.historySummary": "History Summary",
|
||||
"tokenDetails.rest": "Remaining",
|
||||
"tokenDetails.supervisor": "Orchestrator",
|
||||
"tokenDetails.supervisor": "Group Host",
|
||||
"tokenDetails.systemRole": "Role Settings",
|
||||
"tokenDetails.title": "Context Details",
|
||||
"tokenDetails.tools": "Skill Settings",
|
||||
@@ -882,7 +877,6 @@
|
||||
"toolAuth.authorize": "Authorize",
|
||||
"toolAuth.authorizing": "Authorizing...",
|
||||
"toolAuth.hint": "When Skills aren't authorized or configured, the related Skills won't work and the Agent's capabilities may be limited or run into errors.",
|
||||
"toolAuth.remove": "Remove",
|
||||
"toolAuth.signIn": "Sign In",
|
||||
"toolAuth.title": "Authorize Skills for this Agent",
|
||||
"topic.checkOpenNewTopic": "Start a new topic?",
|
||||
@@ -890,7 +884,7 @@
|
||||
"topic.defaultTitle": "Untitled Topic",
|
||||
"topic.openNewTopic": "Open New Topic",
|
||||
"topic.recent": "Recent Topics",
|
||||
"topic.saveCurrentMessages": "Save current conversation as topic",
|
||||
"topic.saveCurrentMessages": "Save current session as topic",
|
||||
"topic.viewAll": "View All Topics",
|
||||
"translate.action": "Translate",
|
||||
"translate.clear": "Clear Translation",
|
||||
@@ -986,7 +980,7 @@
|
||||
"workflow.toolDisplayName.saveUserQuestion": "Recorded info",
|
||||
"workflow.toolDisplayName.search": "Searched the web",
|
||||
"workflow.toolDisplayName.searchAgent": "Searched agents",
|
||||
"workflow.toolDisplayName.searchKnowledgeBase": "Searched library",
|
||||
"workflow.toolDisplayName.searchKnowledgeBase": "Searched knowledge base",
|
||||
"workflow.toolDisplayName.searchLocalFiles": "Searched files",
|
||||
"workflow.toolDisplayName.searchSkill": "Searched skills",
|
||||
"workflow.toolDisplayName.searchUserMemory": "Searched memory",
|
||||
@@ -1043,7 +1037,6 @@
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.deleteTitleMulti": "Delete {{count}} items?",
|
||||
"workingPanel.resources.empty": "No webpages. Webpages crawled in this agent will show up here.",
|
||||
"workingPanel.resources.emptyDocuments": "No documents yet. Create one with the + above.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.documents": "Documents",
|
||||
"workingPanel.resources.filter.skills": "Skills",
|
||||
@@ -1125,5 +1118,6 @@
|
||||
"workingPanel.skills.title": "Skills",
|
||||
"workingPanel.space": "Space",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "You"
|
||||
"you": "You",
|
||||
"zenMode": "Zen Mode"
|
||||
}
|
||||
|
||||
@@ -168,8 +168,8 @@
|
||||
"cmdk.search.agents": "Agents",
|
||||
"cmdk.search.assistant": "Agent",
|
||||
"cmdk.search.assistants": "Agents",
|
||||
"cmdk.search.chatGroup": "Group",
|
||||
"cmdk.search.chatGroups": "Groups",
|
||||
"cmdk.search.chatGroup": "Agent Team",
|
||||
"cmdk.search.chatGroups": "Agent Teams",
|
||||
"cmdk.search.communityAgent": "Community Agent",
|
||||
"cmdk.search.file": "File",
|
||||
"cmdk.search.files": "Files",
|
||||
@@ -370,7 +370,7 @@
|
||||
"navPanel.visible": "Visible",
|
||||
"new": "New",
|
||||
"noContent": "No content",
|
||||
"oauth": "SSO Sign-in",
|
||||
"oauth": "SSO Login",
|
||||
"officialSite": "Official Website",
|
||||
"ok": "OK",
|
||||
"or": "or",
|
||||
|
||||
@@ -101,8 +101,7 @@
|
||||
"LocalFile.action.open": "Open",
|
||||
"LocalFile.action.showInFolder": "Show in Folder",
|
||||
"MaxTokenSlider.unlimited": "Unlimited",
|
||||
"ModelSelect.featureTag.audio": "This model supports audio input recognition.",
|
||||
"ModelSelect.featureTag.custom": "Custom model, by default, supports both tool calls and visual recognition. Please verify the availability of the above capabilities based on actual situations.",
|
||||
"ModelSelect.featureTag.custom": "Custom model, by default, supports both function calls and visual recognition. Please verify the availability of the above capabilities based on actual situations.",
|
||||
"ModelSelect.featureTag.file": "This model supports file upload for reading and recognition.",
|
||||
"ModelSelect.featureTag.functionCall": "This model supports tool calls.",
|
||||
"ModelSelect.featureTag.imageOutput": "This model supports image generation.",
|
||||
@@ -115,7 +114,6 @@
|
||||
"ModelSwitchPanel.byModel": "By Model",
|
||||
"ModelSwitchPanel.byProvider": "By Provider",
|
||||
"ModelSwitchPanel.detail.abilities": "Abilities",
|
||||
"ModelSwitchPanel.detail.abilities.audio": "Audio",
|
||||
"ModelSwitchPanel.detail.abilities.files": "Files",
|
||||
"ModelSwitchPanel.detail.abilities.functionCall": "Tool Calling",
|
||||
"ModelSwitchPanel.detail.abilities.imageOutput": "Image Output",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"authResult.failed.desc": "Please try again or switch to a different sign-in method",
|
||||
"authResult.failed.desc": "Please try again or switch to a different login method",
|
||||
"authResult.failed.title": "Authorization Failed",
|
||||
"authResult.success.desc": "Please click the Start button below to continue using LobeHub Desktop",
|
||||
"authResult.success.title": "Authorization Successful",
|
||||
|
||||
@@ -620,7 +620,7 @@
|
||||
"user.githubUrlInvalid": "Please enter a valid GitHub repository URL",
|
||||
"user.githubUrlRequired": "Please enter a GitHub repository URL",
|
||||
"user.login": "Become a Creator",
|
||||
"user.logout": "Sign out",
|
||||
"user.logout": "Logout",
|
||||
"user.myProfile": "My Profile",
|
||||
"user.noAgents": "This user hasn’t published any Agents yet",
|
||||
"user.noAgents.ownerDescription": "Create your first Agent and share it with the Community.",
|
||||
@@ -630,11 +630,11 @@
|
||||
"user.noForkedAgentGroups": "No forked Agent Groups yet",
|
||||
"user.noForkedAgents": "No forked Agents yet",
|
||||
"user.noGroups.title": "No Agent Groups yet",
|
||||
"user.noPlugins": "This user hasn't published any Skills yet",
|
||||
"user.noPlugins": "This user hasn't published any Plugins yet",
|
||||
"user.noSkills": "This user hasn't published any Skills yet",
|
||||
"user.openWorkspacePublicProfile": "Open Public Link",
|
||||
"user.org.noAgents": "This organization hasn’t published any Agents yet",
|
||||
"user.plugins": "Skills",
|
||||
"user.plugins": "Plugins",
|
||||
"user.publishedAgents": "Created Agents",
|
||||
"user.publishedGroups": "Created Groups",
|
||||
"user.searchPlaceholder": "Search by name or description...",
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
"fleet.allShown": "All running tasks are shown",
|
||||
"fleet.backToHome": "Back to home",
|
||||
"fleet.closeColumn": "Close column",
|
||||
"fleet.closeIdleColumns": "Close idle columns",
|
||||
"fleet.closeIdleColumnsCount": "Close {{count}} idle columns",
|
||||
"fleet.collapseReply": "Collapse",
|
||||
"fleet.createTask": "Create task",
|
||||
"fleet.dragHint": "Drag to reorder",
|
||||
@@ -34,11 +32,10 @@
|
||||
"gateway.title": "Device Gateway",
|
||||
"navigation.chat": "Chat",
|
||||
"navigation.discover": "Discover",
|
||||
"navigation.discoverAssistants": "Discover Agents",
|
||||
"navigation.discoverAssistants": "Discover Assistants",
|
||||
"navigation.discoverMcp": "Discover MCP",
|
||||
"navigation.discoverModels": "Discover Models",
|
||||
"navigation.discoverProviders": "Discover Providers",
|
||||
"navigation.document": "Document",
|
||||
"navigation.group": "Group",
|
||||
"navigation.groupChat": "Group Chat",
|
||||
"navigation.home": "Home",
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"import.importConfigFile.title": "Import Failed",
|
||||
"import.incompatible.description": "This file was exported from a higher version. Please try upgrading to the latest version and then re-importing.",
|
||||
"import.incompatible.title": "Current application does not support importing this file",
|
||||
"loginRequired.desc": "You will be redirected to the sign-in page shortly",
|
||||
"loginRequired.title": "Please sign in to use this feature",
|
||||
"loginRequired.desc": "You will be redirected to the login page shortly",
|
||||
"loginRequired.title": "Please log in to use this feature",
|
||||
"notFound.backHome": "Back to Home",
|
||||
"notFound.check": "Please check if your URL is correct.",
|
||||
"notFound.desc": "We couldn't find the page you were looking for.",
|
||||
@@ -124,9 +124,9 @@
|
||||
"unlock.comfyui.title": "Verify your {{name}} credentials",
|
||||
"unlock.confirm": "Confirm and Retry",
|
||||
"unlock.goToSettings": "Go to Settings",
|
||||
"unlock.oauth.description": "The administrator has enabled unified sign-in authentication. Click the button below to sign in and unlock the application.",
|
||||
"unlock.oauth.success": "Sign-in successful",
|
||||
"unlock.oauth.title": "Sign in to your account",
|
||||
"unlock.oauth.description": "The administrator has enabled unified login authentication. Click the button below to log in and unlock the application.",
|
||||
"unlock.oauth.success": "Login successful",
|
||||
"unlock.oauth.title": "Log in to your account",
|
||||
"unlock.oauth.welcome": "Welcome!",
|
||||
"unlock.password.description": "The application encryption has been enabled by the administrator. Enter the application password to unlock the application. The password only needs to be filled in once.",
|
||||
"unlock.password.placeholder": "Please enter password",
|
||||
|
||||
+3
-13
@@ -56,9 +56,9 @@
|
||||
"library.hierarchy.empty.desc": "Add files or create a folder to get started",
|
||||
"library.hierarchy.empty.title": "Nothing here yet",
|
||||
"library.import.action": "Import to workspace…",
|
||||
"library.import.failed": "Failed to import library.",
|
||||
"library.import.success": "Library imported to {{name}}.",
|
||||
"library.import.tooltip": "Fork this library into a workspace. Files are shared by reference; the original stays in your personal space.",
|
||||
"library.import.failed": "Failed to import knowledge base.",
|
||||
"library.import.success": "Knowledge base imported to {{name}}.",
|
||||
"library.import.tooltip": "Fork this knowledge base into a workspace. Files are shared by reference; the original stays in your personal space.",
|
||||
"library.list.confirmRemoveLibrary": "You are about to delete this library. The files within it will not be deleted but moved to All Files. This action cannot be undone, so please proceed with caution.",
|
||||
"library.list.copyDescription": "Clone this library and all of its contents into another workspace.",
|
||||
"library.list.copyFailed": "Failed to copy library",
|
||||
@@ -95,18 +95,8 @@
|
||||
"pageEditor.duplicateError": "Failed to duplicate the page",
|
||||
"pageEditor.duplicateSuccess": "Page duplicated successfully",
|
||||
"pageEditor.editMode.checking": "Checking edit availability…",
|
||||
"pageEditor.editMode.draftRestoreCancel": "Discard",
|
||||
"pageEditor.editMode.draftRestoreContent": "Found unsaved local changes from your last session. Restore them?",
|
||||
"pageEditor.editMode.draftRestoreOk": "Restore",
|
||||
"pageEditor.editMode.draftRestoreTitle": "Restore Unsaved Draft",
|
||||
"pageEditor.editMode.lockLostDescription": "Recent edits haven’t synced yet. They’ll resume saving once the connection recovers.",
|
||||
"pageEditor.editMode.lockLostTitle": "Edit lock temporarily lost",
|
||||
"pageEditor.editMode.lockUnstable": "Reconnecting edit lock…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} is editing this document",
|
||||
"pageEditor.editMode.lockedBySelf": "You’re editing this document in another tab",
|
||||
"pageEditor.editMode.lockedBySelfDescription": "Saves will resume after the other session closes or its lock expires (~30s).",
|
||||
"pageEditor.editMode.lockedBySomeone": "Someone else is editing this document",
|
||||
"pageEditor.editMode.lockedDescription": "The page is read-only while they edit. Your changes won’t be saved until they’re done.",
|
||||
"pageEditor.editedAt": "Last edited on {{time}}",
|
||||
"pageEditor.editedBy": "Last edited by {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Press \"/\" for AI and commands.",
|
||||
|
||||
@@ -38,5 +38,7 @@
|
||||
"toggleLeftPanel.desc": "Show or hide the left panel",
|
||||
"toggleLeftPanel.title": "Toggle Left Panel",
|
||||
"toggleRightPanel.desc": "Show or hide the right panel",
|
||||
"toggleRightPanel.title": "Toggle Right Panel"
|
||||
"toggleRightPanel.title": "Toggle Right Panel",
|
||||
"toggleZenMode.desc": "In focus mode, only display the current conversation and hide other UI elements",
|
||||
"toggleZenMode.title": "Toggle Focus Mode"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"features.agentDocumentFloatingChatPanel.desc": "Show the floating chat panel in agent document preview only when this lab feature is enabled.",
|
||||
"features.agentDocumentFloatingChatPanel.title": "Agent Document Floating Chat Panel",
|
||||
"features.agentSelfIteration.desc": "Allow the agent to reflect, build self-awareness, and continuously iterate through ongoing attempts and interactions.",
|
||||
"features.agentSelfIteration.desc": "Allow the assistant to reflect, build self-awareness, and continuously iterate through ongoing attempts and interactions.",
|
||||
"features.agentSelfIteration.title": "Agent Self-iteration",
|
||||
"features.assistantMessageGroup.desc": "Group agent messages and their tool call results together for display",
|
||||
"features.assistantMessageGroup.title": "Agent Message Grouping",
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
"authorize.footer.terms": "Terms of Service",
|
||||
"authorize.scenes.mcp.subtitle": "Create a community profile to install and run this skill from the community.",
|
||||
"authorize.scenes.mcp.title": "Install Community Skill",
|
||||
"authorize.scenes.publish.subtitle": "Create a community profile to publish and manage your listing within the community.",
|
||||
"authorize.scenes.publish.title": "Publish to the Community",
|
||||
"authorize.scenes.sandbox.subtitle": "Create a community profile to run this tool in the community sandbox.",
|
||||
"authorize.scenes.sandbox.title": "Try the Community Sandbox",
|
||||
"authorize.subtitle": "Create a community profile to use community features.",
|
||||
"authorize.subtitle": "Create a community profile to submit and manage listings within the community.",
|
||||
"authorize.title": "Create Community Profile",
|
||||
"callback.buttons.close": "Close Window",
|
||||
"callback.messages.authFailed": "Authorization failed: {{error}}",
|
||||
@@ -32,7 +34,7 @@
|
||||
"errors.authorizationFailed": "Authorization failed, please try again.",
|
||||
"errors.browserOnly": "The authorization process can only be initiated in a browser.",
|
||||
"errors.codeConsumed": "The authorization code has already been used. Please try again.",
|
||||
"errors.codeVerifierMissing": "Invalid authorization session. Please restart the sign-in process.",
|
||||
"errors.codeVerifierMissing": "Invalid authorization session. Please restart the login process.",
|
||||
"errors.general": "An error occurred during authorization. Please try again.",
|
||||
"errors.handoffFailed": "Failed to retrieve authorization result. Please try again.",
|
||||
"errors.handoffTimeout": "Authorization timed out. Please complete the process in your browser and try again.",
|
||||
@@ -40,7 +42,7 @@
|
||||
"errors.openBrowserFailed": "Failed to open the system browser. Please try again.",
|
||||
"errors.openPopupFailed": "Failed to open authorization popup. Please check your browser's popup blocker settings.",
|
||||
"errors.popupClosed": "The authorization window was closed before completion.",
|
||||
"errors.sessionExpired": "Authorization session has expired. Please sign in again.",
|
||||
"errors.sessionExpired": "Authorization session has expired. Please log in again.",
|
||||
"errors.stateMismatch": "Authorization state mismatch. Please try again.",
|
||||
"errors.stateMissing": "Authorization state not found. Please try again.",
|
||||
"messages.authorizationFailed": "Authorization ran into an issue. Retry, or check if you finished signing in in your browser.",
|
||||
@@ -48,6 +50,8 @@
|
||||
"messages.handoffTimeout": "Authorization timed out. Finish it in your browser, then retry.",
|
||||
"messages.loading": "Starting authorization process...",
|
||||
"messages.success.cloudMcpInstall": "Authorization successful! You can now install the Cloud MCP skill.",
|
||||
"messages.success.submit": "Authorization successful! You can now publish your agent.",
|
||||
"messages.success.upload": "Authorization successful! You can now publish a new version.",
|
||||
"profileSetup.cancel": "Cancel",
|
||||
"profileSetup.confirmChangeUserId.cancel": "Cancel",
|
||||
"profileSetup.confirmChangeUserId.confirm": "Change User ID",
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
"activity.notes": "Notes",
|
||||
"analysis.action.button": "Request memory analysis",
|
||||
"analysis.modal.cancel": "Cancel",
|
||||
"analysis.modal.helper": "By default Lobe AI will analyze all unprocessed conversations. It's optional to select a date range to analyze.",
|
||||
"analysis.modal.helper": "By default Lobe AI will analyze all unprocessed chats. It's optional to select a date range to analyze.",
|
||||
"analysis.modal.rangePlaceholder": "No range selected; all conversations will be analyzed.",
|
||||
"analysis.modal.rangeSelected": "Analyzing conversations from {{start}} to {{end}}",
|
||||
"analysis.modal.rangeSelected": "Analyzing chats from {{start}} to {{end}}",
|
||||
"analysis.modal.submit": "Request memory analysis",
|
||||
"analysis.modal.title": "Analyze conversations to generate memories",
|
||||
"analysis.modal.title": "Analyze chats to generate memories",
|
||||
"analysis.range.all": "All conversations",
|
||||
"analysis.range.end": "Today",
|
||||
"analysis.range.start": "Beginning",
|
||||
"analysis.status.errorTitle": "Memory analysis request failed",
|
||||
"analysis.status.progress": "Processed {{completed}} / {{total}} conversations",
|
||||
"analysis.status.progressUnknown": "Processed {{completed}} conversations so far",
|
||||
"analysis.status.progress": "Processed {{completed}} / {{total}} topics",
|
||||
"analysis.status.progressUnknown": "Processed {{completed}} topics so far",
|
||||
"analysis.status.tip": "We are processing your conversations to build personal memories. This may take a few minutes.",
|
||||
"analysis.status.title": "Memory analysis in progress",
|
||||
"analysis.toast.deduped": "A memory request is already running, continuing progress…",
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"createNewAiProvider.createSuccess": "Creation successful",
|
||||
"createNewAiProvider.description.placeholder": "Provider description (optional)",
|
||||
"createNewAiProvider.description.title": "Provider Description",
|
||||
"createNewAiProvider.id.desc": "Unique identifier for the provider, which cannot be modified after creation",
|
||||
"createNewAiProvider.id.desc": "Unique identifier for the service provider, which cannot be modified after creation",
|
||||
"createNewAiProvider.id.duplicate": "Provider ID already exists",
|
||||
"createNewAiProvider.id.format": "Can only contain numbers, lowercase letters, hyphens (-), and underscores (_) ",
|
||||
"createNewAiProvider.id.placeholder": "Suggested all lowercase, e.g., openai, cannot be modified after creation",
|
||||
@@ -222,7 +222,6 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.effort.hint": "For Claude Opus 4.6; controls effort level (low/medium/high/max).",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "For Claude Opus 4.6; toggles adaptive thinking on or off.",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "For Claude, DeepSeek and other reasoning models; unlock deeper thinking.",
|
||||
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "For GLM-5.2; controls reasoning effort with High and Max levels.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "For GPT-5 series; controls reasoning intensity.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "For GPT-5.1 series; controls reasoning intensity.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "For GPT-5.2 Pro series; controls reasoning intensity.",
|
||||
@@ -256,8 +255,7 @@
|
||||
"providerModels.item.modelConfig.files.extra": "The current file upload implementation is just a hack solution, limited to self-experimentation. Please wait for complete file upload capabilities in future implementations.",
|
||||
"providerModels.item.modelConfig.files.title": "File Upload Support",
|
||||
"providerModels.item.modelConfig.functionCall.extra": "This configuration will only enable the model's ability to use tools, allowing for the addition of tool-type skills. However, whether the model can truly use the tools depends entirely on the model itself; please test for usability on your own.",
|
||||
"providerModels.item.modelConfig.functionCall.title": "Support for Tool Calling",
|
||||
"providerModels.item.modelConfig.id.duplicate": "A model with this ID already exists. Use a different model ID.",
|
||||
"providerModels.item.modelConfig.functionCall.title": "Support for Tool Usage",
|
||||
"providerModels.item.modelConfig.id.extra": "This cannot be modified after creation and will be used as the model ID when calling AI",
|
||||
"providerModels.item.modelConfig.id.placeholder": "Please enter the model ID, e.g., gpt-4o or claude-3.5-sonnet",
|
||||
"providerModels.item.modelConfig.id.title": "Model ID",
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
"handoff.desc.success": "An attempt has been made to open the desktop application. If it does not open automatically, please switch manually. You can close this browser window later.",
|
||||
"handoff.title.processing": "Authorization in progress...",
|
||||
"handoff.title.success": "Authorization completed",
|
||||
"login.button": "Confirm Sign In",
|
||||
"login.description": "The application {{clientName}} is requesting to use your account for sign-in",
|
||||
"login.title": "Sign in to {{clientName}}",
|
||||
"login.button": "Confirm Login",
|
||||
"login.description": "The application {{clientName}} is requesting to use your account for login",
|
||||
"login.title": "Login to {{clientName}}",
|
||||
"login.userWelcome": "Welcome back, ",
|
||||
"success.subTitle": "You have successfully authorized the application to access your account. You may now close this page.",
|
||||
"success.title": "Authorization Successful"
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"builtins.lobe-agent-management.apiName.deleteAgent": "Delete agent",
|
||||
"builtins.lobe-agent-management.apiName.duplicateAgent": "Duplicate agent",
|
||||
"builtins.lobe-agent-management.apiName.getAgentDetail": "Get agent detail",
|
||||
"builtins.lobe-agent-management.apiName.installPlugin": "Install Skill",
|
||||
"builtins.lobe-agent-management.apiName.installPlugin": "Install plugin",
|
||||
"builtins.lobe-agent-management.apiName.searchAgent": "Search agents",
|
||||
"builtins.lobe-agent-management.apiName.updateAgent": "Update agent",
|
||||
"builtins.lobe-agent-management.apiName.updatePrompt": "Update prompt",
|
||||
@@ -84,7 +84,7 @@
|
||||
"builtins.lobe-agent-management.inspector.createAgent.title": "Creating agent:",
|
||||
"builtins.lobe-agent-management.inspector.duplicateAgent.title": "Duplicating agent:",
|
||||
"builtins.lobe-agent-management.inspector.getAgentDetail.title": "Getting details:",
|
||||
"builtins.lobe-agent-management.inspector.installPlugin.title": "Installing Skill:",
|
||||
"builtins.lobe-agent-management.inspector.installPlugin.title": "Installing plugin:",
|
||||
"builtins.lobe-agent-management.inspector.searchAgent.all": "Search agents:",
|
||||
"builtins.lobe-agent-management.inspector.searchAgent.market": "Search market:",
|
||||
"builtins.lobe-agent-management.inspector.searchAgent.results": "{{count}} results",
|
||||
@@ -94,7 +94,7 @@
|
||||
"builtins.lobe-agent-management.render.duplicateAgent.newId": "New Agent ID",
|
||||
"builtins.lobe-agent-management.render.duplicateAgent.sourceId": "Source Agent ID",
|
||||
"builtins.lobe-agent-management.render.installPlugin.failed": "Installation failed",
|
||||
"builtins.lobe-agent-management.render.installPlugin.plugin": "Skill",
|
||||
"builtins.lobe-agent-management.render.installPlugin.plugin": "Plugin",
|
||||
"builtins.lobe-agent-management.render.installPlugin.success": "Installed successfully",
|
||||
"builtins.lobe-agent-management.title": "Agent Manager",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia": "Analyze visual media",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user