Compare commits

..

2 Commits

Author SHA1 Message Date
Arvin Xu 67d314b089 🐛 fix: seed message:list cache even when replaceMessages store-set is a no-op
Optimistic flows (optimisticUpdateMessageContent / optimisticDeleteMessage[s])
dispatch the mutation into dbMessagesMap first, then call
replaceMessages(server). When the server echo equals the already-applied
optimistic state, the isEqual early-return skipped the store-set AND the
write-through, leaving message:list at the pre-mutation snapshot — a later
remount could hydrate stale content / deleted rows.

Move the write-through ahead of the equality early-return so the cache is
seeded even on a store no-op. Streaming / fetch-sync guards stay inside
#writeThroughMessageCache, so per-token thrash is still avoided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:53:41 +08:00
Arvin Xu 22eddbc474 ️ perf: write message mutations through to the message:list SWR cache
Message mutations only touched the in-memory store, so the message:list
SWR/IndexedDB cache stayed stale until a network refetch. Because the
Conversation store is recreated on every topic/session switch and
re-hydrates from that cache, the stale cache is what forced a refetch on
every switch.

replaceMessages now seeds the message:list cache for the exact bucket via
mutate(matcher, messages, { revalidate: false }). Skips the
useFetchMessages onData sync path (SWR already holds it) and skips while
the context is streaming to avoid per-token IndexedDB thrash; the
agent_runtime_end snapshot still writes through since it clears the
running flag first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:48:48 +08:00
626 changed files with 8116 additions and 15347 deletions
@@ -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
+1 -36
View File
@@ -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**
-37
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.31",
"version": "0.0.29",
"type": "module",
"bin": {
"lh": "./dist/index.js",
+3 -117
View File
@@ -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', () => {
+7 -49
View File
@@ -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 });
-140
View File
@@ -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', () => {
+39 -167
View File
@@ -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
View File
@@ -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)}`,
);
});
}
-125
View File
@@ -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,
});
};
-6
View File
@@ -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);
}),
/**
+2 -36
View File
@@ -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
-161
View File
@@ -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;
+7 -84
View File
@@ -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',
});
}
};
+6 -11
View File
@@ -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
-2
View File
@@ -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,
+41 -12
View File
@@ -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,
}));
}),
/**
+8 -10
View File
@@ -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 };
+34 -11
View File
@@ -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) {
+4 -6
View File
@@ -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 () => {
+3 -10
View File
@@ -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;
+4 -32
View File
@@ -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', () => {
+18 -115
View File
@@ -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);
});
});
+21 -154
View File
@@ -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);
});
});
+124 -52
View File
@@ -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;
}
}
@@ -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
View File
@@ -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
View File
@@ -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,
];
+28 -50
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "وضع التركيز"
}
-26
View File
@@ -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": "الصفحات",
-10
View File
@@ -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": "اضغط \"/\" للوصول إلى الذكاء الاصطناعي والأوامر",
+2 -2
View File
@@ -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.",
+2 -8
View File
@@ -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": "متصل",
+1 -3
View File
@@ -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": "نظرة عامة على الإحالة",
-18
View File
@@ -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
View File
@@ -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": "Режим Зен"
}
-26
View File
@@ -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": "Страници",
-10
View File
@@ -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": "Натиснете \"/\" за ИИ и команди",
+2 -2
View File
@@ -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 мост.",
+2 -8
View File
@@ -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": "Свързано",
+1 -3
View File
@@ -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": "Обзор на поканите",
-18
View File
@@ -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
View File
@@ -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"
}
-26
View File
@@ -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",
-10
View File
@@ -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",
+2 -2
View File
@@ -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.",
+2 -8
View File
@@ -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",
+1 -3
View File
@@ -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",
-18
View File
@@ -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.",
+9 -9
View File
@@ -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",
+9 -9
View File
@@ -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",
+2 -2
View File
@@ -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
View File
@@ -20,13 +20,10 @@
"agentDefaultMessage": "Hi, Im **{{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, Im **{{name}}**. One sentence is enough—you're in control.",
"agentDefaultMessageWithoutEdit": "Hi, Im **{{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"
}
+3 -3
View File
@@ -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",
+1 -3
View File
@@ -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 -1
View File
@@ -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",
+3 -3
View File
@@ -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 hasnt 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 hasnt 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...",
+1 -4
View File
@@ -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",
+5 -5
View File
@@ -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
View File
@@ -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 havent synced yet. Theyll 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": "Youre 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 wont be saved until theyre done.",
"pageEditor.editedAt": "Last edited on {{time}}",
"pageEditor.editedBy": "Last edited by {{name}}",
"pageEditor.editorPlaceholder": "Press \"/\" for AI and commands.",
+3 -1
View File
@@ -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 -1
View File
@@ -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 -3
View File
@@ -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",
+5 -5
View File
@@ -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…",
+2 -4
View File
@@ -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",
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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