mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 22:26:05 +00:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04e847a56b | |||
| 908804ddce | |||
| 93a6f956d9 | |||
| 46bad6e617 | |||
| dcd1650167 | |||
| af51c3290f | |||
| dc3b325040 | |||
| 33d95777eb | |||
| dc5aa7c39e | |||
| 9d4bb09aa5 | |||
| ad4545539c | |||
| d3e95dc8f4 | |||
| 3d0ee23c2d | |||
| cd4de1cf96 | |||
| eb2978dcfd | |||
| 3f79eefcf7 | |||
| db19e28dea | |||
| 6cbfe4389d | |||
| d5ac9cf0c2 | |||
| a7fac87b02 | |||
| 85fe095ce5 | |||
| e92ab2acdd | |||
| f46cc508b5 | |||
| 0fd4fd6562 | |||
| 7b932a01d0 | |||
| 68ef8a1cc6 | |||
| 5862a3ead8 | |||
| 4c6e9bbf27 | |||
| acbf969cdc | |||
| a4a5cc93cb | |||
| 3d23ccd63f | |||
| 09ba6cd69b | |||
| 04b8d214b9 | |||
| 7624ce635e | |||
| dc8f9d79b4 | |||
| 95dc8e00b0 | |||
| 7f45a8d730 | |||
| ba2660c8c9 | |||
| 2fdebb9f4e | |||
| ebb3e53769 | |||
| 4595f86e41 | |||
| cdcd57970b | |||
| 85e2d3c1eb | |||
| a3faf7b1aa | |||
| 9401e6eeac | |||
| 5e2fdeb342 | |||
| 1d7fc18cdb | |||
| 2c66867f65 | |||
| 8718e1d33f | |||
| 7bc47071c4 | |||
| c66b1fc8fe | |||
| 46439bbd16 | |||
| 25387ada92 | |||
| da3412e202 | |||
| 7ea84a2695 | |||
| 211e8c1f54 | |||
| 0ef1309b68 | |||
| 73907480d7 | |||
| a38437c1da | |||
| de207a65c2 |
@@ -112,9 +112,14 @@ 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. 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`.
|
||||
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.
|
||||
|
||||
### Using the authenticated session
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ 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"
|
||||
@@ -481,8 +482,13 @@ PY
|
||||
|
||||
if [[ ! "$code" =~ ^[23] ]]; then
|
||||
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
|
||||
note "make sure the seed user exists:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
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
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -517,6 +523,7 @@ cmd_web_verify() {
|
||||
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
|
||||
return 1
|
||||
fi
|
||||
agent-browser --session "$SESSION" wait --load networkidle > /dev/null 2>&1 || true
|
||||
local url
|
||||
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
|
||||
if [[ -z "$url" ]]; then
|
||||
|
||||
@@ -46,6 +46,13 @@ 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)_
|
||||
@@ -96,6 +103,33 @@ 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
|
||||
@@ -250,10 +284,11 @@ 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.
|
||||
- [ ] 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.
|
||||
- [ ] 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**
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const prComment = async ({ github, context, releaseUrl, artifactsUrl, version, t
|
||||
const COMMENT_IDENTIFIER = '<!-- DESKTOP-BUILD-COMMENT -->';
|
||||
|
||||
/**
|
||||
* 生成评论内容
|
||||
* Generate comment body content
|
||||
*/
|
||||
const generateCommentBody = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -77,6 +80,40 @@ describe('lh file - E2E', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── upload (local file) ───────────────────────────────
|
||||
|
||||
describe('upload', () => {
|
||||
it('should upload a local file passed as a positional argument', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello from lh e2e upload');
|
||||
|
||||
try {
|
||||
const result = runJson<{ id: string }>(`file upload ${tmpFile} --json id`);
|
||||
expect(result).toHaveProperty('id');
|
||||
if (result.id) run(`file delete ${result.id} --yes`);
|
||||
} finally {
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should upload a local file passed via --file', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-f-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello from lh e2e --file upload');
|
||||
|
||||
try {
|
||||
const result = runJson<{ id: string }>(`file upload --file ${tmpFile} --json id`);
|
||||
expect(result).toHaveProperty('id');
|
||||
if (result.id) run(`file delete ${result.id} --yes`);
|
||||
} finally {
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should error when the local file does not exist', () => {
|
||||
expect(() => run('file upload -f /no/such/lh-file.txt')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── recent ────────────────────────────────────────────
|
||||
|
||||
describe('recent', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.29" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.32" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.29",
|
||||
"version": "0.0.31",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -17,6 +21,9 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
removeFiles: { mutate: vi.fn() },
|
||||
updateFile: { mutate: vi.fn() },
|
||||
},
|
||||
upload: {
|
||||
createS3PreSignedUrl: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,9 +45,11 @@ describe('file command', () => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const method of Object.values(mockTrpcClient.file)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -205,6 +214,111 @@ describe('file command', () => {
|
||||
expect(mockTrpcClient.file.createFile.mutate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
|
||||
});
|
||||
|
||||
it('should upload a local file passed as a positional argument', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-${process.pid}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello world');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
|
||||
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({
|
||||
id: 'f-local',
|
||||
url: 'files/x.txt',
|
||||
});
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
|
||||
|
||||
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://s3/presigned',
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
);
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileType: 'text/plain',
|
||||
name: path.basename(tmpFile),
|
||||
url: expect.stringContaining('.txt'),
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('File created'));
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should upload a local file passed via --file', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-f-${process.pid}.json`);
|
||||
fs.writeFileSync(tmpFile, '{}');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
|
||||
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-json' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', '--file', tmpFile]);
|
||||
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileType: 'application/json' }),
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should skip the S3 upload when the local file hash already exists', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-dedup-${process.pid}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'dedup me');
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({
|
||||
isExist: true,
|
||||
url: 'files/2024-01-01/existing.txt',
|
||||
});
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-dedup' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
|
||||
|
||||
// No pre-sign and no S3 PUT should happen
|
||||
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
// The record reuses the existing url
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: 'files/2024-01-01/existing.txt' }),
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should error when local file does not exist', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', '-f', '/no/such/file.txt']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('File not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should error when no source is provided', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Provide a local file path'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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');
|
||||
@@ -113,18 +114,20 @@ export function registerFileCommand(program: Command) {
|
||||
// ── upload ───────────────────────────────────────────
|
||||
|
||||
file
|
||||
.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')
|
||||
.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)')
|
||||
.option('--parent-id <id>', 'Parent folder ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
url: string,
|
||||
source: string | undefined,
|
||||
options: {
|
||||
file?: string;
|
||||
hash?: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
@@ -133,8 +136,47 @@ export function registerFileCommand(program: Command) {
|
||||
type?: string;
|
||||
},
|
||||
) => {
|
||||
const isUrl = (value: string) =>
|
||||
value.startsWith('http://') || value.startsWith('https://');
|
||||
|
||||
// Resolve the local file path: explicit --file, or a positional that is
|
||||
// not a URL (e.g. `lh file upload ./games_list.txt`).
|
||||
const localPath = options.file ?? (source && !isUrl(source) ? source : undefined);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// ── Local file upload ──
|
||||
if (localPath) {
|
||||
let result;
|
||||
try {
|
||||
result = await uploadLocalFile(client, localPath, { parentId: options.parentId });
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} File created: ${pc.bold(r.id || '')}`);
|
||||
if (r.url) console.log(` URL: ${pc.dim(r.url)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── URL upload ──
|
||||
if (!source) {
|
||||
log.error('Provide a local file path, --file <path>, or a URL to upload.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = source;
|
||||
|
||||
// Check hash first if provided
|
||||
if (options.hash) {
|
||||
const check = await client.file.checkFileHash.mutate({ hash: options.hash });
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -6,6 +10,9 @@ import { registerGenerateCommand } from './generate';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
asr: {
|
||||
transcribe: { mutate: vi.fn() },
|
||||
},
|
||||
generation: {
|
||||
deleteGeneration: { mutate: vi.fn() },
|
||||
getGenerationStatus: { query: vi.fn() },
|
||||
@@ -35,6 +42,15 @@ 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) => {
|
||||
@@ -369,6 +385,130 @@ describe('generate command', () => {
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should upload large local audio and transcribe by fileId', async () => {
|
||||
// Real >3MB temp file so existsSync/statSync (unmocked) see it as large.
|
||||
const bigPath = path.join(os.tmpdir(), `lh-asr-test-${process.pid}-${Date.now()}.mp3`);
|
||||
await fsWriteFile(bigPath, Buffer.alloc(4 * 1024 * 1024));
|
||||
mockUploadLocalFile.mockResolvedValue({ id: 'file_999' });
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'big result' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'generate', 'asr', bigPath]);
|
||||
|
||||
expect(mockUploadLocalFile).toHaveBeenCalledWith(expect.anything(), bigPath);
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileId: 'file_999', model: 'whisper-1', provider: 'openai' }),
|
||||
);
|
||||
// never inlines bytes for the large file
|
||||
expect(mockTrpcClient.asr.transcribe.mutate.mock.calls[0][0]).not.toHaveProperty(
|
||||
'audioBase64',
|
||||
);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('big result');
|
||||
} finally {
|
||||
await fsRm(bigPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should download and transcribe an audio URL', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers(),
|
||||
ok: true,
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'hello world' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/audio/sample.mp3',
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://example.com/audio/sample.mp3');
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audioBase64: Buffer.from('audio-bytes').toString('base64'),
|
||||
fileName: 'sample.mp3',
|
||||
model: 'whisper-1',
|
||||
provider: 'openai',
|
||||
}),
|
||||
);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('hello world');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should derive an extension and mime type from Content-Type when the URL has none', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers({ 'content-type': 'audio/mpeg; charset=binary' }),
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'generate', 'asr', 'https://example.com/download']);
|
||||
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: 'download.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer the filename from Content-Disposition', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers({
|
||||
'content-disposition': 'attachment; filename="recording.wav"',
|
||||
}),
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/files/abc123?sig=xyz',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileName: 'recording.wav' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit when audio URL download fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }),
|
||||
);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/missing.mp3',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to download audio'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getAuthInfo } from '../../api/http';
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
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)')
|
||||
.description(
|
||||
'Convert speech to text (automatic speech recognition). Accepts a local path or a URL',
|
||||
)
|
||||
.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(
|
||||
@@ -20,58 +31,175 @@ export function registerAsrCommand(parent: Command) {
|
||||
json?: boolean;
|
||||
language?: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
},
|
||||
) => {
|
||||
if (!existsSync(audioFile)) {
|
||||
const isUrl = audioFile.startsWith('http://') || audioFile.startsWith('https://');
|
||||
|
||||
if (!isUrl && !existsSync(audioFile)) {
|
||||
log.error(`File not found: ${audioFile}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
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}`);
|
||||
// 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));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
try {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
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');
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
// 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;
|
||||
}
|
||||
return new Blob(chunks);
|
||||
}
|
||||
|
||||
+11
-72
@@ -1,14 +1,12 @@
|
||||
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 '';
|
||||
@@ -324,81 +322,22 @@ 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 resolved = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
log.error(`File not found: ${resolved}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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}`);
|
||||
let result;
|
||||
try {
|
||||
result = await uploadLocalFile(client, filePath, {
|
||||
knowledgeBaseId,
|
||||
parentId: options.parent,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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(fileName)} → ${pc.bold((result as any).id)}`,
|
||||
`${pc.green('✓')} Uploaded ${pc.bold(path.basename(filePath))} → ${pc.bold((result as any).id)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,6 +100,19 @@ describe('model command', () => {
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
|
||||
});
|
||||
|
||||
it('should normalize the legacy `stt` type to `asr` when filtering', async () => {
|
||||
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
|
||||
{ displayName: 'Whisper', enabled: true, id: 'whisper-1', type: 'asr' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'model', 'list', 'openai', '--type', 'stt']);
|
||||
|
||||
expect(mockTrpcClient.aiModel.getAiProviderModelList.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'openai', type: 'asr' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
@@ -157,6 +170,28 @@ describe('model command', () => {
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created model'));
|
||||
});
|
||||
|
||||
it('should normalize the legacy `stt` type to `asr`', async () => {
|
||||
mockTrpcClient.aiModel.createAiModel.mutate.mockResolvedValue('whisper-1');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'create',
|
||||
'--id',
|
||||
'whisper-1',
|
||||
'--provider',
|
||||
'openai',
|
||||
'--type',
|
||||
'stt',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.createAiModel.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'whisper-1', providerId: 'openai', type: 'asr' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
@@ -184,6 +219,29 @@ describe('model command', () => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated model'));
|
||||
});
|
||||
|
||||
it('should normalize the legacy `stt` type to `asr`', async () => {
|
||||
mockTrpcClient.aiModel.updateAiModel.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'edit',
|
||||
'whisper-1',
|
||||
'--provider',
|
||||
'openai',
|
||||
'--type',
|
||||
'stt',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.updateAiModel.mutate).toHaveBeenCalledWith({
|
||||
id: 'whisper-1',
|
||||
providerId: 'openai',
|
||||
value: expect.objectContaining({ type: 'asr' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should error when no changes specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'model', 'edit', 'gpt-4', '--provider', 'openai']);
|
||||
|
||||
@@ -7,6 +7,11 @@ import { log } from '../utils/logger';
|
||||
|
||||
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
|
||||
|
||||
// The model type `stt` was renamed to the standard `asr`. Accept the legacy
|
||||
// alias on CLI input and forward/compare `asr`, so existing scripts and muscle
|
||||
// memory keep working against the new router schema.
|
||||
const normalizeModelType = (type: string): string => (type === 'stt' ? 'asr' : type);
|
||||
|
||||
export function registerModelCommand(program: Command) {
|
||||
const model = program.command('model').description('Manage AI models');
|
||||
|
||||
@@ -19,7 +24,7 @@ export function registerModelCommand(program: Command) {
|
||||
.option('--enabled', 'Only show enabled models')
|
||||
.option(
|
||||
'--type <type>',
|
||||
'Filter by model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
|
||||
'Filter by model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
|
||||
)
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
@@ -29,18 +34,20 @@ export function registerModelCommand(program: Command) {
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const typeFilter = options.type ? normalizeModelType(options.type) : undefined;
|
||||
|
||||
const input: Record<string, any> = { id: providerId };
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.enabled) input.enabled = true;
|
||||
if (options.type) input.type = options.type;
|
||||
if (typeFilter) input.type = typeFilter;
|
||||
|
||||
const result = await client.aiModel.getAiProviderModelList.query(input as any);
|
||||
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
|
||||
isVisibleModel,
|
||||
);
|
||||
|
||||
if (options.type) {
|
||||
items = items.filter((m: any) => m.type === options.type);
|
||||
if (typeFilter) {
|
||||
items = items.filter((m: any) => m.type === typeFilter);
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
@@ -106,7 +113,7 @@ export function registerModelCommand(program: Command) {
|
||||
.option('--display-name <name>', 'Display name')
|
||||
.option(
|
||||
'--type <type>',
|
||||
'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
|
||||
'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
|
||||
'chat',
|
||||
)
|
||||
.action(
|
||||
@@ -116,7 +123,7 @@ export function registerModelCommand(program: Command) {
|
||||
const input: Record<string, any> = {
|
||||
id: options.id,
|
||||
providerId: options.provider,
|
||||
type: options.type || 'chat',
|
||||
type: normalizeModelType(options.type || 'chat'),
|
||||
};
|
||||
if (options.displayName) input.displayName = options.displayName;
|
||||
|
||||
@@ -132,7 +139,7 @@ export function registerModelCommand(program: Command) {
|
||||
.description('Update model info')
|
||||
.requiredOption('--provider <providerId>', 'Provider ID')
|
||||
.option('--display-name <name>', 'Display name')
|
||||
.option('--type <type>', 'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)')
|
||||
.option('--type <type>', 'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)')
|
||||
.action(
|
||||
async (id: string, options: { displayName?: string; provider: string; type?: string }) => {
|
||||
if (!options.displayName && !options.type) {
|
||||
@@ -144,7 +151,7 @@ export function registerModelCommand(program: Command) {
|
||||
|
||||
const value: Record<string, any> = {};
|
||||
if (options.displayName) value.displayName = options.displayName;
|
||||
if (options.type) value.type = options.type;
|
||||
if (options.type) value.type = normalizeModelType(options.type);
|
||||
|
||||
await client.aiModel.updateAiModel.mutate({
|
||||
id,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
// apps/desktop/src/main/menus/impl/BaseMenuPlatform.ts
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import type { BaseWindow, MenuItemConstructorOptions } from 'electron';
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
@@ -34,6 +34,26 @@ export abstract class BaseMenuPlatform {
|
||||
];
|
||||
}
|
||||
|
||||
protected closeFocusedTabOrWindow(targetWindow?: BaseWindow | null): void {
|
||||
const focused =
|
||||
targetWindow && 'webContents' in targetWindow
|
||||
? (targetWindow as BrowserWindow)
|
||||
: BrowserWindow.getFocusedWindow();
|
||||
if (!focused) return;
|
||||
|
||||
if (focused.webContents.isDevToolsOpened()) {
|
||||
focused.webContents.closeDevTools();
|
||||
return;
|
||||
}
|
||||
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
if (focused === mainWindow.browserWindow) {
|
||||
mainWindow.broadcast('closeCurrentTabOrWindow');
|
||||
} else {
|
||||
focused.close();
|
||||
}
|
||||
}
|
||||
|
||||
private buildZoomMenuItemOption(
|
||||
action: ZoomAction,
|
||||
label: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, dialog, Menu, shell } from 'electron';
|
||||
import { app, BrowserWindow, dialog, Menu, shell } from 'electron';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
@@ -7,6 +7,9 @@ import { LinuxMenu } from './linux';
|
||||
|
||||
// Mock Electron modules
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: class BrowserWindow {
|
||||
static getFocusedWindow = vi.fn();
|
||||
},
|
||||
Menu: {
|
||||
buildFromTemplate: vi.fn((template) => ({ template })),
|
||||
setApplicationMenu: vi.fn(),
|
||||
@@ -339,6 +342,100 @@ describe('LinuxMenu', () => {
|
||||
expect(closeItem.role).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should close open DevTools before delegating CmdOrCtrl+W to renderer window logic', () => {
|
||||
linuxMenu.buildAndSetAppMenu();
|
||||
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
const fileMenu = template.find((item: any) => item.label === 'File');
|
||||
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
|
||||
const focusedWindow = {
|
||||
close: vi.fn(),
|
||||
webContents: {
|
||||
closeDevTools: vi.fn(),
|
||||
isDevToolsOpened: vi.fn(() => true),
|
||||
},
|
||||
};
|
||||
|
||||
closeItem.click(undefined, focusedWindow);
|
||||
|
||||
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
|
||||
expect(focusedWindow.close).not.toHaveBeenCalled();
|
||||
expect(mockApp.browserManager.getMainWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should broadcast tab close when CmdOrCtrl+W targets the main window', () => {
|
||||
linuxMenu.buildAndSetAppMenu();
|
||||
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
const fileMenu = template.find((item: any) => item.label === 'File');
|
||||
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
|
||||
const mainBrowserWindow = {
|
||||
close: vi.fn(),
|
||||
webContents: {
|
||||
closeDevTools: vi.fn(),
|
||||
isDevToolsOpened: vi.fn(() => false),
|
||||
},
|
||||
};
|
||||
const broadcast = vi.fn();
|
||||
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
|
||||
broadcast,
|
||||
browserWindow: mainBrowserWindow,
|
||||
} as any);
|
||||
|
||||
closeItem.click(undefined, mainBrowserWindow);
|
||||
|
||||
expect(broadcast).toHaveBeenCalledWith('closeCurrentTabOrWindow');
|
||||
expect(mainBrowserWindow.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close non-main windows when CmdOrCtrl+W has no DevTools panel to close', () => {
|
||||
linuxMenu.buildAndSetAppMenu();
|
||||
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
const fileMenu = template.find((item: any) => item.label === 'File');
|
||||
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
|
||||
const mainBrowserWindow = {
|
||||
webContents: {
|
||||
isDevToolsOpened: vi.fn(() => false),
|
||||
},
|
||||
};
|
||||
const focusedWindow = {
|
||||
close: vi.fn(),
|
||||
webContents: {
|
||||
closeDevTools: vi.fn(),
|
||||
isDevToolsOpened: vi.fn(() => false),
|
||||
},
|
||||
};
|
||||
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
|
||||
broadcast: vi.fn(),
|
||||
browserWindow: mainBrowserWindow,
|
||||
} as any);
|
||||
|
||||
closeItem.click(undefined, focusedWindow);
|
||||
|
||||
expect(focusedWindow.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use the focused window when Electron does not pass a menu target window', () => {
|
||||
linuxMenu.buildAndSetAppMenu();
|
||||
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
const fileMenu = template.find((item: any) => item.label === 'File');
|
||||
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
|
||||
const focusedWindow = {
|
||||
close: vi.fn(),
|
||||
webContents: {
|
||||
closeDevTools: vi.fn(),
|
||||
isDevToolsOpened: vi.fn(() => true),
|
||||
},
|
||||
};
|
||||
vi.mocked(BrowserWindow.getFocusedWindow).mockReturnValue(focusedWindow as any);
|
||||
|
||||
closeItem.click();
|
||||
|
||||
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use role for minimize (accelerator handled by Electron)', () => {
|
||||
linuxMenu.buildAndSetAppMenu();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
|
||||
import { app, clipboard, dialog, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
|
||||
@@ -122,16 +122,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
click: () => {
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (!focused) return;
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
if (focused === mainWindow.browserWindow) {
|
||||
mainWindow.broadcast('closeCurrentTabOrWindow');
|
||||
} else {
|
||||
focused.close();
|
||||
}
|
||||
},
|
||||
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
|
||||
label: t('window.close'),
|
||||
},
|
||||
{ label: t('window.minimize'), role: 'minimize' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
|
||||
import { app, clipboard, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
|
||||
@@ -164,16 +164,7 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
click: () => {
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (!focused) return;
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
if (focused === mainWindow.browserWindow) {
|
||||
mainWindow.broadcast('closeCurrentTabOrWindow');
|
||||
} else {
|
||||
focused.close();
|
||||
}
|
||||
},
|
||||
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
|
||||
label: t('window.close'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
|
||||
import { app, clipboard, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
|
||||
@@ -185,16 +185,7 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ label: t('window.minimize'), role: 'minimize' },
|
||||
{
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
click: () => {
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (!focused) return;
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
if (focused === mainWindow.browserWindow) {
|
||||
mainWindow.broadcast('closeCurrentTabOrWindow');
|
||||
} else {
|
||||
focused.close();
|
||||
}
|
||||
},
|
||||
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
|
||||
label: t('window.close'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1249,6 +1249,12 @@ 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,7 +28,11 @@ import { ToolsEngine } from '@lobechat/context-engine';
|
||||
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { executionTargetToRuntimeMode, resolveExecutionTarget } from '@/helpers/executionTarget';
|
||||
import {
|
||||
executionTargetToRuntimeMode,
|
||||
resolveExecutionTarget,
|
||||
resolveToolMode,
|
||||
} from '@/helpers/executionTarget';
|
||||
import {
|
||||
buildAllowedBuiltinTools,
|
||||
DEVICE_TOOL_IDENTIFIERS,
|
||||
@@ -170,9 +174,7 @@ 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: 'agent' | 'chat' | 'custom' =
|
||||
agentConfig.chatConfig?.toolMode ??
|
||||
(agentConfig.chatConfig?.enableAgentMode === false ? 'chat' : 'agent');
|
||||
const toolMode = resolveToolMode(agentConfig.chatConfig ?? undefined);
|
||||
const isChatMode = toolMode === 'chat';
|
||||
const isCustomMode = toolMode === 'custom';
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ export const serverMessagesEngine = async ({
|
||||
const engine = new MessagesEngine({
|
||||
// Capability injection
|
||||
capabilities: {
|
||||
isCanUseAudio: capabilities?.isCanUseAudio,
|
||||
isCanUseFC: capabilities?.isCanUseFC,
|
||||
isCanUseVideo: capabilities?.isCanUseVideo,
|
||||
isCanUseVision: capabilities?.isCanUseVision,
|
||||
|
||||
@@ -23,6 +23,8 @@ 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 */
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type * as AgentDocumentModels from '@/database/models/agentDocuments';
|
||||
import { createCallerFactory } from '@/libs/trpc/lambda';
|
||||
import { createContextInner } from '@/libs/trpc/lambda/context';
|
||||
import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
|
||||
import { agentDocumentRouter } from '../agentDocument';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
associate: vi.fn(),
|
||||
createTopic: vi.fn(),
|
||||
findByAgentAndDocumentTrigger: vi.fn(),
|
||||
findRowByDocumentId: vi.fn(),
|
||||
getServerDB: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: mocks.getServerDB,
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/agentDocuments', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentDocumentModels>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
AgentDocumentModel: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/database/models/topic', () => ({
|
||||
TopicModel: vi.fn().mockImplementation(() => ({
|
||||
create: mocks.createTopic,
|
||||
findByAgentAndDocumentTrigger: mocks.findByAgentAndDocumentTrigger,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/topicDocument', () => ({
|
||||
TopicDocumentModel: vi.fn().mockImplementation(() => ({
|
||||
associate: mocks.associate,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/agentDocuments', () => ({
|
||||
AgentDocumentsService: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/agentDocumentVfs', () => ({
|
||||
AgentDocumentVfsService: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/agentDocuments/toolOutcome', () => ({
|
||||
emitAgentDocumentToolOutcomeSafely: vi.fn(),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(agentDocumentRouter);
|
||||
|
||||
describe('agentDocumentRouter.getOrCreateChatTopic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getServerDB.mockResolvedValue({ kind: 'server-db' });
|
||||
|
||||
vi.mocked(AgentDocumentsService).mockImplementation(
|
||||
() =>
|
||||
({ findRowByDocumentId: mocks.findRowByDocumentId }) as unknown as AgentDocumentsService,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the existing topic when a doc-anchored row is already linked', async () => {
|
||||
mocks.findByAgentAndDocumentTrigger.mockResolvedValue({ id: 'topic-existing' });
|
||||
|
||||
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
|
||||
const result = await caller.getOrCreateChatTopic({
|
||||
agentId: 'agent-1',
|
||||
documentId: 'docs_abc',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ topicId: 'topic-existing' });
|
||||
expect(mocks.findByAgentAndDocumentTrigger).toHaveBeenCalledWith({
|
||||
agentId: 'agent-1',
|
||||
documentId: 'docs_abc',
|
||||
trigger: 'document',
|
||||
});
|
||||
expect(mocks.createTopic).not.toHaveBeenCalled();
|
||||
expect(mocks.associate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a new doc-anchored topic and associates it when none exists', async () => {
|
||||
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
|
||||
mocks.findRowByDocumentId.mockResolvedValue({
|
||||
filename: 'spec.md',
|
||||
id: 'agent-document-1',
|
||||
title: 'Spec',
|
||||
});
|
||||
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
|
||||
|
||||
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
|
||||
const result = await caller.getOrCreateChatTopic({
|
||||
agentId: 'agent-1',
|
||||
documentId: 'docs_abc',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ topicId: 'topic-new' });
|
||||
expect(mocks.createTopic).toHaveBeenCalledWith({
|
||||
agentId: 'agent-1',
|
||||
title: 'Spec',
|
||||
trigger: 'document',
|
||||
});
|
||||
expect(mocks.associate).toHaveBeenCalledWith({
|
||||
documentId: 'docs_abc',
|
||||
topicId: 'topic-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the filename when the document has no title', async () => {
|
||||
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
|
||||
mocks.findRowByDocumentId.mockResolvedValue({
|
||||
filename: 'fallback.md',
|
||||
id: 'agent-document-1',
|
||||
title: undefined,
|
||||
});
|
||||
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
|
||||
|
||||
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
|
||||
await caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_abc' });
|
||||
|
||||
expect(mocks.createTopic).toHaveBeenCalledWith({
|
||||
agentId: 'agent-1',
|
||||
title: 'fallback.md',
|
||||
trigger: 'document',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NOT_FOUND when the document is missing or not owned by the agent', async () => {
|
||||
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
|
||||
mocks.findRowByDocumentId.mockResolvedValue(undefined);
|
||||
|
||||
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
|
||||
await expect(
|
||||
caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_missing' }),
|
||||
).rejects.toThrow(/Document not found/);
|
||||
expect(mocks.createTopic).not.toHaveBeenCalled();
|
||||
expect(mocks.associate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,10 +29,12 @@ 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,
|
||||
);
|
||||
|
||||
@@ -44,12 +46,68 @@ 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',
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ const mockFindById = vi.fn();
|
||||
|
||||
const mockCountTopicsForMemoryExtractor = vi.fn();
|
||||
const mockDeleteAll = vi.fn();
|
||||
const mockDeletePersona = vi.fn();
|
||||
const { mockTriggerProcessUsers } = vi.hoisted(() => ({
|
||||
mockTriggerProcessUsers: vi.fn(),
|
||||
}));
|
||||
@@ -43,6 +44,12 @@ vi.mock('@/database/models/userMemory', () => ({
|
||||
UserMemoryPreferenceModel: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/userMemory/persona', () => ({
|
||||
UserPersonaModel: vi.fn(() => ({
|
||||
deletePersona: mockDeletePersona,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/envs/app', () => ({
|
||||
appEnv: {
|
||||
APP_URL: 'https://example.com',
|
||||
@@ -301,11 +308,13 @@ describe('userMemoryRouter.deleteAll', () => {
|
||||
|
||||
it('purges all user memories through the aggregate model', async () => {
|
||||
mockDeleteAll.mockResolvedValue(undefined);
|
||||
mockDeletePersona.mockResolvedValue(undefined);
|
||||
|
||||
const caller = createCaller();
|
||||
const result = await caller.deleteAll();
|
||||
|
||||
expect(mockDeleteAll).toHaveBeenCalledOnce();
|
||||
expect(mockDeletePersona).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { z } from 'zod';
|
||||
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { TopicTrigger } from '@/const/topic';
|
||||
import { AgentDocumentModel } from '@/database/models/agentDocuments';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import { TopicDocumentModel } from '@/database/models/topicDocument';
|
||||
@@ -254,6 +255,56 @@ export const agentDocumentRouter = router({
|
||||
return ctx.agentDocumentService.getDocument(input.agentId, input.filename);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Return the chat topic that anchors the doc-scoped conversation for this
|
||||
* `(documentId, agentId)` pair, creating it idempotently on the first call.
|
||||
*
|
||||
* Topics are marked with `trigger='document'` so they stay out of the main
|
||||
* sidebar history (`MAIN_SIDEBAR_EXCLUDE_TRIGGERS` already excludes them).
|
||||
* The mapping is persisted through `topic_documents`, so subsequent calls
|
||||
* resolve the same topic id.
|
||||
*/
|
||||
getOrCreateChatTopic: agentDocumentProcedure
|
||||
.input(
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
documentId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.topicModel.findByAgentAndDocumentTrigger({
|
||||
agentId: input.agentId,
|
||||
documentId: input.documentId,
|
||||
trigger: TopicTrigger.Document,
|
||||
});
|
||||
if (existing) return { topicId: existing.id };
|
||||
|
||||
const document = await ctx.agentDocumentService.findRowByDocumentId(
|
||||
input.agentId,
|
||||
input.documentId,
|
||||
);
|
||||
if (!document) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Document not found for agentId=${input.agentId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const title = document.title || document.filename || 'Document chat';
|
||||
const topic = await ctx.topicModel.create({
|
||||
agentId: input.agentId,
|
||||
title,
|
||||
trigger: TopicTrigger.Document,
|
||||
});
|
||||
|
||||
await ctx.topicDocumentModel.associate({
|
||||
documentId: input.documentId,
|
||||
topicId: topic.id,
|
||||
});
|
||||
|
||||
return { topicId: topic.id };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create or update a document
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { type AiProviderModelListItem } from 'model-bank';
|
||||
import {
|
||||
AiModelTypeSchema,
|
||||
@@ -18,6 +19,30 @@ 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;
|
||||
@@ -82,9 +107,18 @@ export const aiModelRouter = router({
|
||||
.use(withScopedPermission('ai_model:create'))
|
||||
.input(CreateAiModelSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const data = await ctx.aiModelModel.create(input);
|
||||
const existingModel = await ctx.aiModelModel.findByIdAndProvider(input.id, input.providerId);
|
||||
if (existingModel) throwDuplicateAiModelError(input.id);
|
||||
|
||||
return data?.id;
|
||||
try {
|
||||
const data = await ctx.aiModelModel.create(input);
|
||||
|
||||
return data?.id;
|
||||
} catch (error) {
|
||||
if (isDuplicateAiModelError(error)) throwDuplicateAiModelError(input.id);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
getAiModelById: aiModelProcedure
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { FileService } from '@/server/services/file';
|
||||
|
||||
const asrProcedure = wsCompatProcedure.use(serverDatabase);
|
||||
|
||||
// Inline base64 is only for short clips. The whole request must fit inside the
|
||||
// platform body limit (≈4.5MB on serverless deploys) and base64 inflates bytes
|
||||
// by ~4/3, so cap the decoded audio well under that — anything larger should be
|
||||
// uploaded and passed as `fileId`.
|
||||
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
|
||||
// base64 length ≈ ceil(bytes / 3) * 4; validating the string length lets us
|
||||
// reject oversized payloads before allocating/decoding them.
|
||||
const MAX_INLINE_AUDIO_BASE64_CHARS = Math.ceil(MAX_INLINE_AUDIO_BYTES / 3) * 4;
|
||||
|
||||
interface ResolvedAudio {
|
||||
bytes: Uint8Array;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export const asrRouter = router({
|
||||
/**
|
||||
* Automatic Speech Recognition (speech-to-text).
|
||||
*
|
||||
* Accepts the audio either as an already-uploaded `fileId` (preferred — the
|
||||
* server streams the bytes from storage, nothing large travels over tRPC) or
|
||||
* inline as base64 for short clips (capped at `MAX_INLINE_AUDIO_BYTES`;
|
||||
* larger payloads are rejected with guidance to upload and pass `fileId`).
|
||||
*
|
||||
* Note on base64: tRPC here uses an `httpLink` + superjson (JSON only), which
|
||||
* has no binary representation for a `Buffer`/`Uint8Array` — a raw buffer would
|
||||
* serialize to a per-byte JSON object, far worse than base64. So inline bytes
|
||||
* stay base64; use `fileId` to avoid inlining entirely.
|
||||
*
|
||||
* Transcription is a single request/response (not streamed), so a mutation is
|
||||
* the right shape.
|
||||
*/
|
||||
transcribe: asrProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
/** Base64-encoded audio bytes (short clips only). Mutually exclusive with `fileId`. */
|
||||
audioBase64: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MAX_INLINE_AUDIO_BASE64_CHARS, {
|
||||
message: `Inline audio is limited to ${MAX_INLINE_AUDIO_BYTES / 1024 / 1024}MB. Upload the file and pass \`fileId\` instead.`,
|
||||
})
|
||||
.optional(),
|
||||
/** Already-uploaded audio file id. Mutually exclusive with `audioBase64`. */
|
||||
fileId: z.string().min(1).optional(),
|
||||
/** Original file name (base64 path); its extension helps format detection. */
|
||||
fileName: z.string().optional(),
|
||||
/** ISO-639-1 language code (e.g. `en`, `zh`). */
|
||||
language: z.string().optional(),
|
||||
/** Audio mime type (base64 path, e.g. `audio/mp4`). */
|
||||
mimeType: z.string().optional(),
|
||||
model: z.string().min(1),
|
||||
/** Optional text to guide the model's style. */
|
||||
prompt: z.string().optional(),
|
||||
provider: z.string().default('openai'),
|
||||
responseFormat: z.enum(['json', 'srt', 'text', 'verbose_json', 'vtt']).optional(),
|
||||
})
|
||||
.refine((d) => Boolean(d.fileId) !== Boolean(d.audioBase64), {
|
||||
message: 'Provide exactly one of `fileId` or `audioBase64`.',
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }): Promise<{ text: string }> => {
|
||||
const workspaceId = ctx.workspaceId ?? undefined;
|
||||
|
||||
const { bytes, fileName, mimeType } = await resolveAudio(ctx, input, workspaceId);
|
||||
|
||||
// Resolve the user's provider config (key + baseURL) from the database,
|
||||
// falling back to server env keys, exactly like chat/embeddings do.
|
||||
const runtime = await initModelRuntimeFromDB(
|
||||
ctx.serverDB,
|
||||
ctx.userId,
|
||||
input.provider,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// `Uint8Array` is a valid BlobPart at runtime; the cast sidesteps the
|
||||
// `Uint8Array<ArrayBufferLike>` vs BlobPart generic mismatch in lib.dom.
|
||||
const file = new File([bytes as BlobPart], fileName, {
|
||||
type: mimeType || 'application/octet-stream',
|
||||
});
|
||||
|
||||
const result = await runtime.transcribe(
|
||||
{
|
||||
file,
|
||||
fileName,
|
||||
language: input.language,
|
||||
model: input.model,
|
||||
prompt: input.prompt,
|
||||
responseFormat: input.responseFormat,
|
||||
},
|
||||
{ user: ctx.userId },
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_IMPLEMENTED',
|
||||
message: `Provider "${input.provider}" does not support ASR.`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Turn the request into raw audio bytes + metadata, from either a stored file
|
||||
* (downloaded from S3, ownership enforced by the userId-scoped FileModel) or the
|
||||
* inline base64 payload.
|
||||
*/
|
||||
async function resolveAudio(
|
||||
ctx: { serverDB: LobeChatDatabase; userId: string },
|
||||
input: { audioBase64?: string; fileId?: string; fileName?: string; mimeType?: string },
|
||||
workspaceId?: string,
|
||||
): Promise<ResolvedAudio> {
|
||||
if (input.fileId) {
|
||||
const fileModel = new FileModel(ctx.serverDB, ctx.userId, workspaceId);
|
||||
const fileItem = await fileModel.findById(input.fileId);
|
||||
|
||||
if (!fileItem) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `File "${input.fileId}" not found.` });
|
||||
}
|
||||
|
||||
const fileService = new FileService(ctx.serverDB, ctx.userId, workspaceId);
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = await fileService.getFileByteArray(fileItem.url);
|
||||
} catch (error) {
|
||||
if ((error as { Code?: string }).Code === 'NoSuchKey') {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `File "${input.fileId}" is no longer available in storage.`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { bytes, fileName: fileItem.name, mimeType: fileItem.fileType };
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: new Uint8Array(Buffer.from(input.audioBase64!, 'base64')),
|
||||
fileName: input.fileName || 'audio',
|
||||
mimeType: input.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
export type AsrRouter = typeof asrRouter;
|
||||
@@ -8,6 +8,7 @@ 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.
|
||||
@@ -29,6 +30,23 @@ 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
|
||||
@@ -334,24 +352,22 @@ 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: deviceProcedure
|
||||
getLocalFilePreview: workspaceFileProcedure
|
||||
.input(
|
||||
z.object({
|
||||
accept: z.enum(['image']).optional(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
deviceGateway.getLocalFilePreview({
|
||||
.query(async ({ ctx, input }) => {
|
||||
return 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
|
||||
@@ -388,68 +404,62 @@ 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: deviceProcedure
|
||||
moveProjectFiles: workspaceFileProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
items: z.array(z.object({ newPath: z.string(), oldPath: z.string() })),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.moveProjectFiles({
|
||||
.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: deviceProcedure
|
||||
renameProjectFile: workspaceFileProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
newName: z.string(),
|
||||
path: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.renameProjectFile({
|
||||
.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: deviceProcedure
|
||||
writeProjectFile: workspaceFileProcedure
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.writeProjectFile({
|
||||
.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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -32,6 +32,7 @@ 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';
|
||||
@@ -98,6 +99,7 @@ export const lambdaRouter = router({
|
||||
aiModel: aiModelRouter,
|
||||
aiProvider: aiProviderRouter,
|
||||
apiKey: apiKeyRouter,
|
||||
asr: asrRouter,
|
||||
chunk: chunkRouter,
|
||||
comfyui: comfyuiRouter,
|
||||
config: configRouter,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DEFAULT_INBOX_AVATAR, INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, desc, eq, ne, or } from 'drizzle-orm';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
isMessengerPlatformEnabled,
|
||||
type MessengerPlatform,
|
||||
} from '@/config/messenger';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import {
|
||||
MessengerAccountLinkConflictError,
|
||||
MessengerAccountLinkModel,
|
||||
@@ -23,7 +23,6 @@ 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';
|
||||
@@ -122,6 +121,12 @@ 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),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -454,44 +459,10 @@ export const messengerRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
// 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();
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { KNOWN_TASK_TEMPLATE_IDS } from '@lobechat/const';
|
||||
import { TASK_TEMPLATE_RECOMMEND_MAX_COUNT } from '@lobechat/const';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { ENABLED_SKILL_SOURCES, TaskTemplateService } from '@/server/services/taskTemplate';
|
||||
import { TaskTemplateService } from '@/server/services/taskTemplate';
|
||||
|
||||
const listDailyRecommendSchema = z.object({
|
||||
count: z.number().int().min(1).optional(),
|
||||
count: z.number().int().min(1).max(TASK_TEMPLATE_RECOMMEND_MAX_COUNT).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
|
||||
.string()
|
||||
.max(64)
|
||||
.refine((id) => KNOWN_TASK_TEMPLATE_IDS.has(id), { message: 'Unknown task template id' }),
|
||||
templateId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const taskTemplateRouter = router({
|
||||
@@ -28,7 +26,7 @@ export const taskTemplateRouter = router({
|
||||
const service = new TaskTemplateService(ctx.userId);
|
||||
const data = await service.listDailyRecommend(input.interestKeys, {
|
||||
count: input.count,
|
||||
enabledSkillSources: ENABLED_SKILL_SOURCES,
|
||||
locale: input.locale,
|
||||
refreshSeed: input.refreshSeed,
|
||||
});
|
||||
return { data, success: true };
|
||||
|
||||
@@ -4,21 +4,26 @@ import {
|
||||
type RecentTopicGroupMember,
|
||||
} from '@lobechat/types';
|
||||
import { cleanObject } from '@lobechat/utils';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { 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 { serverDBEnv } from '@/config/db';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { ChatGroupModel } from '@/database/models/chatGroup';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
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 { agents, chatGroups, chatGroupsAgents } from '@/database/schemas';
|
||||
import { chatGroups } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import { type BatchTaskResult } from '@/types/service';
|
||||
|
||||
import {
|
||||
@@ -35,7 +40,9 @@ 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),
|
||||
@@ -445,22 +452,14 @@ export const topicRouter = router({
|
||||
// Collect all agentIds to fetch agent info
|
||||
const allAgentIds = [...new Set(topicAgentIdMap.values())];
|
||||
|
||||
// Batch query agent info
|
||||
// Batch query agent info (already normalized for the inbox agent)
|
||||
const agentInfoMap = new Map<
|
||||
string,
|
||||
{ avatar: string | null; backgroundColor: string | null; id: string; title: string | null }
|
||||
>();
|
||||
|
||||
if (allAgentIds.length > 0) {
|
||||
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));
|
||||
const agentInfos = await ctx.agentModel.getAgentAvatarsByIds(allAgentIds);
|
||||
|
||||
for (const agent of agentInfos) {
|
||||
agentInfoMap.set(agent.id, agent);
|
||||
@@ -481,28 +480,9 @@ export const topicRouter = router({
|
||||
.from(chatGroups)
|
||||
.where(inArray(chatGroups.id, 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);
|
||||
}
|
||||
// Query group member avatars (already normalized for the inbox agent)
|
||||
const groupMembersMap: Map<string, RecentTopicGroupMember[]> =
|
||||
await ctx.chatGroupModel.getMemberAvatarsByGroupIds(allGroupIds);
|
||||
|
||||
// Build group info map
|
||||
for (const group of chatGroupInfos) {
|
||||
@@ -569,9 +549,31 @@ export const topicRouter = router({
|
||||
|
||||
removeTopic: topicProcedure
|
||||
.use(withScopedPermission('topic:delete'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.input(z.object({ id: z.string(), removeFiles: z.boolean().optional() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return ctx.topicModel.delete(input.id);
|
||||
if (!input.removeFiles) return ctx.topicModel.delete(input.id);
|
||||
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
const fileModel = new FileModel(ctx.serverDB, ctx.userId, wsId);
|
||||
|
||||
// Collect the topic's deletable attachments BEFORE deleting it — the lookup
|
||||
// joins messages, which are cascade-deleted along with the topic. Files
|
||||
// still referenced by another topic or the session are intentionally kept.
|
||||
const fileIds = await fileModel.findDeletableFilesByTopicId(input.id);
|
||||
|
||||
const result = await ctx.topicModel.delete(input.id);
|
||||
|
||||
if (fileIds.length > 0) {
|
||||
const needToRemove = await fileModel.deleteMany(fileIds, serverDBEnv.REMOVE_GLOBAL_FILE);
|
||||
// deleteMany returns only files whose underlying object is no longer
|
||||
// referenced by any other file, so the S3 cleanup is reference-safe.
|
||||
if (needToRemove && needToRemove.length > 0) {
|
||||
const fileService = new FileService(ctx.serverDB, ctx.userId, wsId);
|
||||
await fileService.deleteFiles(needToRemove.map((file) => file.url!));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
searchTopics: topicProcedure
|
||||
|
||||
@@ -104,6 +104,7 @@ export const userMemoryRouter = router({
|
||||
|
||||
deleteAll: userMemoryWriteProcedure.mutation(async ({ ctx }) => {
|
||||
await ctx.userMemoryModel.deleteAll();
|
||||
await ctx.personaModel.deletePersona();
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { DEFAULT_AGENT_CONFIG } from '@lobechat/const';
|
||||
import { DEFAULT_AGENT_CONFIG, DEFAULT_INBOX_AVATAR, DEFAULT_INBOX_TITLE } from '@lobechat/const';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
@@ -190,10 +190,12 @@ describe('AgentService', () => {
|
||||
expect(result?.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should merge avatar from builtin-agents package definition', async () => {
|
||||
it('should fallback inbox title and avatar', async () => {
|
||||
const mockAgent = {
|
||||
avatar: null,
|
||||
id: 'agent-1',
|
||||
slug: 'inbox',
|
||||
title: null,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
@@ -207,8 +209,8 @@ describe('AgentService', () => {
|
||||
const newService = new AgentService(mockDb, mockUserId);
|
||||
const result = await newService.getBuiltinAgent('inbox');
|
||||
|
||||
// Avatar should be merged from BUILTIN_AGENTS definition
|
||||
expect((result as any)?.avatar).toBe('/avatars/lobe-ai.png');
|
||||
expect((result as any)?.avatar).toBe(DEFAULT_INBOX_AVATAR);
|
||||
expect((result as any)?.title).toBe(DEFAULT_INBOX_TITLE);
|
||||
});
|
||||
|
||||
it('should not include avatar for non-builtin agents', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -83,14 +84,20 @@ 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 && !mergedConfig.avatar) {
|
||||
return { ...mergedConfig, avatar: builtinAgent.avatar };
|
||||
if (builtinAgent?.avatar && !normalizedConfig.avatar) {
|
||||
return { ...normalizedConfig, avatar: builtinAgent.avatar };
|
||||
}
|
||||
|
||||
return mergedConfig;
|
||||
return normalizedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,69 +3,78 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDefaultSnapshotStore, shouldUseAgentS3Tracing } from '../snapshotStore';
|
||||
|
||||
const s3SnapshotStoreMock = vi.fn(() => ({ kind: 's3' }));
|
||||
const fileSnapshotStoreMock = vi.fn(() => ({ kind: 'file' }));
|
||||
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 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(loadModule)).toEqual({ kind: 's3' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
|
||||
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(createDefaultSnapshotStore(factories)).toBe(s3Store);
|
||||
expect(createS3).toHaveBeenCalledTimes(1);
|
||||
expect(createFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the local file snapshot store in development when env is unset', () => {
|
||||
setEnv('development');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(false);
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 'file' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@lobechat/agent-tracing');
|
||||
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(fileSnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(createDefaultSnapshotStore(factories)).toBe(fileStore);
|
||||
expect(createS3).not.toHaveBeenCalled();
|
||||
expect(createFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets ENABLE_AGENT_S3_TRACING=1 force S3 tracing outside production', () => {
|
||||
setEnv('development', '1');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(true);
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
|
||||
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(createDefaultSnapshotStore(factories)).toBe(s3Store);
|
||||
expect(createS3).toHaveBeenCalledTimes(1);
|
||||
expect(createFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an explicit ENABLE_AGENT_S3_TRACING value disable the production default', () => {
|
||||
setEnv('production', '0');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(false);
|
||||
expect(createDefaultSnapshotStore(loadModule)).toBeNull();
|
||||
expect(loadModule).not.toHaveBeenCalled();
|
||||
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import type { ISnapshotStore } from '@lobechat/agent-tracing';
|
||||
import { FileSnapshotStore, type ISnapshotStore } from '@lobechat/agent-tracing';
|
||||
|
||||
import { S3SnapshotStore } from '@/server/modules/AgentTracing';
|
||||
|
||||
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;
|
||||
|
||||
@@ -23,6 +12,18 @@ 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
|
||||
@@ -31,28 +32,22 @@ export const shouldUseAgentS3Tracing = () => {
|
||||
* - Otherwise -> null (no tracing)
|
||||
*/
|
||||
export const createDefaultSnapshotStore = (
|
||||
loadModule: SnapshotStoreModuleLoader = nodeRequire,
|
||||
factories: SnapshotStoreFactories = {},
|
||||
): ISnapshotStore | null => {
|
||||
if (shouldUseAgentS3Tracing()) {
|
||||
try {
|
||||
const { S3SnapshotStore } = loadModule(
|
||||
'@/server/modules/AgentTracing',
|
||||
) as S3SnapshotStoreModule;
|
||||
return new S3SnapshotStore();
|
||||
} catch {
|
||||
// S3SnapshotStore not available
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
try {
|
||||
const { FileSnapshotStore } = loadModule(
|
||||
'@lobechat/agent-tracing',
|
||||
) as FileSnapshotStoreModule;
|
||||
return new FileSnapshotStore();
|
||||
} catch {
|
||||
// agent-tracing not available
|
||||
}
|
||||
return (factories.createFile ?? (() => new FileSnapshotStore()))();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
|
||||
import { buildTaskManagerDefaultsPrompt } from '@lobechat/prompts';
|
||||
import type {
|
||||
ChatAudioItem,
|
||||
ChatFileItem,
|
||||
ChatTopicBotContext,
|
||||
ChatVideoItem,
|
||||
@@ -476,6 +477,7 @@ 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 }>;
|
||||
@@ -486,13 +488,15 @@ 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 / document).
|
||||
// Upload raw bot/IM files to S3 and classify them (image / video / audio / 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);
|
||||
@@ -522,7 +526,16 @@ export class AiAgentService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-image / non-video: parse file content into the documents table so
|
||||
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
|
||||
// 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
|
||||
@@ -559,15 +572,17 @@ export class AiAgentService {
|
||||
|
||||
if (fileIds.length > 0) {
|
||||
log(
|
||||
'execAgent: uploaded %d files to S3 (%d images, %d videos, %d documents)',
|
||||
'execAgent: uploaded %d files to S3 (%d images, %d videos, %d audios, %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;
|
||||
}
|
||||
|
||||
@@ -597,6 +612,9 @@ 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];
|
||||
}
|
||||
@@ -614,7 +632,7 @@ export class AiAgentService {
|
||||
// an empty messagesFiles relation.
|
||||
if (fileIds && fileIds.length === 0) fileIds = undefined;
|
||||
|
||||
return { fileIds, fileList, imageList, videoList, warnings };
|
||||
return { audioList, fileIds, fileList, imageList, videoList, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1898,9 +1916,17 @@ 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,
|
||||
@@ -2416,8 +2442,10 @@ 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,6 +33,7 @@ export interface AttachmentSource {
|
||||
|
||||
export interface IngestResult {
|
||||
fileId: string;
|
||||
isAudio: boolean;
|
||||
isImage: boolean;
|
||||
isVideo: boolean;
|
||||
key: string;
|
||||
@@ -149,12 +150,17 @@ 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, bufferSize=%d',
|
||||
'ingestAttachment: classified name=%s, finalMimeType=%s, isImage=%s, isVideo=%s, isAudio=%s, bufferSize=%d',
|
||||
source.name,
|
||||
mimeType,
|
||||
isImage,
|
||||
isVideo,
|
||||
isAudio,
|
||||
buffer.length,
|
||||
);
|
||||
|
||||
@@ -164,9 +170,11 @@ 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 and videos.
|
||||
// 5. Resolve access URL for images, videos and audio.
|
||||
const resolvedUrl =
|
||||
isImage || isVideo ? await fileService.getFileAccessUrl({ id: fileId, url: key }) : '';
|
||||
isImage || isVideo || isAudio
|
||||
? await fileService.getFileAccessUrl({ id: fileId, url: key })
|
||||
: '';
|
||||
|
||||
log(
|
||||
'ingestAttachment: uploaded fileId=%s, key=%s, resolvedUrl=%s',
|
||||
@@ -175,5 +183,5 @@ export async function ingestAttachment(
|
||||
resolvedUrl ? 'set' : '(empty)',
|
||||
);
|
||||
|
||||
return { fileId, isImage, isVideo, key, resolvedUrl };
|
||||
return { fileId, isAudio, isImage, isVideo, key, resolvedUrl };
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const log = debug('lobe-server:device-gateway');
|
||||
* 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`.
|
||||
*/
|
||||
const isPathWithinRoot = (root: string, target: string): boolean => {
|
||||
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));
|
||||
|
||||
@@ -311,8 +311,8 @@ export class DocumentService {
|
||||
*/
|
||||
async runWithDocumentLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
||||
if (!this.workspaceId) {
|
||||
// TEMP DIAGNOSTIC (LOBE-10470): distinguishes "no-op because workspaceId is
|
||||
// missing at runtime" from "lock actually evaluated". Remove once verified.
|
||||
// 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();
|
||||
}
|
||||
@@ -330,7 +330,7 @@ export class DocumentService {
|
||||
heldBeforeByUser && holderBefore?.ownerId ? holderBefore.ownerId : `server:${randomUUID()}`;
|
||||
|
||||
const lock = await this.acquireDocumentLockWithOwner(id, ownerId);
|
||||
// TEMP DIAGNOSTIC (LOBE-10470): one reproduction reveals workspaceId/holder/acquire.
|
||||
// Diagnostic: surfaces workspaceId/holder/acquire for debugging lock issues.
|
||||
log(
|
||||
'runWithDocumentLock: id=%s userId=%s ws=%s holderBefore=%s acquired=%o',
|
||||
id,
|
||||
|
||||
@@ -171,6 +171,9 @@ describe('EditLockService', () => {
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getBlockingHolder('document', 'doc-1'),
|
||||
).toBe('user-1');
|
||||
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',
|
||||
|
||||
@@ -203,9 +203,13 @@ export class EditLockService {
|
||||
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, then keep the stale-tab guard (same user, different
|
||||
// active ownerId still blocks so a ghost tab can't save over a newer one).
|
||||
// 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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import type { ChatFileItem, ChatImageItem, ChatVideoItem } from '@lobechat/types';
|
||||
import type { ChatAudioItem, ChatFileItem, ChatImageItem, ChatVideoItem } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { FileModel } from '@/database/models/file';
|
||||
@@ -9,6 +9,7 @@ import { FileService } from '@/server/services/file';
|
||||
const log = debug('lobe-server:resolveAttachments');
|
||||
|
||||
export interface ResolvedAttachments {
|
||||
audioList: ChatAudioItem[];
|
||||
fileList: ChatFileItem[];
|
||||
imageList: ChatImageItem[];
|
||||
/**
|
||||
@@ -45,6 +46,7 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
workspaceId,
|
||||
}: ResolveArgs): Promise<ResolvedAttachments> => {
|
||||
const result: ResolvedAttachments = {
|
||||
audioList: [],
|
||||
fileList: [],
|
||||
imageList: [],
|
||||
orderedFileIds: [],
|
||||
@@ -76,7 +78,11 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
}
|
||||
const resolvedUrl = (await fileService.getFullFileUrl(file.url)) || file.url;
|
||||
const fileType = file.fileType || '';
|
||||
if (fileType.startsWith('image') || fileType.startsWith('video')) {
|
||||
if (
|
||||
fileType.startsWith('image') ||
|
||||
fileType.startsWith('video') ||
|
||||
fileType.startsWith('audio')
|
||||
) {
|
||||
return { file, fileType, id, resolvedUrl };
|
||||
}
|
||||
let content: string | undefined;
|
||||
@@ -106,6 +112,10 @@ 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(
|
||||
@@ -123,10 +133,11 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
}
|
||||
|
||||
log(
|
||||
'resolved %d attachment(s) (%d images, %d videos, %d documents)',
|
||||
'resolved %d attachment(s) (%d images, %d videos, %d audios, %d documents)',
|
||||
fileRecords.length,
|
||||
result.imageList.length,
|
||||
result.videoList.length,
|
||||
result.audioList.length,
|
||||
result.fileList.length,
|
||||
);
|
||||
|
||||
|
||||
@@ -115,14 +115,6 @@ interface OperationState {
|
||||
main: MainAgentRunState;
|
||||
operationId: string;
|
||||
processedKeys: Set<string>;
|
||||
/**
|
||||
* The operation's seeded placeholder assistant (the row `execAgent` creates
|
||||
* before the first ingest). Immutable for the run's lifetime. Used as the
|
||||
* `createdAt` floor when anchoring the chain to the run's real last tool —
|
||||
* a topic runs at most one operation at a time, so "tool messages on/after
|
||||
* the seed" scopes to THIS run without a recursive parent walk.
|
||||
*/
|
||||
seedAssistantMessageId: string;
|
||||
/**
|
||||
* Run-global DB index for every tool message in the topic, keyed by
|
||||
* `tool_call_id`. Main and subagent reducers keep only their per-turn maps;
|
||||
@@ -404,7 +396,6 @@ export class HeterogeneousPersistenceHandler {
|
||||
main: createMainAgentRunState(currentAssistantMessageId),
|
||||
operationId,
|
||||
processedKeys: new Set(),
|
||||
seedAssistantMessageId: baseAssistantMessageId,
|
||||
toolMsgIdByCallId: new Map(),
|
||||
topicId,
|
||||
};
|
||||
@@ -479,17 +470,6 @@ export class HeterogeneousPersistenceHandler {
|
||||
return toolState;
|
||||
}
|
||||
|
||||
private getLastSnapshotToolMessageId(
|
||||
snapshot: AssistantDbSnapshot,
|
||||
toolMsgIdByCallId: Map<string, string>,
|
||||
): string | undefined {
|
||||
for (const tool of [...snapshot.tools].reverse()) {
|
||||
const toolMessageId = tool.result_msg_id ?? toolMsgIdByCallId.get(tool.id);
|
||||
if (toolMessageId) return toolMessageId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async refreshToolMessageIndex(state: OperationState): Promise<void> {
|
||||
const toolPlugins = await this.deps.messageModel.listMessagePluginsByTopic(state.topicId);
|
||||
for (const plugin of toolPlugins) {
|
||||
@@ -497,10 +477,6 @@ export class HeterogeneousPersistenceHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async getLastChildToolMessageId(assistantMessageId: string): Promise<string | undefined> {
|
||||
return await this.deps.messageModel.getLastChildToolMessageId?.(assistantMessageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate reducer state from the DB projection of the current assistant.
|
||||
* This preserves the shared pure reducer as the single state machine while
|
||||
@@ -553,49 +529,16 @@ export class HeterogeneousPersistenceHandler {
|
||||
if (snapshot.model) state.main.turnModel = snapshot.model;
|
||||
if (snapshot.provider) state.main.turnProvider = snapshot.provider;
|
||||
|
||||
// Anchor the chain to the RUN's real latest main-thread tool message, read
|
||||
// straight from the DB and independent of `currentAssistantId`. The latter
|
||||
// can regress to the seeded placeholder on a cold / non-sticky replica
|
||||
// (see the multi-replica caveat on the class) when `heteroCurrentMsgId` is
|
||||
// not yet bound to this operation: anchoring off its child tools would then
|
||||
// collapse onto the run's FIRST tool, and every later step opens off that
|
||||
// same node — forking the wire into orphan siblings. Ordering by createdAt
|
||||
// also sidesteps the multi-tool-batch hazard where an earlier tool's
|
||||
// result_msg_id is backfilled before a later tool row's JSONB is rewritten.
|
||||
const runLastToolId = await this.getLastRunToolMessageId(state);
|
||||
if (runLastToolId) {
|
||||
state.main.lastToolMsgIdEver = runLastToolId;
|
||||
return;
|
||||
}
|
||||
|
||||
// No tool persisted in this run yet — fall back to the per-assistant lookups
|
||||
// so the very first turn still chains correctly before any tool exists.
|
||||
const currentTurnToolId =
|
||||
(await this.getLastChildToolMessageId(state.main.currentAssistantId)) ??
|
||||
this.getLastSnapshotToolMessageId(snapshot, state.toolMsgIdByCallId);
|
||||
if (currentTurnToolId) {
|
||||
state.main.lastToolMsgIdEver = currentTurnToolId;
|
||||
return;
|
||||
}
|
||||
|
||||
const toolMessageIds = new Set(state.toolMsgIdByCallId.values());
|
||||
if (snapshot.parentId && toolMessageIds.has(snapshot.parentId)) {
|
||||
state.main.lastToolMsgIdEver = snapshot.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest main-thread tool message created on/after the run's seed assistant.
|
||||
* Scopes to the current operation via the seed's `createdAt` floor without a
|
||||
* recursive walk, and stays correct even when `currentAssistantId` has
|
||||
* regressed on a cold replica. Optional on the model so test mocks that don't
|
||||
* implement it transparently fall back to the per-assistant anchors.
|
||||
*/
|
||||
private async getLastRunToolMessageId(state: OperationState): Promise<string | undefined> {
|
||||
return await this.deps.messageModel.getLastMainThreadToolMessageIdSince?.(
|
||||
state.topicId,
|
||||
state.seedAssistantMessageId,
|
||||
);
|
||||
// Recover the chain spine from the DB (LOBE-10445 phase 2). The next normal
|
||||
// turn parents off the run's latest NON-tool / NON-signal main-thread
|
||||
// message; reading it straight from the DB (independent of
|
||||
// `currentAssistantId`, which can regress to the seed placeholder on a cold
|
||||
// / non-sticky replica — see the multi-replica caveat on the class) keeps
|
||||
// consecutive cold-replica steps chained linearly instead of forking onto a
|
||||
// stale node. Signal turns still anchor off `lastToolMsgIdEver`, which is
|
||||
// maintained in-memory across the run's tool batches.
|
||||
const spineId = await this.deps.messageModel.getLastMainThreadSpineMessageId?.(state.topicId);
|
||||
if (spineId) state.main.lastSpineMessageId = spineId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -706,8 +649,11 @@ export class HeterogeneousPersistenceHandler {
|
||||
if (!currentAssistant) return undefined;
|
||||
|
||||
const toolRows = messages.filter((m) => m.role === 'tool' && m.tool_call_id);
|
||||
const childTools = toolRows.filter((m) => m.parentId === currentAssistant.id);
|
||||
const lastChainParentId = childTools.at(-1)?.id ?? currentAssistant.id;
|
||||
// Chain rule (LOBE-10445 phase 2): the next turn's assistant parents off the
|
||||
// prior assistant (the spine), not its last child tool — recover the anchor
|
||||
// as the current assistant itself (matches the subagent reducer, and is
|
||||
// fork-resistant since it reads the thread's real latest assistant from DB).
|
||||
const lastChainParentId = currentAssistant.id;
|
||||
// Recover the in-flight turn's CC message.id so a continuation event is
|
||||
// recognized as the SAME turn (no spurious boundary → no fragmentation).
|
||||
const currentSubagentMessageId =
|
||||
|
||||
+33
-32
@@ -9,19 +9,19 @@ import {
|
||||
|
||||
/**
|
||||
* Regression for the remote-device chain-fork (observed on tpc_3DKmFfAmx9YA):
|
||||
* several CONSECUTIVE, DISTINCT main-agent steps all parented onto the run's
|
||||
* FIRST tool message instead of chaining linearly.
|
||||
* several CONSECUTIVE, DISTINCT main-agent steps all parented onto the same
|
||||
* stale node instead of chaining linearly.
|
||||
*
|
||||
* Root cause: `refreshMainStateFromDb` used to anchor `lastToolMsgIdEver` off
|
||||
* `getLastChildToolMessageId(currentAssistantId)`. On a non-sticky / cold
|
||||
* replica (a WS reconnect storm spreads one run's batches across replicas),
|
||||
* `currentAssistantId` regresses to the operation's seeded placeholder when the
|
||||
* `heteroCurrentMsgId` pointer is not yet visible. The anchor then collapses to
|
||||
* the SEED's first child tool, and every later `newStep` opens off that same
|
||||
* node → orphan sibling forks.
|
||||
* On a non-sticky / cold replica (a WS reconnect storm spreads one run's batches
|
||||
* across replicas), `currentAssistantId` regresses to the operation's seeded
|
||||
* placeholder when the `heteroCurrentMsgId` pointer is not yet visible. If the
|
||||
* chain parent were derived from that in-memory pointer, every later `newStep`
|
||||
* would open off the seed → orphan sibling forks.
|
||||
*
|
||||
* The fix anchors the chain to the RUN's real latest main-thread tool, read
|
||||
* from the DB and ordered by createdAt, independent of `currentAssistantId`.
|
||||
* LOBE-10445 phase 2 anchors the chain to the run's latest NON-tool / NON-signal
|
||||
* main-thread message (`getLastMainThreadSpineMessageId`), read straight from the
|
||||
* DB and ordered by createdAt — independent of `currentAssistantId`. So step 2
|
||||
* chains off step 1's assistant even though the in-memory pointer regressed.
|
||||
*
|
||||
* This harness models the precondition deterministically: `updateMetadata`
|
||||
* never persists `heteroCurrentMsgId`, so every cold load regresses
|
||||
@@ -103,18 +103,15 @@ const createHarness = () => {
|
||||
return [...messages.values()].filter((m) => m.threadId === params.threadId);
|
||||
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
|
||||
}),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
const match = [...messages.values()]
|
||||
.filter((m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId)
|
||||
.sort((a, b) => b.seq - a.seq)[0];
|
||||
return match?.id;
|
||||
}),
|
||||
getLastMainThreadToolMessageIdSince: vi.fn(async (topicId: string, sinceMessageId: string) => {
|
||||
const seed = messages.get(sinceMessageId);
|
||||
if (!seed) return undefined;
|
||||
getLastMainThreadSpineMessageId: vi.fn(async (topicId: string) => {
|
||||
// Most recent main-thread, non-tool, non-signal message — the spine anchor.
|
||||
const match = [...messages.values()]
|
||||
.filter(
|
||||
(m) => m.topicId === topicId && m.role === 'tool' && !m.threadId && m.seq >= seed.seq,
|
||||
(m) =>
|
||||
m.topicId === topicId &&
|
||||
m.role !== 'tool' &&
|
||||
!m.threadId &&
|
||||
!(m as any).metadata?.signal,
|
||||
)
|
||||
.sort((a, b) => b.seq - a.seq)[0];
|
||||
return match?.id;
|
||||
@@ -184,7 +181,7 @@ describe('HeterogeneousPersistenceHandler — chain anchor survives a regressed
|
||||
beforeEach(() => __resetOperationStatesForTesting());
|
||||
afterEach(() => __resetOperationStatesForTesting());
|
||||
|
||||
it('chains consecutive cold-replica steps off the run last tool, not the seed first tool', async () => {
|
||||
it('chains consecutive cold-replica steps linearly off the spine, not forking onto the seed', async () => {
|
||||
const h = createHarness();
|
||||
|
||||
// Step 1 on a cold replica (currentAssistantId regresses to SEED).
|
||||
@@ -209,18 +206,22 @@ describe('HeterogeneousPersistenceHandler — chain anchor survives a regressed
|
||||
expect(assistants).toHaveLength(2);
|
||||
|
||||
const [a1, a2] = assistants.sort((x, y) => x.seq - y.seq);
|
||||
const toolA = [...h.messages.values()].find((m) => m.tool_call_id === 'tc-A')!;
|
||||
|
||||
// First step still chains off the run's only existing tool (T1, seed's child).
|
||||
expect(a1.parentId).toBe(T1);
|
||||
// Second step must chain off step 1's tool — NOT collapse back onto T1.
|
||||
expect(a2.parentId).toBe(toolA.id);
|
||||
expect(a2.parentId).not.toBe(T1);
|
||||
// Step 1 chains off the spine = the seed assistant (T1 is a tool, excluded).
|
||||
expect(a1.parentId).toBe(SEED);
|
||||
// Step 2 chains off step 1's assistant — the spine query found a1 from the DB
|
||||
// despite the in-memory currentAssistantId having regressed to SEED.
|
||||
expect(a2.parentId).toBe(a1.id);
|
||||
|
||||
// No fork: T1 has exactly one assistant child across the whole run.
|
||||
const t1AssistantChildren = [...h.messages.values()].filter(
|
||||
(m) => m.role === 'assistant' && m.parentId === T1,
|
||||
// No fork: the seed has exactly one assistant child (a1), and a1 has exactly
|
||||
// one assistant child (a2) — a linear spine, not a fan-out.
|
||||
const seedAssistantChildren = [...h.messages.values()].filter(
|
||||
(m) => m.role === 'assistant' && m.parentId === SEED,
|
||||
);
|
||||
expect(t1AssistantChildren).toHaveLength(1);
|
||||
expect(seedAssistantChildren).toHaveLength(1);
|
||||
const a1AssistantChildren = [...h.messages.values()].filter(
|
||||
(m) => m.role === 'assistant' && m.parentId === a1.id,
|
||||
);
|
||||
expect(a1AssistantChildren).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -122,9 +122,9 @@ const createHarness = (
|
||||
},
|
||||
),
|
||||
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
getLastMainThreadSpineMessageId: vi.fn(async (_topicId: string) => {
|
||||
const match = [...messages.values()].findLast(
|
||||
(m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId,
|
||||
(m) => m.role !== 'tool' && !m.threadId && !(m as any).metadata?.signal,
|
||||
);
|
||||
return match?.id;
|
||||
}),
|
||||
|
||||
+4
-3
@@ -264,11 +264,12 @@ describe('HeterogeneousPersistenceHandler — synthetic CC trace fixture', () =>
|
||||
// STEP_COUNT-1 new assistants from `stream_start { newStep }` events.
|
||||
expect(allAssistants.length).toBe(STEP_COUNT);
|
||||
|
||||
// Each new assistant chains off the LAST tool message of the prior step
|
||||
// (renderer parity: "the wire becomes asst → tool → asst → tool → ...").
|
||||
// Each new assistant chains off the PRIOR ASSISTANT (the spine, phase 2):
|
||||
// the persisted shape is `user → asst → asst …` with tools as inline
|
||||
// children; the read side reconstructs the `asst → tool → asst` zigzag.
|
||||
for (let i = 1; i < allAssistants.length; i += 1) {
|
||||
const parent = h.messages.get(allAssistants[i].parentId!);
|
||||
expect(parent?.role).toBe('tool');
|
||||
expect(parent?.role).toBe('assistant');
|
||||
}
|
||||
|
||||
// ─── Bursty-text dedupe key correctness ───
|
||||
|
||||
+2
-2
@@ -129,9 +129,9 @@ const createHarness = () => {
|
||||
return [...messages.values()].filter((m) => m.threadId === params.threadId);
|
||||
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
|
||||
}),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
getLastMainThreadSpineMessageId: vi.fn(async (_topicId: string) => {
|
||||
const match = [...messages.values()].findLast(
|
||||
(m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId,
|
||||
(m) => m.role !== 'tool' && !m.threadId && !(m as any).metadata?.signal,
|
||||
);
|
||||
return match?.id;
|
||||
}),
|
||||
|
||||
+2
-2
@@ -123,9 +123,9 @@ const createHarness = (params: {
|
||||
}
|
||||
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
|
||||
}),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
getLastMainThreadSpineMessageId: vi.fn(async (_topicId: string) => {
|
||||
const match = [...messages.values()].findLast(
|
||||
(m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId,
|
||||
(m) => m.role !== 'tool' && !m.threadId && !(m as any).metadata?.signal,
|
||||
);
|
||||
return match?.id;
|
||||
}),
|
||||
|
||||
+34
-40
@@ -117,11 +117,11 @@ const createHarness = (params: {
|
||||
},
|
||||
),
|
||||
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
// Mirror the SQL: last-created main-agent (threadId null) tool row whose
|
||||
// parentId is the assistant. Map insertion order == creation order.
|
||||
getLastMainThreadSpineMessageId: vi.fn(async (_topicId: string) => {
|
||||
// Mirror the SQL: most recent main-agent (threadId null) message that is
|
||||
// NOT a tool and NOT a signal-tagged callback. Insertion order == creation.
|
||||
const match = [...messages.values()].findLast(
|
||||
(m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId,
|
||||
(m) => m.role !== 'tool' && !m.threadId && !(m as any).metadata?.signal,
|
||||
);
|
||||
return match?.id;
|
||||
}),
|
||||
@@ -561,7 +561,7 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
});
|
||||
|
||||
describe('step boundaries (stream_start newStep)', () => {
|
||||
it('flushes prior content, opens a new assistant chained off the last tool message', async () => {
|
||||
it('flushes prior content, opens a new assistant chained off the prior assistant (spine)', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-1',
|
||||
@@ -591,28 +591,24 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// First step: asst-1 got content + tools
|
||||
// After step boundary: a NEW assistant created chained off the tool msg
|
||||
// First step: asst-1 got content + tools.
|
||||
// After step boundary (phase 2 spine rule): a NEW assistant is created
|
||||
// chained off the prior assistant (asst-1), with the tool as an inline
|
||||
// child — the read side reconstructs the zigzag.
|
||||
const newAssistants = [...h.messages.values()].filter(
|
||||
(m) => m.role === 'assistant' && m.id !== 'asst-1',
|
||||
);
|
||||
expect(newAssistants).toHaveLength(1);
|
||||
|
||||
const toolMsg = [...h.messages.values()].find((m) => m.role === 'tool');
|
||||
expect(newAssistants[0].parentId).toBe(toolMsg?.id);
|
||||
expect(newAssistants[0].parentId).toBe('asst-1');
|
||||
});
|
||||
|
||||
it('chains off the tool message even when the prior tools_calling landed on a DIFFERENT replica (multi-replica recovery)', async () => {
|
||||
// Reproduces the prod bug: the in-memory state.toolState gets RESET at
|
||||
// the end of every handleStepStart. If the next step's tools_calling
|
||||
// event then lands on a different replica, this replica's toolState
|
||||
// stays empty, and the FOLLOWING step boundary computes parentId from
|
||||
// that empty state → falls back to currentAssistantMessageId →
|
||||
// new assistant chains off the previous ASSISTANT rather than the
|
||||
// previous TOOL message.
|
||||
//
|
||||
// Fix: `ingest()` refresh adopts `tools[]` from DB as authoritative
|
||||
// whenever DB has more resolved tools than memory.
|
||||
it('chains off the prior assistant (spine) across a multi-replica boundary, recovered from DB', async () => {
|
||||
// Phase 2: the chain parent is the run's latest non-tool / non-signal
|
||||
// main message, recovered from the DB (`getLastMainThreadSpineMessageId`)
|
||||
// independent of the in-memory current-assistant pointer. So even when the
|
||||
// prior step's tools_calling drained on a DIFFERENT replica (this replica's
|
||||
// toolState stays empty), step 2 still chains off step 1's assistant — a
|
||||
// linear spine, not a fork.
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-init',
|
||||
operationId: 'op-1',
|
||||
@@ -680,10 +676,8 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
});
|
||||
|
||||
// ── Batch 2: step 2 stream_start lands back on THIS replica ──
|
||||
// Pre-fix: state.toolState.payloads is still [] → lastToolMsgId is
|
||||
// undefined → stepParentId falls back to step1Asst.id (BUG).
|
||||
// Post-fix: ingest() refresh reads step1Asst.tools from DB → toolState
|
||||
// gets the tool with result_msg_id → handleStepStart chains correctly.
|
||||
// The DB spine query returns step1Asst (the latest non-tool main message),
|
||||
// so step 2 chains off it regardless of this replica's empty toolState.
|
||||
await h.handler.ingest({
|
||||
events: [buildEvent('stream_start', 2, { newStep: true })],
|
||||
operationId: 'op-1',
|
||||
@@ -694,7 +688,7 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
(m) => m.role === 'assistant' && m.id !== 'asst-init' && m.id !== step1Asst.id,
|
||||
);
|
||||
expect(step2Asst).toBeDefined();
|
||||
expect(step2Asst!.parentId).toBe('tool-other-replica');
|
||||
expect(step2Asst!.parentId).toBe(step1Asst.id);
|
||||
// And the new assistant should inherit model/provider that the other
|
||||
// replica wrote — refresh also restores lastModel/lastProvider so we
|
||||
// no longer create assistants with model=null/provider=null on the
|
||||
@@ -703,14 +697,12 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
expect(step2Asst!.provider).toBe('claude-code');
|
||||
});
|
||||
|
||||
it('chains off the tool ROW when the refresh misses the tools[] result_msg_id backfill', async () => {
|
||||
// Residual race the batch-start refresh does NOT cover: the other replica
|
||||
// created the tool row (Phase 2) but its assistant.tools[] result_msg_id
|
||||
// backfill (Phase 3) is not yet visible. The refresh keys off
|
||||
// result_msg_id, so it sees 0 resolved tools → does NOT adopt → toolState
|
||||
// stays empty → pre-fix the step boundary falls back to the previous
|
||||
// assistant and forks the wire. The fix queries the role:'tool' row
|
||||
// itself (committed in Phase 2, independent of the JSONB mirror).
|
||||
it('chains off the spine regardless of the prior step tool backfill state', async () => {
|
||||
// Phase 2: the chain anchors to the spine (latest non-tool main message),
|
||||
// so the prior step's tool-row / result_msg_id backfill timing — which
|
||||
// used to matter for the tool anchor — no longer affects the chain. Even
|
||||
// with a tool row present but no tools[] backfill, step 2 chains off the
|
||||
// prior assistant.
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-init',
|
||||
operationId: 'op-1',
|
||||
@@ -763,11 +755,11 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
(m) => m.role === 'assistant' && m.id !== 'asst-init' && m.id !== step1Asst.id,
|
||||
);
|
||||
expect(step2Asst).toBeDefined();
|
||||
// Chains off the tool row, NOT the previous assistant → wire stays linear.
|
||||
expect(step2Asst!.parentId).toBe('tool-row-only');
|
||||
// Chains off the prior assistant (spine) → wire stays linear; the tool is inline.
|
||||
expect(step2Asst!.parentId).toBe(step1Asst.id);
|
||||
});
|
||||
|
||||
it('chains off the latest tool row when parallel tools are only partially backfilled', async () => {
|
||||
it('chains off the spine when parallel tools are only partially backfilled', async () => {
|
||||
// Regression for main-chain breaks with parallel/multi tool calls:
|
||||
// tool A is visible in assistant.tools[].result_msg_id, while tool B's
|
||||
// row exists but Phase 3 has not backfilled assistant.tools[] yet. The
|
||||
@@ -849,7 +841,8 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
(m) => m.role === 'assistant' && m.id !== 'asst-init' && m.id !== step1Asst.id,
|
||||
);
|
||||
expect(step2Asst).toBeDefined();
|
||||
expect(step2Asst!.parentId).toBe('tool-b-row-only');
|
||||
// Spine-anchored: parallel-tool backfill state is irrelevant to the chain.
|
||||
expect(step2Asst!.parentId).toBe(step1Asst.id);
|
||||
});
|
||||
|
||||
it('ignores subagent tool rows (threadId set) when resolving the step anchor', async () => {
|
||||
@@ -1095,9 +1088,10 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
expect(threadTool?.tool_call_id).toBe('inner-tc-1');
|
||||
expect(threadTool?.parentId).toBe(threadAssts[0].id);
|
||||
|
||||
// Second-turn assistant chains off the tool message
|
||||
// Second-turn assistant chains off the prior in-thread assistant (spine),
|
||||
// with the tool as an inline child (phase 2 rule).
|
||||
const secondTurn = threadAssts[1];
|
||||
expect(secondTurn.parentId).toBe(threadTool?.id);
|
||||
expect(secondTurn.parentId).toBe(threadAssts[0].id);
|
||||
});
|
||||
|
||||
it('finalizes the run with terminal assistant carrying tool_result content', async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createIoRedisState } from '@chat-adapter/state-ioredis';
|
||||
import { INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import {
|
||||
Chat,
|
||||
ConsoleLogger,
|
||||
@@ -8,16 +7,14 @@ 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';
|
||||
@@ -1511,33 +1508,17 @@ export class MessengerRouter {
|
||||
userId: string,
|
||||
workspaceId?: string | null,
|
||||
): Promise<AgentSummary[]> {
|
||||
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));
|
||||
// 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 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);
|
||||
// `fallbackTitle` guarantees a non-null title for every row.
|
||||
return rows.map((row) => ({ id: row.id, title: row.title! }));
|
||||
}
|
||||
|
||||
private async dispatchToAgent(
|
||||
|
||||
@@ -1,303 +1,275 @@
|
||||
// @vitest-environment node
|
||||
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 { TASK_TEMPLATE_RECOMMEND_MAX_COUNT } from '@lobechat/const';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isTemplateSkillSourceEligible, TaskTemplateService } from './index';
|
||||
import { createTaskTemplateRecommendationSeedKey, TaskTemplateService } from './index';
|
||||
|
||||
const makeTemplate = (overrides: Partial<TaskTemplate>): TaskTemplate => ({
|
||||
category: 'engineering',
|
||||
cronPattern: '0 9 * * *',
|
||||
id: 't',
|
||||
interests: [],
|
||||
...overrides,
|
||||
const { mockAppEnv, mockGetTaskTemplateRecommendations, mockMarket } = vi.hoisted(() => {
|
||||
const market: {
|
||||
taskTemplates: {
|
||||
getTaskTemplateRecommendations: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
} = {
|
||||
taskTemplates: {
|
||||
getTaskTemplateRecommendations: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
mockAppEnv: {
|
||||
APP_URL: 'https://self-hosted.example',
|
||||
MARKET_TRUSTED_CLIENT_ID: 'client-id' as string | undefined,
|
||||
MARKET_TRUSTED_CLIENT_SECRET: 'secret' as string | undefined,
|
||||
},
|
||||
mockGetTaskTemplateRecommendations: vi.fn(),
|
||||
mockMarket: market,
|
||||
};
|
||||
});
|
||||
|
||||
const UTC_DAY_1 = new Date('2026-04-24T10:00:00Z');
|
||||
const UTC_DAY_2 = new Date('2026-04-25T10:00:00Z');
|
||||
vi.mock('@/server/services/market', () => ({
|
||||
MarketService: vi.fn(() => ({ market: mockMarket })),
|
||||
}));
|
||||
|
||||
vi.mock('@/envs/app', () => ({
|
||||
appEnv: mockAppEnv,
|
||||
}));
|
||||
|
||||
const template = {
|
||||
category: 'engineering',
|
||||
connectors: [],
|
||||
cronPattern: '0 9 * * *',
|
||||
description: 'Description',
|
||||
id: 101,
|
||||
identifier: 'daily-engineering',
|
||||
instruction: 'Instruction',
|
||||
interests: ['coding'],
|
||||
title: 'Title',
|
||||
} satisfies TaskTemplate;
|
||||
|
||||
describe('TaskTemplateService.listDailyRecommend', () => {
|
||||
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 });
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
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);
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAppEnv.MARKET_TRUSTED_CLIENT_ID = 'client-id';
|
||||
mockAppEnv.MARKET_TRUSTED_CLIENT_SECRET = 'secret';
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockMarket.taskTemplates = {
|
||||
getTaskTemplateRecommendations: mockGetTaskTemplateRecommendations,
|
||||
};
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: [template] });
|
||||
});
|
||||
|
||||
it('is stable for the same (userId, utcDate)', async () => {
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns Market recommendation items', async () => {
|
||||
const service = new TaskTemplateService('user-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
|
||||
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,
|
||||
excludeIds: [101],
|
||||
locale: 'zh-CN',
|
||||
refreshSeed: 'refresh-1',
|
||||
});
|
||||
|
||||
expect(a.map((t) => t.id)).toEqual(b.map((t) => t.id));
|
||||
expect(mockGetTaskTemplateRecommendations).toHaveBeenCalledWith({
|
||||
count: 10,
|
||||
excludeIds: [101],
|
||||
interestKeys: ['coding'],
|
||||
locale: 'zh-CN',
|
||||
refreshSeed: 'refresh-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);
|
||||
it('does not pass seedKey when trusted client auth is enabled', async () => {
|
||||
const service = new TaskTemplateService('local-user-raw-id');
|
||||
|
||||
await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(mockGetTaskTemplateRecommendations.mock.calls[0][0]).not.toHaveProperty('seedKey');
|
||||
});
|
||||
|
||||
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(',')),
|
||||
),
|
||||
it('uses an opaque stable seedKey without exposing the local user id when anonymous', async () => {
|
||||
mockAppEnv.MARKET_TRUSTED_CLIENT_ID = undefined;
|
||||
mockAppEnv.MARKET_TRUSTED_CLIENT_SECRET = undefined;
|
||||
const service = new TaskTemplateService('local-user-raw-id');
|
||||
|
||||
await service.listDailyRecommend(['coding']);
|
||||
await service.listDailyRecommend(['coding']);
|
||||
|
||||
const firstSeedKey = mockGetTaskTemplateRecommendations.mock.calls[0][0].seedKey;
|
||||
const secondSeedKey = mockGetTaskTemplateRecommendations.mock.calls[1][0].seedKey;
|
||||
expect(firstSeedKey).toBe(secondSeedKey);
|
||||
expect(firstSeedKey).not.toContain('local-user-raw-id');
|
||||
expect(firstSeedKey).toBe(createTaskTemplateRecommendationSeedKey('local-user-raw-id'));
|
||||
});
|
||||
|
||||
it('clamps oversized recommendation counts before calling Market', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
await service.listDailyRecommend(['coding'], { count: 25 });
|
||||
|
||||
expect(mockGetTaskTemplateRecommendations).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ count: TASK_TEMPLATE_RECOMMEND_MAX_COUNT }),
|
||||
);
|
||||
expect(new Set(results).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('falls back to fallback categories when user has no interests', async () => {
|
||||
it('throws when Market recommendations fail', async () => {
|
||||
mockGetTaskTemplateRecommendations.mockRejectedValue(new Error('market down'));
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend([], { now: UTC_DAY_1 });
|
||||
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
for (const p of picked) {
|
||||
expect(taskTemplates.some((t) => t.id === p.id)).toBe(true);
|
||||
}
|
||||
await expect(service.listDailyRecommend(['coding'])).rejects.toThrow('market down');
|
||||
});
|
||||
|
||||
it('intersection is case-insensitive and trims whitespace', async () => {
|
||||
it('throws when Market returns a malformed response', async () => {
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({});
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend([' CoDing '], { now: UTC_DAY_1 });
|
||||
|
||||
const codingMatches = taskTemplates.filter((t) => t.interests.includes('coding'));
|
||||
expect(picked.some((p) => codingMatches.some((m) => m.id === p.id))).toBe(true);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
});
|
||||
|
||||
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 excludedId = baseline[0].id;
|
||||
const picked = await service.listDailyRecommend(['coding'], {
|
||||
excludeIds: [excludedId],
|
||||
now: UTC_DAY_1,
|
||||
});
|
||||
|
||||
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(',')),
|
||||
),
|
||||
await expect(service.listDailyRecommend(['coding'])).rejects.toThrow(
|
||||
'Market recommendations returned no items array',
|
||||
);
|
||||
expect(new Set(results).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('refreshSeed does not bypass excludeIds', async () => {
|
||||
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] });
|
||||
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);
|
||||
}
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([templateWithConnectors]);
|
||||
});
|
||||
|
||||
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' },
|
||||
it('throws when Market recommendation items are malformed', async () => {
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({
|
||||
items: [
|
||||
template,
|
||||
null,
|
||||
[],
|
||||
{ ...template, category: 'unknown-category' },
|
||||
{ ...template, connectors: 'invalid-connectors' },
|
||||
{ ...template, description: undefined },
|
||||
{ ...template, icon: 'unknown-icon' },
|
||||
{ ...template, id: 101.5 },
|
||||
{ ...template, id: '101' },
|
||||
{ ...template, identifier: 101 },
|
||||
{ ...template, instruction: 101 },
|
||||
{ ...template, interests: 'coding' },
|
||||
{ ...template, interests: ['unknown-interest'] },
|
||||
{ ...template, title: 101 },
|
||||
{ ...template, cronPattern: 0 },
|
||||
{ ...template, cronPattern: '0 9 * *' },
|
||||
{ ...template, cronPattern: '*/0 * * * *' },
|
||||
{ ...template, cronPattern: '0 */0 * * *' },
|
||||
{ ...template, cronPattern: '60 9 * * *' },
|
||||
{ ...template, cronPattern: '0 24 * * *' },
|
||||
{ ...template, cronPattern: '0 9 1 * *' },
|
||||
{ ...template, cronPattern: '0 9 * 1 *' },
|
||||
{ ...template, cronPattern: '0 9 * * 7' },
|
||||
{ ...template, cronPattern: '0 9 * * 1,7' },
|
||||
{ ...template, connectors: [{ identifier: 101, required: true, source: 'lobehub' }] },
|
||||
{ ...template, connectors: [{ identifier: 'github', source: 'lobehub' }] },
|
||||
{ ...template, connectors: [{ identifier: 'github', required: true, source: 'unknown' }] },
|
||||
],
|
||||
});
|
||||
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);
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
await expect(service.listDailyRecommend(['coding'])).rejects.toThrow(
|
||||
'Market recommendations returned malformed items',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats empty requiresSkills array same as undefined (always eligible)', () => {
|
||||
const t = makeTemplate({ requiresSkills: [] });
|
||||
expect(isTemplateSkillSourceEligible(t, undefined)).toBe(true);
|
||||
it('accepts scheduler-supported cron patterns from Market recommendation items', async () => {
|
||||
const templates = [
|
||||
{ ...template, cronPattern: '0 * * * *', id: 102 },
|
||||
{ ...template, cronPattern: '30 */6 * * *', id: 103 },
|
||||
{ ...template, cronPattern: '*/30 * * * *', id: 104 },
|
||||
{ ...template, cronPattern: '0 9 * * 1,3', id: 105 },
|
||||
{ ...template, cronPattern: '0 9 * * 0,1,2,3,4,5,6', id: 106 },
|
||||
] satisfies TaskTemplate[];
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: templates });
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual(templates);
|
||||
});
|
||||
|
||||
it('keeps valid optional template icons from Market recommendation items', async () => {
|
||||
const templateWithIcon = { ...template, icon: 'github', id: 102 } satisfies TaskTemplate;
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: [templateWithIcon] });
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([templateWithIcon]);
|
||||
});
|
||||
|
||||
it('throws when Market recommendation items omit connectors', async () => {
|
||||
const marketTemplate = { ...template, connectors: undefined, id: 102 };
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: [marketTemplate] });
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
await expect(service.listDailyRecommend(['coding'])).rejects.toThrow(
|
||||
'Market recommendations returned malformed items',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when Market recommendation items include unknown connector identifiers', async () => {
|
||||
const validWithConnectors = {
|
||||
...template,
|
||||
connectors: [
|
||||
{ identifier: 'github', required: true, source: 'lobehub' },
|
||||
{ identifier: 'gmail', required: false, source: 'composio' },
|
||||
],
|
||||
id: 102,
|
||||
} satisfies TaskTemplate;
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({
|
||||
items: [
|
||||
validWithConnectors,
|
||||
{
|
||||
...template,
|
||||
connectors: [{ identifier: 'unknown-required', required: true, source: 'lobehub' }],
|
||||
id: 103,
|
||||
},
|
||||
{
|
||||
...template,
|
||||
connectors: [{ identifier: 'unknown-optional', required: false, source: 'composio' }],
|
||||
id: 104,
|
||||
},
|
||||
],
|
||||
});
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
await expect(service.listDailyRecommend(['coding'])).rejects.toThrow(
|
||||
'Market recommendations returned malformed items',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,150 +1,147 @@
|
||||
import type { TaskTemplate, TaskTemplateSkillSource } from '@lobechat/const';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { TaskTemplate, TaskTemplateConnector } from '@lobechat/const';
|
||||
import {
|
||||
TASK_TEMPLATE_FALLBACK_CATEGORIES,
|
||||
TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES,
|
||||
getComposioAppByIdentifier,
|
||||
getLobehubConnectorProviderById,
|
||||
INTEREST_AREA_KEYS,
|
||||
TASK_TEMPLATE_CATEGORIES,
|
||||
TASK_TEMPLATE_ICONS,
|
||||
TASK_TEMPLATE_RECOMMEND_COUNT,
|
||||
taskTemplates,
|
||||
TASK_TEMPLATE_RECOMMEND_MAX_COUNT,
|
||||
} from '@lobechat/const';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { composioEnv } from '@/config/composio';
|
||||
import { appEnv } from '@/envs/app';
|
||||
import { isTrustedClientEnabled } from '@/libs/trusted-client';
|
||||
import { MarketService } from '@/server/services/market';
|
||||
|
||||
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) {
|
||||
sources.add('lobehub');
|
||||
const clampRecommendationCount = (count?: number) =>
|
||||
Math.min(Math.max(1, count ?? TASK_TEMPLATE_RECOMMEND_COUNT), TASK_TEMPLATE_RECOMMEND_MAX_COUNT);
|
||||
|
||||
const getInstanceSeedScope = () =>
|
||||
process.env.VERCEL_PROJECT_ID || process.env.VERCEL_PROJECT_PRODUCTION_URL || appEnv.APP_URL;
|
||||
|
||||
export const createTaskTemplateRecommendationSeedKey = (
|
||||
userId: string,
|
||||
instanceSeedScope = getInstanceSeedScope(),
|
||||
) =>
|
||||
createHash('sha256')
|
||||
.update(`task-template-recommendation:v1:${instanceSeedScope}:${userId}`)
|
||||
.digest('base64url');
|
||||
|
||||
const isCronNumber = (value: string, max: number) => {
|
||||
if (!/^\d+$/.test(value)) return false;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return parsed >= 0 && parsed <= max;
|
||||
};
|
||||
|
||||
const isCronStep = (value: string, max: number) => {
|
||||
if (!/^\*\/\d+$/.test(value)) return false;
|
||||
const parsed = Number.parseInt(value.slice(2), 10);
|
||||
return parsed >= 1 && parsed <= max;
|
||||
};
|
||||
|
||||
const isCronNumberList = (value: string, max: number) =>
|
||||
value.split(',').every((item) => isCronNumber(item, max));
|
||||
|
||||
const isSupportedTaskTemplateCronPattern = (value: unknown): value is string => {
|
||||
if (typeof value !== 'string') return false;
|
||||
|
||||
const parts = value.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return false;
|
||||
|
||||
const [minute, hour, dayOfMonth, month, weekday] = parts;
|
||||
if (
|
||||
!(minute === '*' || isCronNumberList(minute, 59) || isCronStep(minute, 59)) ||
|
||||
!(hour === '*' || isCronNumberList(hour, 23) || isCronStep(hour, 24))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return sources;
|
||||
})();
|
||||
if (dayOfMonth !== '*' || month !== '*') return false;
|
||||
|
||||
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 weekday === '*' || isCronNumberList(weekday, 6);
|
||||
};
|
||||
|
||||
const taskTemplateConnectorSchema: z.ZodType<TaskTemplateConnector> = z
|
||||
.object({
|
||||
identifier: z.string(),
|
||||
required: z.boolean(),
|
||||
source: z.enum(['composio', 'lobehub']),
|
||||
})
|
||||
.refine(
|
||||
(connector) =>
|
||||
connector.source === 'lobehub'
|
||||
? !!getLobehubConnectorProviderById(connector.identifier)
|
||||
: !!getComposioAppByIdentifier(connector.identifier),
|
||||
{ message: 'Unknown task template connector' },
|
||||
);
|
||||
|
||||
const taskTemplateSchema: z.ZodType<TaskTemplate> = z.object({
|
||||
category: z.enum(TASK_TEMPLATE_CATEGORIES),
|
||||
connectors: z.array(taskTemplateConnectorSchema),
|
||||
cronPattern: z.string().refine(isSupportedTaskTemplateCronPattern, {
|
||||
message: 'Unsupported task template cron pattern',
|
||||
}),
|
||||
description: z.string(),
|
||||
icon: z.enum(TASK_TEMPLATE_ICONS).optional(),
|
||||
id: z.number().int(),
|
||||
identifier: z.string(),
|
||||
instruction: z.string(),
|
||||
interests: z.array(z.enum(INTEREST_AREA_KEYS)),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const taskTemplateRecommendationEnvelopeSchema = z.object({
|
||||
items: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
const parseTaskTemplateRecommendations = (value: unknown): TaskTemplate[] => {
|
||||
const envelope = taskTemplateRecommendationEnvelopeSchema.safeParse(value);
|
||||
if (!envelope.success) {
|
||||
throw new Error('Market recommendations returned no items array');
|
||||
}
|
||||
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]];
|
||||
const items = z.array(taskTemplateSchema).safeParse(envelope.data.items);
|
||||
if (!items.success) {
|
||||
throw new Error('Market recommendations returned malformed items');
|
||||
}
|
||||
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));
|
||||
return items.data;
|
||||
};
|
||||
|
||||
export class TaskTemplateService {
|
||||
constructor(private userId: string) {}
|
||||
private marketService: MarketService;
|
||||
|
||||
constructor(private userId: string) {
|
||||
this.marketService = new MarketService({ userInfo: { userId } });
|
||||
}
|
||||
|
||||
async listDailyRecommend(
|
||||
interestKeys: string[],
|
||||
options: {
|
||||
count?: number;
|
||||
enabledSkillSources?: ReadonlySet<TaskTemplateSkillSource>;
|
||||
excludeIds?: string[];
|
||||
now?: Date;
|
||||
excludeIds?: number[];
|
||||
locale?: string;
|
||||
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[]> {
|
||||
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);
|
||||
try {
|
||||
const result = await this.marketService.market.taskTemplates.getTaskTemplateRecommendations({
|
||||
count: clampRecommendationCount(options.count),
|
||||
excludeIds: options.excludeIds,
|
||||
interestKeys,
|
||||
locale: options.locale,
|
||||
refreshSeed: options.refreshSeed,
|
||||
...(isTrustedClientEnabled()
|
||||
? {}
|
||||
: { seedKey: createTaskTemplateRecommendationSeedKey(this.userId) }),
|
||||
});
|
||||
|
||||
const personalOnly = new Set<string>(TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES);
|
||||
|
||||
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));
|
||||
return parseTaskTemplateRecommendations(result);
|
||||
} catch (error) {
|
||||
console.error('[taskTemplate:listDailyRecommend] Market recommendations failed', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -220,7 +220,9 @@ describe('agentDocumentsRuntime auto-pin to task', () => {
|
||||
{ agentId: 'agent-1' },
|
||||
);
|
||||
|
||||
expect(result.content).toBe('Created document "Daily Brief" (agent-doc-assoc-id).');
|
||||
expect(result.content).toBe(
|
||||
'Created document "Daily Brief" (internal id: agent-doc-assoc-id).',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,7 +285,9 @@ describe('AgentDocumentsExecutionRuntime.createDocument', () => {
|
||||
expect(result.content).toContain(
|
||||
'https://app.example.com/agent/agent-1/docs/docs_document-row-id',
|
||||
);
|
||||
expect(result.content).toContain('Use id agent-doc-assoc-id for further edits');
|
||||
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 () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Glossary
|
||||
# Term Translation Reference
|
||||
|
||||
以下是一些词汇的固定翻译:
|
||||
> Looking for the product glossary? See [Glossary](/docs/glossary) for plain-language definitions of LobeHub's concepts and terms, organized by first letter.
|
||||
|
||||
The table below is a translation-consistency reference for contributors: the fixed translations for a few core terms.
|
||||
|
||||
| develop key | zh-CN (中文) | en-US(English) |
|
||||
| ----------- | ------------ | -------------- |
|
||||
@@ -1,7 +1,8 @@
|
||||
```markdown
|
||||
# 术语表
|
||||
# 术语翻译对照
|
||||
|
||||
以下是一些词汇的固定翻译:
|
||||
> 想找产品术语表?请见 [术语表](/docs/glossary),其中按首字母提供 LobeHub 概念与术语的通俗释义。
|
||||
|
||||
下表是面向贡献者的翻译一致性参考:若干核心术语的固定译法。
|
||||
|
||||
| 开发键 | zh-CN(中文) | en-US(英文) |
|
||||
| ---------- | ------------- | ------------- |
|
||||
@@ -10,4 +11,3 @@
|
||||
| page | 文稿 | Page |
|
||||
| topic | 话题 | Topic |
|
||||
| thread | 子话题 | Thread |
|
||||
```
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
title: Glossary
|
||||
description: Definitions of the core concepts and terms used across LobeHub, from agents and topics to skills, memory, models, and billing.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Glossary
|
||||
- Terminology
|
||||
- Concepts
|
||||
- Reference
|
||||
---
|
||||
|
||||
# Glossary
|
||||
|
||||
A quick reference for the words you'll meet across LobeHub. Terms are grouped by their first letter; common synonyms point to the main entry.
|
||||
|
||||
## A
|
||||
|
||||
### Agent
|
||||
|
||||
A configurable AI assistant with its own system role, model, skills, knowledge base, and memory. It is the core unit you chat with and assign tasks to, built to be reused across conversations rather than created for a single one. Also called an Assistant.
|
||||
|
||||
### Agent Builder
|
||||
|
||||
A built-in agent that creates and configures agents (or whole agent groups) for you from a plain-language description of what you want.
|
||||
|
||||
### Agent Group
|
||||
|
||||
See [Group](#group).
|
||||
|
||||
### Artifact
|
||||
|
||||
Substantial, self-contained content an agent produces during a conversation, such as a web app, SVG graphic, HTML page, diagram, document, or code file. Artifacts open in a dedicated preview panel where you can view, iterate on, and export them.
|
||||
|
||||
## B
|
||||
|
||||
### Branching
|
||||
|
||||
Turning a linear chat into a tree by starting a new conversation path from any message. Continuation mode keeps the earlier context, while standalone mode starts fresh. See also [Thread](#thread).
|
||||
|
||||
### Budget
|
||||
|
||||
The pool of credits available to spend, checked before each AI request. Personal and workspace budgets draw from separate credit sources, and a request is blocked when the budget is too low.
|
||||
|
||||
## C
|
||||
|
||||
### Channels
|
||||
|
||||
Per-agent connections that link an agent to an external messaging platform (Discord, Slack, Telegram, LINE, iMessage, QQ, WeChat, Feishu, and Lark) using your own bot, so anyone in that chat can talk to the agent. See also [Messenger](#messenger).
|
||||
|
||||
### Chat Mode
|
||||
|
||||
A per-conversation setting that switches an agent between Agent mode (can use tools, a runtime environment, and memory to work autonomously) and Chat mode (plain conversation with fewer tokens and no tool use).
|
||||
|
||||
### Claude Code
|
||||
|
||||
Anthropic's terminal coding agent that you can drive from the LobeHub desktop app. LobeHub runs the local `claude` CLI and renders its tasks, todos, skills, and tool calls as chat blocks.
|
||||
|
||||
### Cloud Sandbox
|
||||
|
||||
A secure, isolated cloud environment where an agent can run code, create files, and execute commands, returning real output instead of just code snippets.
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI's terminal coding agent that you can drive from the LobeHub desktop app. LobeHub runs the local `codex` CLI and renders its file changes, todos, and command output as chat blocks.
|
||||
|
||||
### Command Menu
|
||||
|
||||
A quick-action search overlay (Cmd/Ctrl + K) for jumping to agents, topics, settings, skills, and actions with fuzzy search.
|
||||
|
||||
### Credits
|
||||
|
||||
The unit LobeHub uses to measure AI usage. Different models consume different amounts per request, and credits are drawn from your free allowance, subscription, referral rewards, and top-ups.
|
||||
|
||||
### Custom API
|
||||
|
||||
Connecting your own model provider API keys (OpenAI, Anthropic, OpenRouter, and others) instead of using built-in credits. Available on paid plans.
|
||||
|
||||
## D
|
||||
|
||||
### Data Analytics
|
||||
|
||||
A personal usage dashboard showing days of use, agent, topic, and message counts, an activity heatmap, and model and agent breakdowns, with a shareable stats image.
|
||||
|
||||
## E
|
||||
|
||||
### Embedding
|
||||
|
||||
A numeric (vector) representation of text that lets content be matched by meaning. LobeHub embeds your files so they can be found through semantic search.
|
||||
|
||||
## F
|
||||
|
||||
### Feature Flags
|
||||
|
||||
Switches that turn features on or off in self-hosted deployments, set through the `FEATURE_FLAGS` environment variable.
|
||||
|
||||
### File Chunk
|
||||
|
||||
A segment of a larger file. Files are split into chunks and embedded so their content can be searched and used in conversation. Also called chunking.
|
||||
|
||||
### File Upload
|
||||
|
||||
Adding documents, code, images, and media to a conversation by drag-and-drop, click, or paste, so an agent can read and reference them. Uploaded files are saved to Resources.
|
||||
|
||||
### Fork
|
||||
|
||||
Creating your own derivative version of a marketplace agent. A fork keeps attribution to the original creator.
|
||||
|
||||
## G
|
||||
|
||||
### Group
|
||||
|
||||
A shared space where several agents collaborate on a task. A group can run automatically under an Orchestrator or be driven by `@mentioning` members, and supports modes such as sequential, parallel, iterative, and debate. Also called an Agent Group or Group Chat.
|
||||
|
||||
### GTD Tools
|
||||
|
||||
A built-in skill that brings the Getting Things Done method into chat, letting an agent capture, track, complete, and reschedule tasks from natural language.
|
||||
|
||||
## I
|
||||
|
||||
### Image Generation
|
||||
|
||||
A built-in workspace that turns text prompts into images using models such as DALL-E 3 and Flux, with controls for aspect ratio, resolution, quality, and seed. Output is saved to Resources.
|
||||
|
||||
### Inbox
|
||||
|
||||
See [Lobe AI](#lobe-ai).
|
||||
|
||||
### Integrations
|
||||
|
||||
Connections from an agent to external services, including MCP servers and OAuth-connected apps such as Linear, Microsoft, and Twitter.
|
||||
|
||||
## K
|
||||
|
||||
### Knowledge Base
|
||||
|
||||
A collection of uploaded files, stored as searchable vector chunks, that an agent can reference so it answers from your data rather than general training alone. Shown in the app as a Library.
|
||||
|
||||
## L
|
||||
|
||||
### Library
|
||||
|
||||
See [Knowledge Base](#knowledge-base).
|
||||
|
||||
### Lobe AI
|
||||
|
||||
The built-in, zero-setup general assistant that is ready to chat, research, write, and code out of the box, without creating a custom agent. Also called the Inbox.
|
||||
|
||||
### LobeHub Provider
|
||||
|
||||
LobeHub's built-in model provider that gives you access to mainstream models without configuring your own API keys, billed with credits.
|
||||
|
||||
## M
|
||||
|
||||
### MCP (Model Context Protocol)
|
||||
|
||||
An open standard for connecting agents to external tools, data, and services. LobeHub acts as the MCP client; MCP servers run locally (STDIO) or remotely (Streamable HTTP) and expose tools, resources, and prompts.
|
||||
|
||||
### Memory
|
||||
|
||||
Personal context (preferences, role, work habits, and experiences) that agents extract from conversations and recall later. LobeHub keeps memory white-box: you can view, edit, delete, and add entries, and agents remember only what you approve.
|
||||
|
||||
### Messenger
|
||||
|
||||
A no-setup way to chat with your own LobeHub agents through the official `@LobeHub` bot on Telegram, Slack, and Discord. Unlike Channels, only you talk to the bot.
|
||||
|
||||
### Model
|
||||
|
||||
The large language (or image and video) model that powers an agent's responses, such as GPT-4o, Claude, or Gemini. You can set a default model per agent and switch models mid-conversation.
|
||||
|
||||
### Model Provider
|
||||
|
||||
A service that supplies models, such as OpenAI, Anthropic, Google, or local options like Ollama. LobeHub integrates many providers, configured under Settings. Also called a Provider.
|
||||
|
||||
### Moderator
|
||||
|
||||
See [Orchestrator](#orchestrator).
|
||||
|
||||
## N
|
||||
|
||||
### Notebook
|
||||
|
||||
A side panel that stores documents (notes, reports, research) tied to the current topic and lists the pages linked to it.
|
||||
|
||||
## O
|
||||
|
||||
### OCR
|
||||
|
||||
Reading and transcribing text from images, screenshots, handwriting, and documents. Short for optical character recognition.
|
||||
|
||||
### Orchestrator
|
||||
|
||||
The coordinator in a group that understands your goal, assigns work to the right member agents, orders their contributions, and summarizes the result. Also called the Moderator or Supervisor.
|
||||
|
||||
## P
|
||||
|
||||
### Page
|
||||
|
||||
A long-form document (文稿) you write and edit in LobeHub, with Markdown, live preview, version history, and auto-save. Agents can draft and revise pages directly through the Pages agent.
|
||||
|
||||
### Plan
|
||||
|
||||
A subscription tier that sets your monthly credit allowance and feature access. Tiers range from a free entry plan to paid plans with more credits and premium features.
|
||||
|
||||
### Plugin
|
||||
|
||||
See [Skill](#skill).
|
||||
|
||||
### Provider
|
||||
|
||||
See [Model Provider](#model-provider).
|
||||
|
||||
## R
|
||||
|
||||
### RAG
|
||||
|
||||
Retrieval-augmented generation. Your question is embedded, compared to stored file chunks by meaning, and the most relevant chunks are given to the agent so it answers from your data. Also called semantic search.
|
||||
|
||||
### Referral
|
||||
|
||||
A reward program where you share a code or link; you and the person you invite both receive bonus credits after they sign up and make a payment.
|
||||
|
||||
### Resources
|
||||
|
||||
The area that holds all your files, folders, pages, and knowledge bases, with semantic search. Also called the Resource Library.
|
||||
|
||||
### Risk Control
|
||||
|
||||
Safeguards that detect and block abuse such as bulk fake registrations and referral fraud.
|
||||
|
||||
## S
|
||||
|
||||
### Sandbox
|
||||
|
||||
See [Cloud Sandbox](#cloud-sandbox).
|
||||
|
||||
### Scheduled Task
|
||||
|
||||
A task that runs an agent on your prompt automatically on a schedule, for example hourly, daily, or weekly. Each run starts without prior chat context, so the prompt must be self-contained.
|
||||
|
||||
### Self-hosting
|
||||
|
||||
Running open-source LobeHub on your own infrastructure (Docker, Vercel, or other platforms) for full control of your data. Commercial features such as credits and subscriptions are inactive in self-hosted deployments.
|
||||
|
||||
### Skill
|
||||
|
||||
An installable capability that gives an agent new tools, such as web search, code execution, or a third-party connector. Skills are the product's user-facing name for plugins and are managed in the Skill Store.
|
||||
|
||||
### Skill Store
|
||||
|
||||
The in-product browser for finding, installing, and configuring skills, including built-in tools, community MCP servers, and custom servers you add yourself.
|
||||
|
||||
### Spend
|
||||
|
||||
A record of credits consumed by a request, itemized by model, token usage, and source (chat, image, bot, API, or scheduled run).
|
||||
|
||||
### STT (Speech-to-Text)
|
||||
|
||||
Voice input that converts your spoken words into text in the message box before sending.
|
||||
|
||||
### Sub-agents
|
||||
|
||||
Scoped helper agents that a coding agent (Claude Code or Codex) spawns to handle parallel or isolated work. Their threads render separately within the conversation.
|
||||
|
||||
### Subscription
|
||||
|
||||
A recurring plan, billed monthly or yearly, that grants a credit allowance and feature access. See also [Plan](#plan).
|
||||
|
||||
### Subtopic
|
||||
|
||||
See [Thread](#thread).
|
||||
|
||||
### System Role
|
||||
|
||||
The instruction that defines an agent's personality, expertise, and behavior. It is the most important part of an agent's configuration. Also called the system prompt or agent profile.
|
||||
|
||||
## T
|
||||
|
||||
### Task
|
||||
|
||||
A unit of agent work with a status, instructions, an assignee, and an activity log. Tasks run in the background, can repeat on a schedule, and can produce artifacts. Statuses move from backlog to in progress to review to done.
|
||||
|
||||
### Thread
|
||||
|
||||
A branched sub-conversation spun off from a message in a topic, optionally carrying the topic's context. Also called a subtopic (子话题). See also [Branching](#branching).
|
||||
|
||||
### Tool
|
||||
|
||||
A function an agent calls to take an action, such as searching the web, generating an image, or managing files. Skills and MCP servers provide tools.
|
||||
|
||||
### Topic
|
||||
|
||||
A single conversation thread with an agent or group. Topics can be searched, renamed, favorited, archived, shared, and grouped by time.
|
||||
|
||||
### Top-up
|
||||
|
||||
Buying extra credits on demand. Auto top-up can refill your balance automatically when it runs low. Top-up credits are used after free, subscription, and referral credits.
|
||||
|
||||
### Translation Assistant
|
||||
|
||||
A model you assign in settings to translate conversation messages into a target language with one click.
|
||||
|
||||
### TTS (Text-to-Speech)
|
||||
|
||||
Voice output that reads an agent's responses aloud, with selectable voices and cached playback.
|
||||
|
||||
## V
|
||||
|
||||
### Video Generation
|
||||
|
||||
A built-in workspace that turns text or images into video using models such as Sora, Veo, and Kling, with controls for start and end frames, duration, and resolution.
|
||||
|
||||
### Vision
|
||||
|
||||
The ability of vision-enabled models to see and understand uploaded images, including describing them, comparing them, answering questions about them, and extracting text (OCR).
|
||||
|
||||
## W
|
||||
|
||||
### Web Search
|
||||
|
||||
Lets an agent retrieve up-to-date information from the internet and cite its sources. You can set it to automatic, always on, or off.
|
||||
|
||||
### Working Directory
|
||||
|
||||
The folder a coding agent (Claude Code or Codex) treats as the project root for a session. Changing it mid-conversation starts a new session.
|
||||
|
||||
### Workspace
|
||||
|
||||
Has two related meanings. (1) The contextual side panel that surfaces a conversation's artifacts, files, pages, and tool details. (2) A shared team space with its own subscription, shared credits, agents, and knowledge bases, billed separately from members' personal accounts.
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
title: 术语表
|
||||
description: LobeHub 中常见概念与术语的释义,涵盖助理、话题、技能、记忆、模型与计费等。
|
||||
tags:
|
||||
- LobeHub
|
||||
- 术语表
|
||||
- 术语
|
||||
- 概念
|
||||
- 参考
|
||||
---
|
||||
|
||||
# 术语表
|
||||
|
||||
LobeHub 中常见术语的速查表。条目按英文首字母分组,常见的同义词会指向主条目。
|
||||
|
||||
## A
|
||||
|
||||
### Agent(助理)
|
||||
|
||||
一个可配置的 AI 助理,拥有自己的系统角色、模型、技能、知识库和记忆。它是你对话和分配任务的基本单位,设计为可在多次对话中复用,而非为单次对话临时创建。也称为 Assistant。
|
||||
|
||||
### Agent Builder(助理构建器)
|
||||
|
||||
一个内置助理,可根据你用自然语言描述的需求,自动为你创建并配置助理(或整个助理群组)。
|
||||
|
||||
### Agent Group
|
||||
|
||||
参见 [Group](#group-群组)。
|
||||
|
||||
### Artifact(制品)
|
||||
|
||||
助理在对话中生成的较为完整、独立的内容,例如 Web 应用、SVG 图形、HTML 页面、图表、文档或代码文件。制品会在独立的预览面板中打开,你可以在其中查看、迭代并导出。
|
||||
|
||||
## B
|
||||
|
||||
### Branching(分支)
|
||||
|
||||
从任意一条消息开启新的对话路径,把线性对话变成树状结构。延续模式会保留之前的上下文,独立模式则从头开始。另见 [Thread](#thread-子话题)。
|
||||
|
||||
### Budget(预算)
|
||||
|
||||
可供消耗的额度池,会在每次 AI 请求前检查。个人预算与工作空间预算来自不同的额度来源,当预算不足时请求会被拦截。
|
||||
|
||||
## C
|
||||
|
||||
### Channels(渠道)
|
||||
|
||||
面向单个助理的连接,使用你自己的机器人把助理接入外部聊天平台(Discord、Slack、Telegram、LINE、iMessage、QQ、微信、飞书、Lark),让该聊天中的任何人都能与助理对话。另见 [Messenger](#messenger)。
|
||||
|
||||
### Chat Mode(对话模式)
|
||||
|
||||
一项按对话设置的开关,可在 Agent 模式(可调用工具、运行环境和记忆以自主完成任务)与 Chat 模式(仅普通对话,消耗更少 token,不使用工具)之间切换。
|
||||
|
||||
### Claude Code
|
||||
|
||||
Anthropic 的终端编码助理,可在 LobeHub 桌面端驱动。LobeHub 会在本地运行 `claude` CLI,并把它的任务、待办、技能和工具调用渲染为对话区块。
|
||||
|
||||
### Cloud Sandbox(云沙箱)
|
||||
|
||||
一个安全隔离的云端环境,助理可在其中运行代码、生成文件、执行命令,返回真实结果而不仅仅是代码片段。
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI 的终端编码助理,可在 LobeHub 桌面端驱动。LobeHub 会在本地运行 `codex` CLI,并把它的文件改动、待办和命令输出渲染为对话区块。
|
||||
|
||||
### Command Menu(命令菜单)
|
||||
|
||||
一个快捷操作搜索浮层(Cmd/Ctrl + K),可通过模糊搜索快速跳转到助理、话题、设置、技能和各类操作。
|
||||
|
||||
### Credits(额度)
|
||||
|
||||
LobeHub 用于计量 AI 用量的单位。不同模型每次请求消耗的额度不同,额度来自你的免费额度、订阅、推荐奖励和充值。
|
||||
|
||||
### Custom API(自定义 API)
|
||||
|
||||
连接你自己的模型服务商 API Key(OpenAI、Anthropic、OpenRouter 等),以替代使用内置额度。付费套餐可用。
|
||||
|
||||
## D
|
||||
|
||||
### Data Analytics(数据统计)
|
||||
|
||||
个人用量看板,展示使用天数,助理、话题与消息数量,活跃度热力图,以及模型和助理的使用分布,并可生成可分享的统计图。
|
||||
|
||||
## E
|
||||
|
||||
### Embedding(嵌入向量)
|
||||
|
||||
文本的数值(向量)表示,使内容可按语义匹配。LobeHub 会为你的文件生成嵌入向量,以便通过语义搜索找到它们。
|
||||
|
||||
## F
|
||||
|
||||
### Feature Flags(功能开关)
|
||||
|
||||
在自托管部署中开启或关闭功能的开关,通过 `FEATURE_FLAGS` 环境变量设置。
|
||||
|
||||
### File Chunk(文件分块)
|
||||
|
||||
较大文件的片段。文件会被切分为分块并生成嵌入向量,从而可被搜索并用于对话。也称为分块(chunking)。
|
||||
|
||||
### File Upload(文件上传)
|
||||
|
||||
通过拖拽、点击或粘贴把文档、代码、图片和媒体加入对话,供助理读取和引用。上传的文件会保存到资源库。
|
||||
|
||||
### Fork(复刻)
|
||||
|
||||
基于市场中的某个助理创建你自己的衍生版本。复刻会保留对原作者的署名。
|
||||
|
||||
## G
|
||||
|
||||
### Group(群组)
|
||||
|
||||
多个助理协作完成任务的共享空间。群组可由编排者(Orchestrator)自动协调,也可通过 @提及成员来驱动,支持顺序、并行、迭代和辩论等模式。也称为 Agent Group 或群聊。
|
||||
|
||||
### GTD Tools(GTD 工具)
|
||||
|
||||
一个内置技能,把 GTD(Getting Things Done)方法带入对话,让助理可通过自然语言捕获、跟踪、完成和重新安排任务。
|
||||
|
||||
## I
|
||||
|
||||
### Image Generation(图像生成)
|
||||
|
||||
内置的文生图工作区,使用 DALL-E 3、Flux 等模型,可调节宽高比、分辨率、质量和种子。生成结果会保存到资源库。
|
||||
|
||||
### Inbox
|
||||
|
||||
参见 [Lobe AI](#lobe-ai)。
|
||||
|
||||
### Integrations(集成)
|
||||
|
||||
助理与外部服务的连接,包括 MCP 服务器以及通过 OAuth 连接的应用(如 Linear、Microsoft、Twitter)。
|
||||
|
||||
## K
|
||||
|
||||
### Knowledge Base(知识库)
|
||||
|
||||
上传文件的集合,以可检索的向量分块形式存储,供助理引用,使其基于你的数据而非仅凭通用训练作答。在应用中显示为「资料库(Library)」。
|
||||
|
||||
## L
|
||||
|
||||
### Library
|
||||
|
||||
参见 [Knowledge Base](#knowledge-base-知识库)。
|
||||
|
||||
### Lobe AI
|
||||
|
||||
内置、零配置的通用助理,开箱即可对话、研究、写作和编码,无需创建自定义助理。也称为 Inbox。
|
||||
|
||||
### LobeHub Provider(LobeHub 服务商)
|
||||
|
||||
LobeHub 内置的模型服务商,让你无需配置自己的 API Key 即可使用主流模型,按额度计费。
|
||||
|
||||
## M
|
||||
|
||||
### MCP(模型上下文协议)
|
||||
|
||||
连接助理与外部工具、数据和服务的开放标准。LobeHub 作为 MCP 客户端;MCP 服务器在本地(STDIO)或远程(Streamable HTTP)运行,对外暴露工具、资源和提示词。
|
||||
|
||||
### Memory(记忆)
|
||||
|
||||
助理从对话中提取并在日后回忆的个人上下文(偏好、角色、工作习惯和经历)。LobeHub 的记忆是白盒的:你可以查看、编辑、删除和新增条目,助理只会记住你认可的内容。
|
||||
|
||||
### Messenger
|
||||
|
||||
一种免配置方式,通过官方 `@LobeHub` 机器人在 Telegram、Slack 和 Discord 上与你自己的 LobeHub 助理对话。与渠道(Channels)不同,只有你本人与该机器人对话。
|
||||
|
||||
### Model(模型)
|
||||
|
||||
驱动助理回复的大语言模型(或图像、视频模型),例如 GPT-4o、Claude 或 Gemini。你可为每个助理设置默认模型,并在对话中途切换模型。
|
||||
|
||||
### Model Provider(模型服务商)
|
||||
|
||||
提供模型的服务,例如 OpenAI、Anthropic、Google,或 Ollama 等本地方案。LobeHub 集成了众多服务商,可在设置中配置。也称为 Provider。
|
||||
|
||||
### Moderator
|
||||
|
||||
参见 [Orchestrator](#orchestrator-编排者)。
|
||||
|
||||
## N
|
||||
|
||||
### Notebook(笔记本)
|
||||
|
||||
一个侧边面板,存储与当前话题关联的文档(笔记、报告、研究),并列出关联到该话题的文稿。
|
||||
|
||||
## O
|
||||
|
||||
### OCR(文字识别)
|
||||
|
||||
从图片、截图、手写内容和文档中读取并转写文字。是 optical character recognition 的缩写。
|
||||
|
||||
### Orchestrator(编排者)
|
||||
|
||||
群组中的协调者,理解你的目标,把工作分配给合适的成员助理,安排发言顺序并总结结果。也称为 Moderator(主持者)或 Supervisor(主管)。
|
||||
|
||||
## P
|
||||
|
||||
### Page(文稿)
|
||||
|
||||
你在 LobeHub 中撰写和编辑的长文文档,支持 Markdown、实时预览、版本历史和自动保存。助理可通过 Pages 助理直接起草和修改文稿。
|
||||
|
||||
### Plan(套餐)
|
||||
|
||||
决定你每月额度与功能权限的订阅档位。档位从免费入门套餐到提供更多额度和高级功能的付费套餐不等。
|
||||
|
||||
### Plugin(插件)
|
||||
|
||||
参见 [Skill](#skill-技能)。
|
||||
|
||||
### Provider
|
||||
|
||||
参见 [Model Provider](#model-provider-模型服务商)。
|
||||
|
||||
## R
|
||||
|
||||
### RAG(检索增强生成)
|
||||
|
||||
你的问题会被转为嵌入向量,按语义与存储的文件分块比对,最相关的分块会交给助理,使其基于你的数据作答。也称为语义搜索。
|
||||
|
||||
### Referral(推荐)
|
||||
|
||||
一种奖励计划,你分享推荐码或链接;当受邀者注册并完成付费后,你和受邀者都会获得额度奖励。
|
||||
|
||||
### Resources(资源库)
|
||||
|
||||
存放你全部文件、文件夹、文稿和知识库的区域,支持语义搜索。也称为 Resource Library。
|
||||
|
||||
### Risk Control(风控)
|
||||
|
||||
检测并拦截滥用行为(如批量虚假注册和推荐作弊)的安全机制。
|
||||
|
||||
## S
|
||||
|
||||
### Sandbox
|
||||
|
||||
参见 [Cloud Sandbox](#cloud-sandbox-云沙箱)。
|
||||
|
||||
### Scheduled Task(定时任务)
|
||||
|
||||
按计划(如每小时、每天或每周)自动以你的提示词运行助理的任务。每次运行都不带先前的对话上下文,因此提示词必须自成一体。
|
||||
|
||||
### Self-hosting(自托管)
|
||||
|
||||
在你自己的基础设施(Docker、Vercel 或其他平台)上运行开源版 LobeHub,从而完全掌控数据。额度、订阅等商业功能在自托管部署中不生效。
|
||||
|
||||
### Skill(技能)
|
||||
|
||||
为助理增加新工具的可安装能力,例如联网搜索、代码执行或第三方连接器。技能是产品中「插件」面向用户的叫法,在技能商店中管理。
|
||||
|
||||
### Skill Store(技能商店)
|
||||
|
||||
用于查找、安装和配置技能的产品内浏览器,涵盖内置工具、社区 MCP 服务器以及你自行添加的自定义服务器。
|
||||
|
||||
### Spend(消耗记录)
|
||||
|
||||
单次请求消耗额度的记录,按模型、token 用量和来源(对话、图像、机器人、API 或定时运行)分项列出。
|
||||
|
||||
### STT(语音转文字)
|
||||
|
||||
语音输入,在发送前把你说的话转写为消息框中的文字。
|
||||
|
||||
### Sub-agents(子助理)
|
||||
|
||||
编码助理(Claude Code 或 Codex)派生出的、用于处理并行或独立工作的受限助理。它们的会话会在对话中单独渲染。
|
||||
|
||||
### Subscription(订阅)
|
||||
|
||||
按月或按年计费的周期性套餐,提供额度配额和功能权限。另见 [Plan](#plan-套餐)。
|
||||
|
||||
### Subtopic
|
||||
|
||||
参见 [Thread](#thread-子话题)。
|
||||
|
||||
### System Role(系统角色)
|
||||
|
||||
定义助理性格、专长和行为的指令,是助理配置中最重要的部分。也称为系统提示词或助理简介。
|
||||
|
||||
## T
|
||||
|
||||
### Task(任务)
|
||||
|
||||
具有状态、说明、负责人和活动记录的助理工作单元。任务在后台运行,可按计划重复执行,并可产出制品。状态依次为待办、进行中、待审核、已完成。
|
||||
|
||||
### Thread(子话题)
|
||||
|
||||
从话题中的某条消息派生出的分支子对话,可选择是否带上该话题的上下文。也称为 subtopic(子话题)。另见 [Branching](#branching-分支)。
|
||||
|
||||
### Tool(工具)
|
||||
|
||||
助理为执行操作而调用的函数,例如联网搜索、生成图像或管理文件。技能和 MCP 服务器都会提供工具。
|
||||
|
||||
### Topic(话题)
|
||||
|
||||
与某个助理或群组的单次对话线程。话题可被搜索、重命名、收藏、归档、分享,并按时间分组。
|
||||
|
||||
### Top-up(充值)
|
||||
|
||||
按需购买额外额度。自动充值可在余额过低时自动补充。充值额度在免费、订阅和推荐额度之后才被消耗。
|
||||
|
||||
### Translation Assistant(翻译助理)
|
||||
|
||||
你在设置中指定的一个模型,可一键把对话消息翻译为目标语言。
|
||||
|
||||
### TTS(文字转语音)
|
||||
|
||||
语音输出,朗读助理的回复,可选择语音并缓存播放。
|
||||
|
||||
## V
|
||||
|
||||
### Video Generation(视频生成)
|
||||
|
||||
内置的文 / 图生视频工作区,使用 Sora、Veo、Kling 等模型,可设置首尾帧、时长和分辨率。
|
||||
|
||||
### Vision(视觉)
|
||||
|
||||
支持视觉的模型「看懂」上传图片的能力,包括描述、比较、就图片作答以及提取文字(OCR)。
|
||||
|
||||
## W
|
||||
|
||||
### Web Search(联网搜索)
|
||||
|
||||
让助理从互联网获取最新信息并标注来源。可设置为自动、始终开启或关闭。
|
||||
|
||||
### Working Directory(工作目录)
|
||||
|
||||
编码助理(Claude Code 或 Codex)在一次会话中视为项目根目录的文件夹。在对话中途更改它会开启一个新会话。
|
||||
|
||||
### Workspace(工作空间)
|
||||
|
||||
有两层相关含义。(1) 展示当前对话的制品、文件、文稿和工具详情的上下文侧边面板。(2) 拥有独立订阅、共享额度、助理和知识库的团队共享空间,与成员的个人账户分开计费。
|
||||
+341
-169
@@ -1,212 +1,384 @@
|
||||
/**
|
||||
* Mock data for Discover/Community module
|
||||
* 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.
|
||||
*/
|
||||
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 mockAssistantList: AssistantListResponse = {
|
||||
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',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
export const mockAssistantCategories = [
|
||||
{ id: 'general', name: 'General' },
|
||||
{ id: 'programming', name: 'Programming' },
|
||||
{ id: 'copywriting', name: 'Copywriting' },
|
||||
{ id: 'education', name: 'Education' },
|
||||
{ category: 'general', count: 12 },
|
||||
{ category: 'programming', count: 10 },
|
||||
{ category: 'academic', count: 8 },
|
||||
{ category: 'copywriting', count: 6 },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// Model Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockModelList: ModelListResponse = {
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Provider Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockProviderList: ProviderListResponse = {
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MCP Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockMcpList: McpListResponse = {
|
||||
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 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,
|
||||
};
|
||||
|
||||
export const mockMcpCategories = [
|
||||
{ id: 'search', name: 'Search' },
|
||||
{ id: 'file', name: 'File' },
|
||||
{ id: 'database', name: 'Database' },
|
||||
{ id: 'utility', name: 'Utility' },
|
||||
{ category: 'business', count: 7 },
|
||||
{ category: 'developer', count: 5 },
|
||||
{ category: 'productivity', count: 3 },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// 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),
|
||||
}));
|
||||
|
||||
+312
-145
@@ -1,179 +1,346 @@
|
||||
/**
|
||||
* Mock handlers for Discover/Community API endpoints
|
||||
* Mock handlers for Discover/Community API endpoints.
|
||||
*/
|
||||
import type { Route } from 'playwright';
|
||||
import type { Request, Route } from 'playwright';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { type MockHandler, createTrpcResponse } from '../index';
|
||||
import type { MockHandler } from '../index';
|
||||
import {
|
||||
mockAssistantCategories,
|
||||
mockAssistantDetails,
|
||||
mockAssistantItems,
|
||||
mockAssistantList,
|
||||
mockMcpCategories,
|
||||
mockMcpDetails,
|
||||
mockMcpItems,
|
||||
mockMcpList,
|
||||
mockModelDetails,
|
||||
mockModelItems,
|
||||
mockModelList,
|
||||
mockProviderDetails,
|
||||
mockProviderItems,
|
||||
mockProviderList,
|
||||
} from './data';
|
||||
|
||||
// ============================================
|
||||
// Helper to parse tRPC batch requests
|
||||
// ============================================
|
||||
interface IdentifierEntry {
|
||||
identifier: string;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
function parseTrpcUrl(url: string): { input?: Record<string, unknown>; procedure: string } {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const SUCCESS_RESPONSE = { success: true };
|
||||
|
||||
// Extract procedure name from path like /trpc/lambda.market.getAssistantList
|
||||
const procedureMatch = pathname.match(/lambda\.market\.(\w+)/);
|
||||
const procedure = procedureMatch ? procedureMatch[1] : '';
|
||||
const createTrpcResult = <T>(data: T) => ({
|
||||
result: {
|
||||
data: superjson.serialize(data),
|
||||
},
|
||||
});
|
||||
|
||||
// Parse input from query string
|
||||
let input: Record<string, unknown> | undefined;
|
||||
const inputParam = urlObj.searchParams.get('input');
|
||||
if (inputParam) {
|
||||
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 {
|
||||
input = JSON.parse(inputParam);
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return { input, procedure };
|
||||
}
|
||||
try {
|
||||
return request.postDataJSON();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Mock Handlers
|
||||
// ============================================
|
||||
const getProcedureInputs = (request: Request, url: URL, count: number): unknown[] => {
|
||||
const rawInput = parseRequestInput(request, url);
|
||||
const isBatch = url.searchParams.get('batch') === '1' || count > 1;
|
||||
|
||||
/**
|
||||
* Handler for assistant list endpoint
|
||||
*/
|
||||
const assistantListHandler: MockHandler = {
|
||||
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: 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: createTrpcResponse(mockAssistantList),
|
||||
body: isBatch ? createTrpcBatchResponse(responses) : createTrpcResponse(responses[0]),
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Set-Cookie': 'mp_token_status=active; Path=/; SameSite=Lax',
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
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.**',
|
||||
pattern: '**/trpc/lambda/**',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Export all handlers
|
||||
// ============================================
|
||||
|
||||
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,
|
||||
];
|
||||
export const discoverHandlers: MockHandler[] = [marketHandler];
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Type definitions for Discover mock data
|
||||
* These mirror the actual types from the application
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
export interface ListResponse<T> {
|
||||
currentPage: number;
|
||||
items: T[];
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@@ -19,21 +22,25 @@ 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 interface AssistantListResponse {
|
||||
items: DiscoverAssistantItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
export type AssistantListResponse = ListResponse<DiscoverAssistantItem>;
|
||||
|
||||
// ============================================
|
||||
// Model Types
|
||||
@@ -46,19 +53,17 @@ export interface DiscoverModelItem {
|
||||
vision?: boolean;
|
||||
};
|
||||
contextWindowTokens: number;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
displayName: string;
|
||||
id: string;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
identifier: string;
|
||||
providerCount: number;
|
||||
providers: string[];
|
||||
releasedAt?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ModelListResponse {
|
||||
items: DiscoverModelItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
export type ModelListResponse = ListResponse<DiscoverModelItem>;
|
||||
|
||||
// ============================================
|
||||
// Provider Types
|
||||
@@ -66,33 +71,50 @@ export interface ModelListResponse {
|
||||
|
||||
export interface DiscoverProviderItem {
|
||||
description: string;
|
||||
id: string;
|
||||
logo?: string;
|
||||
identifier: string;
|
||||
modelCount: number;
|
||||
models: string[];
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface ProviderListResponse {
|
||||
items: DiscoverProviderItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
export type ProviderListResponse = ListResponse<DiscoverProviderItem>;
|
||||
|
||||
// ============================================
|
||||
// MCP Types
|
||||
// ============================================
|
||||
|
||||
export interface DiscoverMcpItem {
|
||||
author: string;
|
||||
avatar: string;
|
||||
author?: string;
|
||||
capabilities: {
|
||||
prompts: boolean;
|
||||
resources: boolean;
|
||||
tools: boolean;
|
||||
};
|
||||
category: string;
|
||||
connectionType?: 'http' | 'stdio';
|
||||
createdAt: string;
|
||||
description: string;
|
||||
github?: {
|
||||
stars?: number;
|
||||
url: string;
|
||||
};
|
||||
icon?: string;
|
||||
identifier: string;
|
||||
installationMethods?: string;
|
||||
installCount?: number;
|
||||
title: string;
|
||||
isClaimed?: boolean;
|
||||
isFeatured?: boolean;
|
||||
isOfficial?: boolean;
|
||||
isValidated?: boolean;
|
||||
manifestUrl: string;
|
||||
name: string;
|
||||
promptsCount?: number;
|
||||
resourcesCount?: number;
|
||||
toolsCount?: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface McpListResponse {
|
||||
items: DiscoverMcpItem[];
|
||||
pagination: PaginationInfo;
|
||||
export interface McpListResponse extends ListResponse<DiscoverMcpItem> {
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
+16
-4
@@ -5,6 +5,7 @@
|
||||
* 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';
|
||||
|
||||
@@ -124,12 +125,23 @@ export class MockManager {
|
||||
/**
|
||||
* Create a JSON response for tRPC endpoints
|
||||
*/
|
||||
export function createTrpcResponse<T>(data: T): string {
|
||||
return JSON.stringify({
|
||||
export function createTrpcResult<T>(data: T) {
|
||||
return {
|
||||
result: {
|
||||
data,
|
||||
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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-2
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -106,8 +107,16 @@ Before(async function (this: CustomWorld, { pickle }) {
|
||||
);
|
||||
console.log(`\n📝 Running: ${pickle.name}${testId ? ` (${testId.name.replace('@', '')})` : ''}`);
|
||||
|
||||
// Setup API mocks before any page navigation
|
||||
// await mockManager.setup(this.page);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Set cached session cookies to skip login
|
||||
if (sessionCookies.length > 0) {
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"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}} قاعدة معرفة",
|
||||
@@ -165,7 +168,9 @@
|
||||
"extendParams.urlContext.title": "استخراج محتوى رابط الويب",
|
||||
"followUpPlaceholder": "متابعة. @ لإسناد مهام لوكلاء آخرين.",
|
||||
"followUpPlaceholderHeterogeneous": "تابع.",
|
||||
"gatewayMode.title": "وضع البوابة",
|
||||
"gatewayMode.beta": "تجريبي",
|
||||
"gatewayMode.cardTitle": "وضع بوابة الوكيل",
|
||||
"gatewayMode.desc": "قم بتشغيل الوكلاء في السحابة من خلال بوابة الوكلاء الخاصة بـ LobeHub. تستمر المهام في العمل حتى بعد إغلاق الصفحة.",
|
||||
"group.desc": "ادفع المهمة للأمام مع عدة وكلاء في مساحة مشتركة واحدة.",
|
||||
"group.memberTooltip": "يوجد {{count}} عضو في المجموعة",
|
||||
"group.orchestratorThinking": "المنسق يفكر...",
|
||||
@@ -877,6 +882,7 @@
|
||||
"toolAuth.authorize": "تفويض",
|
||||
"toolAuth.authorizing": "جارٍ التفويض...",
|
||||
"toolAuth.hint": "بدون التفويض أو الإعداد، قد لا تعمل المهارات. قد يؤدي ذلك إلى تقييد الوكيل أو حدوث أخطاء.",
|
||||
"toolAuth.remove": "إزالة",
|
||||
"toolAuth.signIn": "تسجيل الدخول",
|
||||
"toolAuth.title": "تفويض المهارات لهذا الوكيل",
|
||||
"topic.checkOpenNewTopic": "هل تريد بدء موضوع جديد؟",
|
||||
@@ -1118,6 +1124,5 @@
|
||||
"workingPanel.skills.title": "المهارات",
|
||||
"workingPanel.space": "مسافة",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "أنت",
|
||||
"zenMode": "وضع التركيز"
|
||||
"you": "أنت"
|
||||
}
|
||||
|
||||
@@ -3,18 +3,26 @@
|
||||
"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.runningTasks": "المهام الجارية",
|
||||
"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": "اسم الجهاز",
|
||||
@@ -30,6 +38,7 @@
|
||||
"navigation.discoverMcp": "اكتشف MCP",
|
||||
"navigation.discoverModels": "اكتشف النماذج",
|
||||
"navigation.discoverProviders": "اكتشف المزودين",
|
||||
"navigation.document": "مستند",
|
||||
"navigation.group": "مجموعة",
|
||||
"navigation.groupChat": "محادثة جماعية",
|
||||
"navigation.home": "الرئيسية",
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
"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}} لكل مليون رصيد.",
|
||||
@@ -417,7 +420,6 @@
|
||||
"referral.rules.rewardDelay": "معالجة المكافآت: سيتم توزيع الأرصدة خلال ساعة واحدة بعد أن يكمل المدعو الدفع ويجتاز التحقق",
|
||||
"referral.rules.title": "قواعد البرنامج",
|
||||
"referral.rules.validInvitation": "دعوة صالحة: يسجل المدعو باستخدام رمز الإحالة الخاص بك، وينفذ إجراءً صالحًا، ويكمل الدفع (اشتراك أو شحن أرصدة)",
|
||||
"referral.rules.validOperation": "معايير الإجراء الصالح: إرسال رسالة واحدة أو إنشاء صورة واحدة",
|
||||
"referral.stats.availableBalance": "الرصيد المتاح",
|
||||
"referral.stats.description": "عرض إحصائيات الإحالة الخاصة بك",
|
||||
"referral.stats.title": "نظرة عامة على الإحالة",
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"management.status.archived": "مؤرشف",
|
||||
"management.status.completed": "مكتمل",
|
||||
"management.status.failed": "فشل",
|
||||
"management.status.idle": "خامل",
|
||||
"management.status.paused": "متوقف مؤقتًا",
|
||||
"management.status.running": "قيد التشغيل",
|
||||
"management.status.waitingForHuman": "في انتظار الإدخال",
|
||||
@@ -154,6 +155,8 @@
|
||||
"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": "يُفضَّل أن يكون قصيرًا وسهل التعرّف.",
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"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}} база знания",
|
||||
@@ -165,7 +168,9 @@
|
||||
"extendParams.urlContext.title": "Извличане на съдържание от уеб връзки",
|
||||
"followUpPlaceholder": "Последващо действие. Използвайте @, за да възлагате задачи на други агенти.",
|
||||
"followUpPlaceholderHeterogeneous": "Последващ въпрос.",
|
||||
"gatewayMode.title": "Режим на шлюз",
|
||||
"gatewayMode.beta": "Бета",
|
||||
"gatewayMode.cardTitle": "Режим на шлюза за агенти",
|
||||
"gatewayMode.desc": "Стартирайте агенти в облака чрез шлюза за агенти на LobeHub. Задачите продължават да се изпълняват дори след като затворите страницата.",
|
||||
"group.desc": "Придвижете задача напред с няколко Агента в едно споделено пространство.",
|
||||
"group.memberTooltip": "Групата има {{count}} член(а)",
|
||||
"group.orchestratorThinking": "Оркестраторът мисли...",
|
||||
@@ -877,6 +882,7 @@
|
||||
"toolAuth.authorize": "Упълномощи",
|
||||
"toolAuth.authorizing": "Упълномощаване...",
|
||||
"toolAuth.hint": "Без упълномощаване или конфигурация, уменията може да не работят. Това може да ограничи агента или да доведе до грешки.",
|
||||
"toolAuth.remove": "Премахни",
|
||||
"toolAuth.signIn": "Вход",
|
||||
"toolAuth.title": "Упълномощи уменията за този агент",
|
||||
"topic.checkOpenNewTopic": "Да започнем нова тема?",
|
||||
@@ -1118,6 +1124,5 @@
|
||||
"workingPanel.skills.title": "Умения",
|
||||
"workingPanel.space": "Пространство",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "Вие",
|
||||
"zenMode": "Режим Зен"
|
||||
"you": "Вие"
|
||||
}
|
||||
|
||||
@@ -3,18 +3,26 @@
|
||||
"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.runningTasks": "Текущи задачи",
|
||||
"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": "Име на устройството",
|
||||
@@ -30,6 +38,7 @@
|
||||
"navigation.discoverMcp": "Откриване на MCP",
|
||||
"navigation.discoverModels": "Откриване на Модели",
|
||||
"navigation.discoverProviders": "Откриване на Доставчици",
|
||||
"navigation.document": "Документ",
|
||||
"navigation.group": "Група",
|
||||
"navigation.groupChat": "Групов Чат",
|
||||
"navigation.home": "Начало",
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
"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 кредити.",
|
||||
@@ -417,7 +420,6 @@
|
||||
"referral.rules.rewardDelay": "Обработка на наградите: Кредитите ще бъдат разпределени в рамките на 1 час след като поканеният завърши плащане и премине проверка.",
|
||||
"referral.rules.title": "Правила на програмата",
|
||||
"referral.rules.validInvitation": "Валидна покана: Поканеният се регистрира с вашия код за препоръка, извършва едно валидно действие и завършва плащане (абонамент или зареждане на кредити).",
|
||||
"referral.rules.validOperation": "Критерии за валидно действие: Изпращане на съобщение в Chat страницата или генериране на изображение",
|
||||
"referral.stats.availableBalance": "Налично салдо",
|
||||
"referral.stats.description": "Вижте статистиката на вашите покани",
|
||||
"referral.stats.title": "Обзор на поканите",
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"management.status.archived": "Архивирани",
|
||||
"management.status.completed": "Завършени",
|
||||
"management.status.failed": "Неуспешни",
|
||||
"management.status.idle": "Неактивен",
|
||||
"management.status.paused": "Паузирани",
|
||||
"management.status.running": "В процес",
|
||||
"management.status.waitingForHuman": "Очаква въвеждане",
|
||||
@@ -154,6 +155,8 @@
|
||||
"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": "Поддържайте го кратко и лесно за разпознаване.",
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"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",
|
||||
@@ -165,7 +168,9 @@
|
||||
"extendParams.urlContext.title": "Webseiteninhalte extrahieren",
|
||||
"followUpPlaceholder": "Folgen Sie nach. @, um Aufgaben anderen Agenten zuzuweisen.",
|
||||
"followUpPlaceholderHeterogeneous": "Weiter ausführen.",
|
||||
"gatewayMode.title": "Gateway-Modus",
|
||||
"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...",
|
||||
@@ -877,6 +882,7 @@
|
||||
"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?",
|
||||
@@ -1118,6 +1124,5 @@
|
||||
"workingPanel.skills.title": "Fähigkeiten",
|
||||
"workingPanel.space": "Leerzeichen",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "Du",
|
||||
"zenMode": "Zen-Modus"
|
||||
"you": "Du"
|
||||
}
|
||||
|
||||
@@ -3,18 +3,26 @@
|
||||
"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.runningTasks": "Laufende Aufgaben",
|
||||
"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",
|
||||
@@ -30,6 +38,7 @@
|
||||
"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",
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
"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.",
|
||||
@@ -417,7 +420,6 @@
|
||||
"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",
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"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",
|
||||
@@ -154,6 +155,8 @@
|
||||
"projectStatus.failed_other": "{{count}} fehlgeschlagene Themen",
|
||||
"projectStatus.loading_one": "{{count}} ladendes Thema",
|
||||
"projectStatus.loading_other": "{{count}} ladende Themen",
|
||||
"projectStatus.unread_one": "{{count}} Thema mit ungelesener Antwort",
|
||||
"projectStatus.unread_other": "{{count}} Themen mit ungelesenen Antworten",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} Thema wartet auf Eingabe",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} Themen warten auf Eingabe",
|
||||
"renameModal.description": "Kurz und leicht erkennbar halten.",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"channel.devWebhookProxyUrl": "HTTPS Tunnel URL",
|
||||
"channel.devWebhookProxyUrlHint": "Optional. HTTPS tunnel URL for forwarding webhook requests to local dev server.",
|
||||
"channel.disabled": "Disabled",
|
||||
"channel.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.",
|
||||
"channel.discord.description": "Connect this agent 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 assistant to Feishu for private and group chats.",
|
||||
"channel.feishu.description": "Connect this agent 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 assistant to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
|
||||
"channel.imessage.description": "Connect this agent 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 assistant to Lark for private and group chats.",
|
||||
"channel.lark.description": "Connect this agent 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 assistant to LINE Messaging API for direct and group chats.",
|
||||
"channel.line.description": "Connect this agent 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 assistant to QQ for group chats and direct messages.",
|
||||
"channel.qq.description": "Connect this agent 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 assistant to Slack for channel conversations and direct messages.",
|
||||
"channel.slack.description": "Connect this agent 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 assistant to Telegram for private and group chats.",
|
||||
"channel.telegram.description": "Connect this agent 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 assistant to WeChat via iLink Bot for private and group chats.",
|
||||
"channel.wechat.description": "Connect this agent to WeChat via iLink Bot for private and group chats.",
|
||||
"channel.wechatBotId": "Bot ID",
|
||||
"channel.wechatBotIdHint": "Bot identifier assigned after QR code authorization.",
|
||||
"channel.wechatConnectedInfo": "Connected WeChat Account",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"apikey.list.columns.status": "Enabled Status",
|
||||
"apikey.list.title": "API Key List",
|
||||
"apikey.validation.required": "This field cannot be empty",
|
||||
"authModal.description": "Your login session has expired. Please sign in again to continue using cloud sync features.",
|
||||
"authModal.description": "Your sign-in 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": "Login failed, please check your email and password",
|
||||
"betterAuth.errors.loginFailed": "Sign in 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": "Log In",
|
||||
"login": "Sign 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 logging in, you can:",
|
||||
"loginOrSignup": "Log In / Sign Up",
|
||||
"loginGuide.title": "After signing in, you can:",
|
||||
"loginOrSignup": "Sign 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 login method.",
|
||||
"profile.sso.unlink.forbidden": "You must retain at least one sign-in 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 log in to your {{appName}} account",
|
||||
"signin.subtitle": "Sign up or sign in to your {{appName}} account",
|
||||
"signin.title": "Agent teammates that grow with you",
|
||||
"signout": "Log Out",
|
||||
"signout": "Sign 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 logging in, you can:",
|
||||
"stats.loginGuide.title": "After signing in, you can:",
|
||||
"stats.messages": "Messages",
|
||||
"stats.modelsRank.left": "Model",
|
||||
"stats.modelsRank.right": "Messages",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions.discord": "Go to Discord for feedback",
|
||||
"actions.home": "Return to Home",
|
||||
"actions.retry": "Log in Again",
|
||||
"actions.retry": "Sign 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 log in again",
|
||||
"codes.SESSION_EXPIRED": "Session has expired, please sign 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",
|
||||
|
||||
+35
-21
@@ -25,8 +25,8 @@
|
||||
"agentDocument.openAsPage": "Open as full page",
|
||||
"agentProfile.files_one": "{{count}} file",
|
||||
"agentProfile.files_other": "{{count}} files",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} knowledge base",
|
||||
"agentProfile.knowledgeBases_other": "{{count}} knowledge bases",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} library",
|
||||
"agentProfile.knowledgeBases_other": "{{count}} libraries",
|
||||
"agentProfile.skills_one": "{{count}} skill",
|
||||
"agentProfile.skills_other": "{{count}} skills",
|
||||
"agentSignal.receipts.agentSignalLabel": "Agent Signal",
|
||||
@@ -67,7 +67,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 session messages",
|
||||
"clearCurrentMessages": "Clear current conversation 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,17 +110,29 @@
|
||||
"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 session messages. Once cleared, they cannot be retrieved. Please confirm your action.",
|
||||
"confirmClearCurrentMessages": "You are about to clear the current conversation 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",
|
||||
"confirmRemoveSessionItemAlert": "You are about to delete this agent. Once deleted, it cannot be retrieved. Please confirm your action.",
|
||||
"confirmRemoveSessionSuccess": "Agent removed successfully",
|
||||
"createModal.createBlank": "Create Blank",
|
||||
"createModal.groupPlaceholder": "Describe what this group should do...",
|
||||
"createModal.groupTitle": "What should your group do?",
|
||||
"createModal.placeholder": "Describe what your agent should do...",
|
||||
"createModal.title": "What should your agent do?",
|
||||
"createModal.createBlank": "Start Blank",
|
||||
"createModal.groupPlaceholder": "Describe what this Group should do...",
|
||||
"createModal.groupTitle": "What should this Group do?",
|
||||
"createModal.placeholder": "Describe what this Agent should do...",
|
||||
"createModal.skillSuggestion.actions.createAnyway": "Create Agent Anyway",
|
||||
"createModal.skillSuggestion.actions.createAnywayHint": "Skill not a fit?",
|
||||
"createModal.skillSuggestion.actions.install": "Add Skill",
|
||||
"createModal.skillSuggestion.actions.installing": "Adding…",
|
||||
"createModal.skillSuggestion.actions.openSkills": "View in Skills",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "Use in LobeAI",
|
||||
"createModal.skillSuggestion.description": "This looks like a reusable workflow. Install the Skill once, then use it across Agents.",
|
||||
"createModal.skillSuggestion.installError": "Skill wasn't added. Retry, or create an Agent anyway.",
|
||||
"createModal.skillSuggestion.installed.description": "You can use this Skill in LobeAI or add it to any Agent.",
|
||||
"createModal.skillSuggestion.installed.ready": "Ready in LobeAI",
|
||||
"createModal.skillSuggestion.installed.title": "Skill added",
|
||||
"createModal.skillSuggestion.title": "A Skill may fit better",
|
||||
"createModal.title": "What should this Agent do?",
|
||||
"createTask.assignee": "Assignee",
|
||||
"createTask.collapse": "Hide input",
|
||||
"createTask.expandToInline": "Dock to page",
|
||||
@@ -166,11 +178,13 @@
|
||||
"extendParams.title": "Model Extension Features",
|
||||
"extendParams.urlContext.desc": "When enabled, web links will be automatically parsed to retrieve the actual webpage context content",
|
||||
"extendParams.urlContext.title": "Extract Webpage Link Content",
|
||||
"floatingChatPanel.collapse": "Collapse chat",
|
||||
"floatingChatPanel.expand": "Expand chat",
|
||||
"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": "Agent 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 +192,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": "Supervisor",
|
||||
"group.profile.supervisorPlaceholder": "The supervisor coordinates different agents. Setting supervisor information here enables more precise workflow coordination.",
|
||||
"group.profile.supervisor": "Orchestrator",
|
||||
"group.profile.supervisorPlaceholder": "The Orchestrator coordinates different agents. Setting Orchestrator information here enables more precise workflow coordination.",
|
||||
"group.removeMember": "Remove Member",
|
||||
"group.title": "Group",
|
||||
"groupDescription": "Group description",
|
||||
@@ -477,7 +491,7 @@
|
||||
"search.mode.useModelBuiltin": "Use model built-in web search",
|
||||
"search.searchModel.desc": "The current model does not support function calls, so it needs to be paired with a model that does support function calls for online searching.",
|
||||
"search.searchModel.title": "Search helper model",
|
||||
"search.title": "Web search",
|
||||
"search.title": "Web Search",
|
||||
"searchAgentPlaceholder": "Search agents...",
|
||||
"searchAgents": "Search agents...",
|
||||
"selectedAgents": "Selected agents",
|
||||
@@ -586,7 +600,7 @@
|
||||
"stt.action": "Voice Input",
|
||||
"stt.loading": "Recognizing...",
|
||||
"stt.prettifying": "Polishing...",
|
||||
"supervisor.label": "Supervisor",
|
||||
"supervisor.label": "Orchestrator",
|
||||
"supervisor.todoList.allComplete": "All tasks completed",
|
||||
"supervisor.todoList.title": "Tasks Completed",
|
||||
"tab.groupProfile": "Group Profile",
|
||||
@@ -839,7 +853,7 @@
|
||||
"tokenDetails.chats": "Chat Messages",
|
||||
"tokenDetails.historySummary": "History Summary",
|
||||
"tokenDetails.rest": "Remaining",
|
||||
"tokenDetails.supervisor": "Group Host",
|
||||
"tokenDetails.supervisor": "Orchestrator",
|
||||
"tokenDetails.systemRole": "Role Settings",
|
||||
"tokenDetails.title": "Context Details",
|
||||
"tokenDetails.tools": "Skill Settings",
|
||||
@@ -890,7 +904,7 @@
|
||||
"topic.defaultTitle": "Untitled Topic",
|
||||
"topic.openNewTopic": "Open New Topic",
|
||||
"topic.recent": "Recent Topics",
|
||||
"topic.saveCurrentMessages": "Save current session as topic",
|
||||
"topic.saveCurrentMessages": "Save current conversation as topic",
|
||||
"topic.viewAll": "View All Topics",
|
||||
"translate.action": "Translate",
|
||||
"translate.clear": "Clear Translation",
|
||||
@@ -925,9 +939,9 @@
|
||||
"workflow.collapse": "Collapse",
|
||||
"workflow.expandFull": "Expand fully",
|
||||
"workflow.failedSuffix": "(failed)",
|
||||
"workflow.summaryAcrossTools": "across {{count}} tools",
|
||||
"workflow.summaryCallsLead": "{{count}} calls: {{tools}}",
|
||||
"workflow.summaryFailed": "{{count}} failed",
|
||||
"workflow.summaryMoreTools": "{{count}} tool kinds",
|
||||
"workflow.summaryTotalCalls": "{{count}} calls total",
|
||||
"workflow.thoughtForDuration": "Thought for {{duration}}",
|
||||
"workflow.toolDisplayName.activateDevice": "Activated device",
|
||||
"workflow.toolDisplayName.activateSkill": "Activated a skill",
|
||||
@@ -986,7 +1000,7 @@
|
||||
"workflow.toolDisplayName.saveUserQuestion": "Recorded info",
|
||||
"workflow.toolDisplayName.search": "Searched the web",
|
||||
"workflow.toolDisplayName.searchAgent": "Searched agents",
|
||||
"workflow.toolDisplayName.searchKnowledgeBase": "Searched knowledge base",
|
||||
"workflow.toolDisplayName.searchKnowledgeBase": "Searched library",
|
||||
"workflow.toolDisplayName.searchLocalFiles": "Searched files",
|
||||
"workflow.toolDisplayName.searchSkill": "Searched skills",
|
||||
"workflow.toolDisplayName.searchUserMemory": "Searched memory",
|
||||
@@ -1043,6 +1057,7 @@
|
||||
"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",
|
||||
@@ -1124,6 +1139,5 @@
|
||||
"workingPanel.skills.title": "Skills",
|
||||
"workingPanel.space": "Space",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "You",
|
||||
"zenMode": "Zen Mode"
|
||||
"you": "You"
|
||||
}
|
||||
|
||||
@@ -168,8 +168,8 @@
|
||||
"cmdk.search.agents": "Agents",
|
||||
"cmdk.search.assistant": "Agent",
|
||||
"cmdk.search.assistants": "Agents",
|
||||
"cmdk.search.chatGroup": "Agent Team",
|
||||
"cmdk.search.chatGroups": "Agent Teams",
|
||||
"cmdk.search.chatGroup": "Group",
|
||||
"cmdk.search.chatGroups": "Groups",
|
||||
"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 Login",
|
||||
"oauth": "SSO Sign-in",
|
||||
"officialSite": "Official Website",
|
||||
"ok": "OK",
|
||||
"or": "or",
|
||||
|
||||
@@ -101,7 +101,8 @@
|
||||
"LocalFile.action.open": "Open",
|
||||
"LocalFile.action.showInFolder": "Show in Folder",
|
||||
"MaxTokenSlider.unlimited": "Unlimited",
|
||||
"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.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.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.",
|
||||
@@ -114,6 +115,7 @@
|
||||
"ModelSwitchPanel.byModel": "By Model",
|
||||
"ModelSwitchPanel.byProvider": "By Provider",
|
||||
"ModelSwitchPanel.detail.abilities": "Abilities",
|
||||
"ModelSwitchPanel.detail.abilities.audio": "Audio",
|
||||
"ModelSwitchPanel.detail.abilities.files": "Files",
|
||||
"ModelSwitchPanel.detail.abilities.functionCall": "Tool Calling",
|
||||
"ModelSwitchPanel.detail.abilities.imageOutput": "Image Output",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"authResult.failed.desc": "Please try again or switch to a different login method",
|
||||
"authResult.failed.desc": "Please try again or switch to a different sign-in method",
|
||||
"authResult.failed.title": "Authorization Failed",
|
||||
"authResult.success.desc": "Please click the Start button below to continue using LobeHub Desktop",
|
||||
"authResult.success.title": "Authorization Successful",
|
||||
|
||||
@@ -620,7 +620,7 @@
|
||||
"user.githubUrlInvalid": "Please enter a valid GitHub repository URL",
|
||||
"user.githubUrlRequired": "Please enter a GitHub repository URL",
|
||||
"user.login": "Become a Creator",
|
||||
"user.logout": "Logout",
|
||||
"user.logout": "Sign out",
|
||||
"user.myProfile": "My Profile",
|
||||
"user.noAgents": "This user hasn’t published any Agents yet",
|
||||
"user.noAgents.ownerDescription": "Create your first Agent and share it with the Community.",
|
||||
@@ -630,11 +630,11 @@
|
||||
"user.noForkedAgentGroups": "No forked Agent Groups yet",
|
||||
"user.noForkedAgents": "No forked Agents yet",
|
||||
"user.noGroups.title": "No Agent Groups yet",
|
||||
"user.noPlugins": "This user hasn't published any Plugins yet",
|
||||
"user.noPlugins": "This user hasn't published any Skills yet",
|
||||
"user.noSkills": "This user hasn't published any Skills yet",
|
||||
"user.openWorkspacePublicProfile": "Open Public Link",
|
||||
"user.org.noAgents": "This organization hasn’t published any Agents yet",
|
||||
"user.plugins": "Plugins",
|
||||
"user.plugins": "Skills",
|
||||
"user.publishedAgents": "Created Agents",
|
||||
"user.publishedGroups": "Created Groups",
|
||||
"user.searchPlaceholder": "Search by name or description...",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"gateway.title": "Device Gateway",
|
||||
"navigation.chat": "Chat",
|
||||
"navigation.discover": "Discover",
|
||||
"navigation.discoverAssistants": "Discover Assistants",
|
||||
"navigation.discoverAssistants": "Discover Agents",
|
||||
"navigation.discoverMcp": "Discover MCP",
|
||||
"navigation.discoverModels": "Discover Models",
|
||||
"navigation.discoverProviders": "Discover Providers",
|
||||
|
||||
@@ -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 login page shortly",
|
||||
"loginRequired.title": "Please log in to use this feature",
|
||||
"loginRequired.desc": "You will be redirected to the sign-in page shortly",
|
||||
"loginRequired.title": "Please sign 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 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.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.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",
|
||||
|
||||
@@ -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 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.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.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",
|
||||
|
||||
@@ -38,7 +38,5 @@
|
||||
"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",
|
||||
"toggleZenMode.desc": "In focus mode, only display the current conversation and hide other UI elements",
|
||||
"toggleZenMode.title": "Toggle Focus Mode"
|
||||
"toggleRightPanel.title": "Toggle Right Panel"
|
||||
}
|
||||
|
||||
@@ -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 assistant to reflect, build self-awareness, and continuously iterate through ongoing attempts and interactions.",
|
||||
"features.agentSelfIteration.desc": "Allow the agent 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,11 +7,9 @@
|
||||
"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 submit and manage listings within the community.",
|
||||
"authorize.subtitle": "Create a community profile to use community features.",
|
||||
"authorize.title": "Create Community Profile",
|
||||
"callback.buttons.close": "Close Window",
|
||||
"callback.messages.authFailed": "Authorization failed: {{error}}",
|
||||
@@ -34,7 +32,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 login process.",
|
||||
"errors.codeVerifierMissing": "Invalid authorization session. Please restart the sign-in 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.",
|
||||
@@ -42,7 +40,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 log in again.",
|
||||
"errors.sessionExpired": "Authorization session has expired. Please sign 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.",
|
||||
@@ -50,8 +48,6 @@
|
||||
"messages.handoffTimeout": "Authorization timed out. Finish it in your browser, then retry.",
|
||||
"messages.loading": "Starting authorization process...",
|
||||
"messages.success.cloudMcpInstall": "Authorization successful! You can now install the Cloud MCP skill.",
|
||||
"messages.success.submit": "Authorization successful! You can now publish your agent.",
|
||||
"messages.success.upload": "Authorization successful! You can now publish a new version.",
|
||||
"profileSetup.cancel": "Cancel",
|
||||
"profileSetup.confirmChangeUserId.cancel": "Cancel",
|
||||
"profileSetup.confirmChangeUserId.confirm": "Change User ID",
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
"activity.notes": "Notes",
|
||||
"analysis.action.button": "Request memory analysis",
|
||||
"analysis.modal.cancel": "Cancel",
|
||||
"analysis.modal.helper": "By default Lobe AI will analyze all unprocessed chats. It's optional to select a date range to analyze.",
|
||||
"analysis.modal.helper": "By default Lobe AI will analyze all unprocessed conversations. 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 chats from {{start}} to {{end}}",
|
||||
"analysis.modal.rangeSelected": "Analyzing conversations from {{start}} to {{end}}",
|
||||
"analysis.modal.submit": "Request memory analysis",
|
||||
"analysis.modal.title": "Analyze chats to generate memories",
|
||||
"analysis.modal.title": "Analyze conversations 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}} topics",
|
||||
"analysis.status.progressUnknown": "Processed {{completed}} topics so far",
|
||||
"analysis.status.progress": "Processed {{completed}} / {{total}} conversations",
|
||||
"analysis.status.progressUnknown": "Processed {{completed}} conversations so far",
|
||||
"analysis.status.tip": "We are processing your conversations to build personal memories. This may take a few minutes.",
|
||||
"analysis.status.title": "Memory analysis in progress",
|
||||
"analysis.toast.deduped": "A memory request is already running, continuing progress…",
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"createNewAiProvider.createSuccess": "Creation successful",
|
||||
"createNewAiProvider.description.placeholder": "Provider description (optional)",
|
||||
"createNewAiProvider.description.title": "Provider Description",
|
||||
"createNewAiProvider.id.desc": "Unique identifier for the service provider, which cannot be modified after creation",
|
||||
"createNewAiProvider.id.desc": "Unique identifier for the 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,6 +222,7 @@
|
||||
"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.",
|
||||
@@ -255,7 +256,8 @@
|
||||
"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 Usage",
|
||||
"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.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",
|
||||
@@ -270,11 +272,11 @@
|
||||
"providerModels.item.modelConfig.tokens.title": "Maximum Context Window",
|
||||
"providerModels.item.modelConfig.tokens.unlimited": "Unlimited",
|
||||
"providerModels.item.modelConfig.type.extra": "Different model types have distinct use cases and capabilities",
|
||||
"providerModels.item.modelConfig.type.options.asr": "Speech-to-Text",
|
||||
"providerModels.item.modelConfig.type.options.chat": "Chat",
|
||||
"providerModels.item.modelConfig.type.options.embedding": "Embedding",
|
||||
"providerModels.item.modelConfig.type.options.image": "Image Generation",
|
||||
"providerModels.item.modelConfig.type.options.realtime": "Real-time Chat",
|
||||
"providerModels.item.modelConfig.type.options.stt": "Speech-to-Text",
|
||||
"providerModels.item.modelConfig.type.options.text2music": "Text-to-Music",
|
||||
"providerModels.item.modelConfig.type.options.tts": "Text-to-Speech",
|
||||
"providerModels.item.modelConfig.type.options.video": "Video Generation",
|
||||
@@ -323,10 +325,10 @@
|
||||
"providerModels.list.total": "{{count}} models available",
|
||||
"providerModels.searchNotFound": "No search results found",
|
||||
"providerModels.tabs.all": "All",
|
||||
"providerModels.tabs.asr": "ASR",
|
||||
"providerModels.tabs.chat": "Chat",
|
||||
"providerModels.tabs.embedding": "Embedding",
|
||||
"providerModels.tabs.image": "Image",
|
||||
"providerModels.tabs.stt": "ASR",
|
||||
"providerModels.tabs.tts": "TTS",
|
||||
"providerModels.tabs.video": "Video",
|
||||
"sortModal.success": "Sort update successful",
|
||||
|
||||
@@ -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 Login",
|
||||
"login.description": "The application {{clientName}} is requesting to use your account for login",
|
||||
"login.title": "Login to {{clientName}}",
|
||||
"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.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"
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"Working",
|
||||
"Drafting",
|
||||
"Thinking",
|
||||
"Computing",
|
||||
"Brewing",
|
||||
"Synthesizing",
|
||||
"Crunching",
|
||||
"Architecting",
|
||||
"Composing",
|
||||
"Orchestrating",
|
||||
"Sketching",
|
||||
"Noodling",
|
||||
"Pondering",
|
||||
"Crafting",
|
||||
"Flambéing",
|
||||
"Simmering",
|
||||
"Whirring",
|
||||
"Wrangling",
|
||||
"Polishing",
|
||||
"Preparing the answer",
|
||||
"Baking",
|
||||
"Channeling",
|
||||
"Coalescing",
|
||||
"Deciphering",
|
||||
"Forging",
|
||||
"Harmonizing",
|
||||
"Improvising",
|
||||
"Inferring",
|
||||
"Tinkering",
|
||||
"Zigzagging"
|
||||
"Summoning electrons",
|
||||
"Herding tokens",
|
||||
"Waking the hamsters",
|
||||
"Reticulating splines",
|
||||
"Consulting the oracle",
|
||||
"Bribing the GPU",
|
||||
"Untangling thoughts",
|
||||
"Doing big-brain things",
|
||||
"Letting it cook",
|
||||
"Pondering the orb",
|
||||
"Connecting the dots",
|
||||
"Spinning up neurons",
|
||||
"Chasing the muse",
|
||||
"Aligning the stars",
|
||||
"Brewing brilliance",
|
||||
"Tickling transistors",
|
||||
"Wrangling ideas",
|
||||
"Crunching numbers",
|
||||
"Channeling genius",
|
||||
"Charging brain cells",
|
||||
"Asking the rubber duck",
|
||||
"Overthinking it",
|
||||
"Shuffling synapses",
|
||||
"Mining for answers",
|
||||
"Polishing the prose",
|
||||
"Befriending the algorithm",
|
||||
"Greasing the gears",
|
||||
"Decoding the universe"
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user