mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-18 05:18:31 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ec55f5941 | |||
| 63f13c2a31 | |||
| 1fa6f47fc9 |
@@ -112,14 +112,9 @@ secret: don't paste it into shared logs, PRs, or commit it anywhere.
|
||||
|
||||
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
|
||||
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
|
||||
3. If repo-root `.env` exists and `web-seed` fails, do **not** seed or modify the current DB; treat it as an existing local environment and use Cookie injection.
|
||||
4. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
|
||||
5. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:` → **Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
|
||||
6. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
|
||||
`ENABLE_MOCK_DEV_USER` is not Web auth. It only affects server-side API context
|
||||
and does not satisfy Better Auth or stop the SPA from redirecting to `/signin`.
|
||||
Do not use it as a substitute for `status --surface web` or Cookie injection.
|
||||
3. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
|
||||
4. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:` → **Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
|
||||
5. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
|
||||
### Using the authenticated session
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ SERVER_URL="${SERVER_URL:-$(default_server_url)}"
|
||||
SESSION="${SESSION:-lobehub-dev}"
|
||||
AUTH_DIR="${AUTH_DIR:-$HOME/.lobehub-agent-testing}"
|
||||
STATE_FILE="$AUTH_DIR/web-state.json"
|
||||
ROOT_ENV_FILE="$REPO_ROOT/.env"
|
||||
CLI_HOME_NAME="${LOBEHUB_CLI_HOME:-.lobehub-dev}"
|
||||
CLI_HOME="$HOME/${CLI_HOME_NAME#/}"
|
||||
CLI_CREDENTIALS_FILE="$CLI_HOME/credentials.json"
|
||||
@@ -482,13 +481,8 @@ PY
|
||||
|
||||
if [[ ! "$code" =~ ^[23] ]]; then
|
||||
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
|
||||
if [[ -f "$ROOT_ENV_FILE" ]]; then
|
||||
note "root .env exists; do not seed or modify this DB for Web auth."
|
||||
note "Use Chrome Cookie injection instead: $0 open-chrome, then pbpaste | $0 web"
|
||||
else
|
||||
note "make sure the seed user exists:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
fi
|
||||
note "make sure the seed user exists:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -523,7 +517,6 @@ cmd_web_verify() {
|
||||
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
|
||||
return 1
|
||||
fi
|
||||
agent-browser --session "$SESSION" wait --load networkidle > /dev/null 2>&1 || true
|
||||
local url
|
||||
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
|
||||
if [[ -z "$url" ]]; then
|
||||
|
||||
@@ -46,13 +46,6 @@ Every data surface has **four** states — design all of them, not just "has dat
|
||||
over skeleton rows or a blank body. _(Meaningful)_
|
||||
- [ ] **Distinguish the empty variants** — "no data yet" (onboarding CTA) vs
|
||||
"no match for filters" (clear-filters affordance) are different screens. _(Certainty)_
|
||||
- [ ] **Always-rendered chrome still needs a body empty state.** When a surface
|
||||
keeps its toolbar / header mounted even with no data (so a create / `+`
|
||||
affordance stays reachable), the **body** below it must still render an empty
|
||||
placeholder — persistent chrome is not an excuse to leave the content area
|
||||
blank. ✅ The agent **Documents** tab keeps its new-folder / new-doc toolbar
|
||||
and renders an `Empty` below it when there are no documents — ❌ not a toolbar
|
||||
over dead space. _(Meaningful)_
|
||||
- [ ] **Loading state** designed (skeleton / NeuralNetworkLoading), not a flash of
|
||||
blank or layout shift. _(Natural)_
|
||||
- [ ] **Error state** designed — surface the reason and a retry/back path. _(Meaningful)_
|
||||
@@ -103,33 +96,6 @@ the selection is restored rather than freshly clicked.
|
||||
sidebar list, so the move picker re-adds it. An empty picker must mean
|
||||
"genuinely none", never "we filtered out the only option". _(Meaningful)_
|
||||
|
||||
### 1.5 Default view reflects entry intent & data state・Certainty・Meaningful
|
||||
|
||||
A surface with multiple tabs / views / panels has a **landing** selection. Don't
|
||||
hardcode it to "the first tab" — derive it from **(a) how the user got here** (the
|
||||
intent their navigation carried) and **(b) which views actually have data**. A
|
||||
static default that lands the user on an empty tab while a sibling holds exactly
|
||||
what they came for reads as broken. This pairs with §1.1: the empty state is the
|
||||
fallback _within_ a view; this rule is about not landing on that empty view in the
|
||||
first place when a better one exists.
|
||||
|
||||
- [ ] **Open on the tab the entry implies.** When navigation carries intent — the
|
||||
user clicked a Skill, a file, a record of a specific type — land on the view
|
||||
that shows it, not the static first tab. ✅ Opening a document page by clicking
|
||||
a **skill** lands the right panel on the **Skills** tab; opening a plain
|
||||
document lands on **Documents**. _(Meaningful)_
|
||||
- [ ] **Fall back to a populated view when the default would be empty.** If the
|
||||
default tab has no data but a sibling does, default to the populated one so
|
||||
the surface opens on content. ✅ An agent with only skills (no documents)
|
||||
opens the panel on **Skills** instead of an empty **Documents** tab. _(Certainty)_
|
||||
- [ ] **Decide from resolved state, not mid-load.** Compute the default once the
|
||||
data has loaded — choosing off an empty _in-flight_ list flips the tab as data
|
||||
arrives. Hold the static default while loading, switch on resolved-empty. _(Certainty)_
|
||||
- [ ] **A manual choice wins and sticks.** Once the user picks a tab, stop
|
||||
auto-selecting — track "user-picked" separately (e.g. a nullable `pickedTab`
|
||||
that overrides the derived default) so later data changes don't yank them off
|
||||
their choice. _(Natural)_
|
||||
|
||||
---
|
||||
|
||||
## 2. Edit — entering & changing content
|
||||
@@ -284,11 +250,10 @@ The product should grow with the user — deeper power shows up as needs deepen.
|
||||
|
||||
**Read — viewing data & lists**
|
||||
|
||||
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA. Always-rendered chrome (toolbar/header) still gets a body empty state.
|
||||
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA.
|
||||
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
|
||||
- [ ] Capped/scrollable/virtualized list scrolls the restored active item into view on mount (`block: 'nearest'`, re-run after async rows mount).
|
||||
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
|
||||
- [ ] Multi-tab/view surface lands on the tab the entry intent implies (and falls back to a populated view, decided from resolved state); a manual pick sticks.
|
||||
|
||||
**Edit — entering & changing content**
|
||||
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [Version 2.2.6](https://github.com/lobehub/lobe-chat/compare/v2.2.6-canary.8...v2.2.6)
|
||||
|
||||
<sup>Released on **2026-06-17**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **agent**: improve connector, document, and fleet workflows.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **agent**: improve connector, document, and fleet workflows, closes [#15936](https://github.com/lobehub/lobe-chat/issues/15936) ([3f82033](https://github.com/lobehub/lobe-chat/commit/3f82033))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.2.1](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr15228.13999...v2.2.1)
|
||||
|
||||
<sup>Released on **2026-05-29**</sup>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -80,40 +77,6 @@ describe('lh file - E2E', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── upload (local file) ───────────────────────────────
|
||||
|
||||
describe('upload', () => {
|
||||
it('should upload a local file passed as a positional argument', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello from lh e2e upload');
|
||||
|
||||
try {
|
||||
const result = runJson<{ id: string }>(`file upload ${tmpFile} --json id`);
|
||||
expect(result).toHaveProperty('id');
|
||||
if (result.id) run(`file delete ${result.id} --yes`);
|
||||
} finally {
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should upload a local file passed via --file', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-f-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello from lh e2e --file upload');
|
||||
|
||||
try {
|
||||
const result = runJson<{ id: string }>(`file upload --file ${tmpFile} --json id`);
|
||||
expect(result).toHaveProperty('id');
|
||||
if (result.id) run(`file delete ${result.id} --yes`);
|
||||
} finally {
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should error when the local file does not exist', () => {
|
||||
expect(() => run('file upload -f /no/such/lh-file.txt')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── recent ────────────────────────────────────────────
|
||||
|
||||
describe('recent', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.31" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.29" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.31",
|
||||
"version": "0.0.29",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -21,9 +17,6 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
removeFiles: { mutate: vi.fn() },
|
||||
updateFile: { mutate: vi.fn() },
|
||||
},
|
||||
upload: {
|
||||
createS3PreSignedUrl: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -45,11 +38,9 @@ describe('file command', () => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const group of [mockTrpcClient.file, mockTrpcClient.upload]) {
|
||||
for (const method of Object.values(group)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.file)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -214,111 +205,6 @@ describe('file command', () => {
|
||||
expect(mockTrpcClient.file.createFile.mutate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
|
||||
});
|
||||
|
||||
it('should upload a local file passed as a positional argument', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-${process.pid}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello world');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
|
||||
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({
|
||||
id: 'f-local',
|
||||
url: 'files/x.txt',
|
||||
});
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
|
||||
|
||||
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://s3/presigned',
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
);
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileType: 'text/plain',
|
||||
name: path.basename(tmpFile),
|
||||
url: expect.stringContaining('.txt'),
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('File created'));
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should upload a local file passed via --file', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-f-${process.pid}.json`);
|
||||
fs.writeFileSync(tmpFile, '{}');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
|
||||
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-json' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', '--file', tmpFile]);
|
||||
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileType: 'application/json' }),
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should skip the S3 upload when the local file hash already exists', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-dedup-${process.pid}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'dedup me');
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({
|
||||
isExist: true,
|
||||
url: 'files/2024-01-01/existing.txt',
|
||||
});
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-dedup' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
|
||||
|
||||
// No pre-sign and no S3 PUT should happen
|
||||
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
// The record reuses the existing url
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: 'files/2024-01-01/existing.txt' }),
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should error when local file does not exist', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', '-f', '/no/such/file.txt']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('File not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should error when no source is provided', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Provide a local file path'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import pc from 'picocolors';
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { uploadLocalFile } from '../utils/uploadLocalFile';
|
||||
|
||||
export function registerFileCommand(program: Command) {
|
||||
const file = program.command('file').description('Manage files');
|
||||
@@ -114,20 +113,18 @@ export function registerFileCommand(program: Command) {
|
||||
// ── upload ───────────────────────────────────────────
|
||||
|
||||
file
|
||||
.command('upload [source]')
|
||||
.description('Upload a file from a local path or a URL')
|
||||
.option('-f, --file <path>', 'Local file path to upload')
|
||||
.option('--hash <hash>', 'File hash for deduplication check (URL mode)')
|
||||
.option('--name <name>', 'File name (URL mode)')
|
||||
.option('--type <type>', 'File MIME type (URL mode)')
|
||||
.option('--size <size>', 'File size in bytes (URL mode)')
|
||||
.command('upload <url>')
|
||||
.description('Upload a file by URL (checks hash first)')
|
||||
.option('--hash <hash>', 'File hash for deduplication check')
|
||||
.option('--name <name>', 'File name')
|
||||
.option('--type <type>', 'File MIME type')
|
||||
.option('--size <size>', 'File size in bytes')
|
||||
.option('--parent-id <id>', 'Parent folder ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
source: string | undefined,
|
||||
url: string,
|
||||
options: {
|
||||
file?: string;
|
||||
hash?: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
@@ -136,47 +133,8 @@ export function registerFileCommand(program: Command) {
|
||||
type?: string;
|
||||
},
|
||||
) => {
|
||||
const isUrl = (value: string) =>
|
||||
value.startsWith('http://') || value.startsWith('https://');
|
||||
|
||||
// Resolve the local file path: explicit --file, or a positional that is
|
||||
// not a URL (e.g. `lh file upload ./games_list.txt`).
|
||||
const localPath = options.file ?? (source && !isUrl(source) ? source : undefined);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// ── Local file upload ──
|
||||
if (localPath) {
|
||||
let result;
|
||||
try {
|
||||
result = await uploadLocalFile(client, localPath, { parentId: options.parentId });
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} File created: ${pc.bold(r.id || '')}`);
|
||||
if (r.url) console.log(` URL: ${pc.dim(r.url)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── URL upload ──
|
||||
if (!source) {
|
||||
log.error('Provide a local file path, --file <path>, or a URL to upload.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = source;
|
||||
|
||||
// Check hash first if provided
|
||||
if (options.hash) {
|
||||
const check = await client.file.checkFileHash.mutate({ hash: options.hash });
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -10,9 +6,6 @@ import { registerGenerateCommand } from './generate';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
asr: {
|
||||
transcribe: { mutate: vi.fn() },
|
||||
},
|
||||
generation: {
|
||||
deleteGeneration: { mutate: vi.fn() },
|
||||
getGenerationStatus: { query: vi.fn() },
|
||||
@@ -42,15 +35,6 @@ const { writeFileSync: mockWriteFileSync } = vi.hoisted(() => ({
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const { uploadLocalFile: mockUploadLocalFile } = vi.hoisted(() => ({
|
||||
uploadLocalFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/uploadLocalFile', async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return { ...actual, uploadLocalFile: mockUploadLocalFile };
|
||||
});
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -385,130 +369,6 @@ describe('generate command', () => {
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should upload large local audio and transcribe by fileId', async () => {
|
||||
// Real >3MB temp file so existsSync/statSync (unmocked) see it as large.
|
||||
const bigPath = path.join(os.tmpdir(), `lh-asr-test-${process.pid}-${Date.now()}.mp3`);
|
||||
await fsWriteFile(bigPath, Buffer.alloc(4 * 1024 * 1024));
|
||||
mockUploadLocalFile.mockResolvedValue({ id: 'file_999' });
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'big result' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'generate', 'asr', bigPath]);
|
||||
|
||||
expect(mockUploadLocalFile).toHaveBeenCalledWith(expect.anything(), bigPath);
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileId: 'file_999', model: 'whisper-1', provider: 'openai' }),
|
||||
);
|
||||
// never inlines bytes for the large file
|
||||
expect(mockTrpcClient.asr.transcribe.mutate.mock.calls[0][0]).not.toHaveProperty(
|
||||
'audioBase64',
|
||||
);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('big result');
|
||||
} finally {
|
||||
await fsRm(bigPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should download and transcribe an audio URL', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers(),
|
||||
ok: true,
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'hello world' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/audio/sample.mp3',
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://example.com/audio/sample.mp3');
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audioBase64: Buffer.from('audio-bytes').toString('base64'),
|
||||
fileName: 'sample.mp3',
|
||||
model: 'whisper-1',
|
||||
provider: 'openai',
|
||||
}),
|
||||
);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('hello world');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should derive an extension and mime type from Content-Type when the URL has none', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers({ 'content-type': 'audio/mpeg; charset=binary' }),
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'generate', 'asr', 'https://example.com/download']);
|
||||
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: 'download.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer the filename from Content-Disposition', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers({
|
||||
'content-disposition': 'attachment; filename="recording.wav"',
|
||||
}),
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/files/abc123?sig=xyz',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileName: 'recording.wav' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit when audio URL download fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }),
|
||||
);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/missing.mp3',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to download audio'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { getAuthInfo } from '../../api/http';
|
||||
import { log } from '../../utils/logger';
|
||||
import { uploadLocalFile } from '../../utils/uploadLocalFile';
|
||||
|
||||
// Audio at or below this size is sent inline as base64; anything larger is
|
||||
// uploaded first and transcribed by `fileId`. Kept in sync with the server-side
|
||||
// inline cap in `apps/server/src/routers/lambda/asr.ts`.
|
||||
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
export function registerAsrCommand(parent: Command) {
|
||||
parent
|
||||
.command('asr <audio-file>')
|
||||
.description(
|
||||
'Convert speech to text (automatic speech recognition). Accepts a local path or a URL',
|
||||
)
|
||||
.description('Convert speech to text (automatic speech recognition)')
|
||||
.option('--model <model>', 'STT model', 'whisper-1')
|
||||
.option('--provider <provider>', 'AI provider', 'openai')
|
||||
.option('--language <lang>', 'Language code (e.g. en, zh)')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(
|
||||
@@ -31,175 +20,58 @@ export function registerAsrCommand(parent: Command) {
|
||||
json?: boolean;
|
||||
language?: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
},
|
||||
) => {
|
||||
const isUrl = audioFile.startsWith('http://') || audioFile.startsWith('https://');
|
||||
|
||||
if (!isUrl && !existsSync(audioFile)) {
|
||||
if (!existsSync(audioFile)) {
|
||||
log.error(`File not found: ${audioFile}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the input to a local file path (downloading URLs to a temp
|
||||
// file) so large audio can reuse the shared upload flow.
|
||||
let localPath: string;
|
||||
let fileName: string;
|
||||
let mimeType: string | undefined;
|
||||
let size: number;
|
||||
let tempPath: string | undefined;
|
||||
try {
|
||||
if (isUrl) {
|
||||
const downloaded = await fetchAudioFromUrl(audioFile);
|
||||
fileName = downloaded.name;
|
||||
mimeType = downloaded.mimeType;
|
||||
size = downloaded.bytes.byteLength;
|
||||
tempPath = path.join(os.tmpdir(), `lh-asr-${process.pid}-${Date.now()}-${fileName}`);
|
||||
await writeFile(tempPath, downloaded.bytes);
|
||||
localPath = tempPath;
|
||||
} else {
|
||||
localPath = audioFile;
|
||||
fileName = path.basename(audioFile);
|
||||
size = statSync(audioFile).size;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
|
||||
const sttOptions: Record<string, any> = { model: options.model };
|
||||
if (options.language) sttOptions.language = options.language;
|
||||
|
||||
const formData = new FormData();
|
||||
const fileBuffer = await readFileAsBlob(audioFile);
|
||||
formData.append('speech', fileBuffer, path.basename(audioFile));
|
||||
formData.append('options', JSON.stringify(sttOptions));
|
||||
|
||||
// Remove Content-Type for multipart/form-data (let fetch set it with boundary)
|
||||
const { 'Content-Type': _, ...formHeaders } = headers;
|
||||
|
||||
const res = await fetch(`${serverUrl}/webapi/stt/openai`, {
|
||||
body: formData,
|
||||
headers: formHeaders,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
log.error(`ASR failed: ${res.status} ${errText}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await getTrpcClient();
|
||||
const result = await res.json();
|
||||
|
||||
let result: { text: string };
|
||||
if (size > MAX_INLINE_AUDIO_BYTES) {
|
||||
// Large audio: upload to storage, then transcribe by fileId so the
|
||||
// bytes never travel inline through tRPC.
|
||||
process.stderr.write(
|
||||
`Audio is ${(size / 1024 / 1024).toFixed(1)}MB — uploading before transcription…\n`,
|
||||
);
|
||||
const record = (await uploadLocalFile(client, localPath)) as { id: string };
|
||||
result = await client.asr.transcribe.mutate({
|
||||
fileId: record.id,
|
||||
language: options.language,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
});
|
||||
} else {
|
||||
const bytes = await readFile(localPath);
|
||||
result = await client.asr.transcribe.mutate({
|
||||
audioBase64: Buffer.from(bytes).toString('base64'),
|
||||
fileName,
|
||||
language: options.language,
|
||||
mimeType,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
process.stdout.write(result.text);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`ASR failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (tempPath) {
|
||||
await rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
const text = (result as any).text || JSON.stringify(result);
|
||||
process.stdout.write(text);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Common audio MIME types mapped to a file extension the transcription
|
||||
// provider can recognize. Keep the extensions within the set OpenAI's
|
||||
// /audio/transcriptions endpoint accepts.
|
||||
const AUDIO_MIME_TO_EXT: Record<string, string> = {
|
||||
'audio/aac': 'aac',
|
||||
'audio/flac': 'flac',
|
||||
'audio/m4a': 'm4a',
|
||||
'audio/mp3': 'mp3',
|
||||
'audio/mp4': 'm4a',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/mpga': 'mp3',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/opus': 'ogg',
|
||||
'audio/wav': 'wav',
|
||||
'audio/wave': 'wav',
|
||||
'audio/webm': 'webm',
|
||||
'audio/x-m4a': 'm4a',
|
||||
'audio/x-wav': 'wav',
|
||||
};
|
||||
|
||||
async function fetchAudioFromUrl(
|
||||
url: string,
|
||||
): Promise<{ bytes: Uint8Array; mimeType?: string; name: string }> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download audio: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
|
||||
// Strip any parameters from the Content-Type (e.g. `audio/mpeg; charset=...`).
|
||||
const contentType = res.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
|
||||
const mimeType = contentType?.startsWith('audio/') ? contentType : undefined;
|
||||
|
||||
// Prefer the name the server advertises, then the URL path, then a fallback.
|
||||
const name =
|
||||
fileNameFromContentDisposition(res.headers.get('content-disposition')) ||
|
||||
basenameFromUrl(url) ||
|
||||
'audio';
|
||||
|
||||
// Transcription providers infer the audio format from the file extension, so
|
||||
// make sure the name carries one. Signed URLs and /download endpoints often
|
||||
// have no extension in the path — in that case borrow it from the
|
||||
// Content-Type when we recognize it.
|
||||
const ext = contentType ? AUDIO_MIME_TO_EXT[contentType] : undefined;
|
||||
const finalName = path.extname(name) || !ext ? name : `${name}.${ext}`;
|
||||
|
||||
return { bytes, mimeType, name: finalName };
|
||||
}
|
||||
|
||||
// Extract a file name from a Content-Disposition header, handling both the
|
||||
// plain `filename="x"` form and the RFC 5987 extended `filename*=UTF-8''x` form.
|
||||
function fileNameFromContentDisposition(header: string | null): string | undefined {
|
||||
if (!header) return undefined;
|
||||
|
||||
// Extended form takes precedence and may be percent-encoded.
|
||||
const extended = /filename\*=\s*(?:UTF-8|ISO-8859-1)?''([^;]+)/i.exec(header);
|
||||
if (extended?.[1]) {
|
||||
try {
|
||||
return path.basename(decodeURIComponent(extended[1].trim()));
|
||||
} catch {
|
||||
// Malformed encoding — fall through to the plain form.
|
||||
}
|
||||
}
|
||||
|
||||
const plain = /filename=\s*"?([^";]+)"?/i.exec(header);
|
||||
const value = plain?.[1]?.trim();
|
||||
return value ? path.basename(value) : undefined;
|
||||
}
|
||||
|
||||
// Derive the (URL-decoded) last path segment of a URL, if any.
|
||||
function basenameFromUrl(url: string): string | undefined {
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(url).pathname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const base = path.basename(pathname);
|
||||
if (!base) return undefined;
|
||||
try {
|
||||
return decodeURIComponent(base);
|
||||
} catch {
|
||||
return base;
|
||||
async function readFileAsBlob(filePath: string): Promise<Blob> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const stream = createReadStream(filePath);
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk as Uint8Array);
|
||||
}
|
||||
return new Blob(chunks);
|
||||
}
|
||||
|
||||
+74
-13
@@ -1,12 +1,14 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getAuthInfo } from '../api/http';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { uploadLocalFile } from '../utils/uploadLocalFile';
|
||||
|
||||
function formatFileType(fileType: string): string {
|
||||
if (!fileType) return '';
|
||||
@@ -322,22 +324,81 @@ export function registerKbCommand(program: Command) {
|
||||
.description('Upload a file to a knowledge base')
|
||||
.option('--parent <parentId>', 'Parent folder ID')
|
||||
.action(async (knowledgeBaseId: string, filePath: string, options: { parent?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await uploadLocalFile(client, filePath, {
|
||||
knowledgeBaseId,
|
||||
parentId: options.parent,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
log.error(`File not found: ${resolved}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolved);
|
||||
const fileName = path.basename(resolved);
|
||||
const fileBuffer = fs.readFileSync(resolved);
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
// Detect MIME type from extension
|
||||
const ext = path.extname(fileName).toLowerCase().slice(1);
|
||||
const mimeMap: Record<string, string> = {
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
gif: 'image/gif',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
json: 'application/json',
|
||||
md: 'text/markdown',
|
||||
mp3: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
svg: 'image/svg+xml',
|
||||
txt: 'text/plain',
|
||||
webp: 'image/webp',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
const fileType = mimeMap[ext] || 'application/octet-stream';
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
|
||||
// 1. Get presigned URL
|
||||
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
const pathname = `files/${date}/${hash}.${ext}`;
|
||||
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
|
||||
|
||||
// 2. Upload to S3
|
||||
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
|
||||
const uploadRes = await fetch(presignedUrl, {
|
||||
body: fileBuffer,
|
||||
headers: { 'Content-Type': fileType },
|
||||
method: 'PUT',
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
log.error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Create file record
|
||||
const result = await client.file.createFile.mutate({
|
||||
fileType,
|
||||
hash,
|
||||
knowledgeBaseId,
|
||||
metadata: {
|
||||
date,
|
||||
dirname: '',
|
||||
filename: fileName,
|
||||
path: pathname,
|
||||
},
|
||||
name: fileName,
|
||||
parentId: options.parent,
|
||||
size: stat.size,
|
||||
url: pathname,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Uploaded ${pc.bold(path.basename(filePath))} → ${pc.bold((result as any).id)}`,
|
||||
`${pc.green('✓')} Uploaded ${pc.bold(fileName)} → ${pc.bold((result as any).id)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { TrpcClient } from '../api/client';
|
||||
|
||||
/**
|
||||
* Minimal extension → MIME map for files uploaded from the local filesystem.
|
||||
* Unknown extensions fall back to `application/octet-stream`.
|
||||
*/
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
aac: 'audio/aac',
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
flac: 'audio/flac',
|
||||
gif: 'image/gif',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
json: 'application/json',
|
||||
m4a: 'audio/mp4',
|
||||
md: 'text/markdown',
|
||||
mp3: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
ogg: 'audio/ogg',
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
svg: 'image/svg+xml',
|
||||
txt: 'text/plain',
|
||||
wav: 'audio/wav',
|
||||
webm: 'audio/webm',
|
||||
webp: 'image/webp',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect a MIME type from a file name's extension.
|
||||
*/
|
||||
export const detectMimeType = (fileName: string): string => {
|
||||
const ext = path.extname(fileName).toLowerCase().slice(1);
|
||||
return MIME_MAP[ext] || 'application/octet-stream';
|
||||
};
|
||||
|
||||
export interface UploadLocalFileOptions {
|
||||
knowledgeBaseId?: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from the local filesystem, upload it to S3 via a pre-signed URL,
|
||||
* and create the corresponding file record. Shared by `file upload` and
|
||||
* `kb upload`.
|
||||
*
|
||||
* @returns the created file record
|
||||
*/
|
||||
export const uploadLocalFile = async (
|
||||
client: TrpcClient,
|
||||
filePath: string,
|
||||
options: UploadLocalFileOptions = {},
|
||||
) => {
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new Error(`File not found: ${resolved}`);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Not a file: ${resolved}`);
|
||||
}
|
||||
|
||||
const fileName = path.basename(resolved);
|
||||
const fileBuffer = fs.readFileSync(resolved);
|
||||
|
||||
// Compute SHA-256 hash for deduplication
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
const ext = path.extname(fileName).toLowerCase().slice(1);
|
||||
const fileType = detectMimeType(fileName);
|
||||
|
||||
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
|
||||
// 1. Dedup: if the same bytes are already stored (and the object still
|
||||
// exists), skip the S3 upload entirely and reuse the existing url.
|
||||
const existing = (await client.file.checkFileHash.mutate({ hash })) as {
|
||||
isExist?: boolean;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
let pathname: string;
|
||||
if (existing?.isExist && existing.url) {
|
||||
pathname = existing.url;
|
||||
} else {
|
||||
// 2. Get a pre-signed upload URL and PUT the bytes to S3
|
||||
pathname = ext ? `files/${date}/${hash}.${ext}` : `files/${date}/${hash}`;
|
||||
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
|
||||
|
||||
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
|
||||
const uploadRes = await fetch(presignedUrl, {
|
||||
body: fileBuffer,
|
||||
headers: { 'Content-Type': fileType },
|
||||
method: 'PUT',
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create the file record
|
||||
return await client.file.createFile.mutate({
|
||||
fileType,
|
||||
hash,
|
||||
knowledgeBaseId: options.knowledgeBaseId,
|
||||
metadata: {
|
||||
date,
|
||||
dirname: '',
|
||||
filename: fileName,
|
||||
path: pathname,
|
||||
},
|
||||
name: fileName,
|
||||
parentId: options.parentId,
|
||||
size: stat.size,
|
||||
url: pathname,
|
||||
});
|
||||
};
|
||||
@@ -1249,12 +1249,6 @@ export const createRuntimeExecutors = (
|
||||
},
|
||||
userTimezone: ctx.userTimezone,
|
||||
capabilities: {
|
||||
isCanUseAudio: (m: string, p: string) => {
|
||||
const info =
|
||||
builtinModels.find((item) => item.id === m && item.providerId === p) ??
|
||||
builtinModels.find((item) => item.id === m);
|
||||
return info?.abilities?.audio ?? false;
|
||||
},
|
||||
isCanUseFC: (m: string, p: string) => {
|
||||
const info = builtinModels.find((item) => item.id === m && item.providerId === p);
|
||||
return info?.abilities?.functionCall ?? true;
|
||||
|
||||
@@ -28,11 +28,7 @@ import { ToolsEngine } from '@lobechat/context-engine';
|
||||
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import {
|
||||
executionTargetToRuntimeMode,
|
||||
resolveExecutionTarget,
|
||||
resolveToolMode,
|
||||
} from '@/helpers/executionTarget';
|
||||
import { executionTargetToRuntimeMode, resolveExecutionTarget } from '@/helpers/executionTarget';
|
||||
import {
|
||||
buildAllowedBuiltinTools,
|
||||
DEVICE_TOOL_IDENTIFIERS,
|
||||
@@ -174,7 +170,9 @@ export const createServerAgentToolsEngine = (
|
||||
const isSearchEnabled = searchMode !== 'off';
|
||||
// Tool mode: explicit `toolMode` wins; otherwise derive from `enableAgentMode`
|
||||
// (undefined = agent). `custom` = toolset is exactly the agent's plugins.
|
||||
const toolMode = resolveToolMode(agentConfig.chatConfig ?? undefined);
|
||||
const toolMode: 'agent' | 'chat' | 'custom' =
|
||||
agentConfig.chatConfig?.toolMode ??
|
||||
(agentConfig.chatConfig?.enableAgentMode === false ? 'chat' : 'agent');
|
||||
const isChatMode = toolMode === 'chat';
|
||||
const isCustomMode = toolMode === 'custom';
|
||||
|
||||
|
||||
@@ -83,7 +83,6 @@ export const serverMessagesEngine = async ({
|
||||
const engine = new MessagesEngine({
|
||||
// Capability injection
|
||||
capabilities: {
|
||||
isCanUseAudio: capabilities?.isCanUseAudio,
|
||||
isCanUseFC: capabilities?.isCanUseFC,
|
||||
isCanUseVideo: capabilities?.isCanUseVideo,
|
||||
isCanUseVision: capabilities?.isCanUseVision,
|
||||
|
||||
@@ -23,8 +23,6 @@ import type { RuntimeInitialContext, UIChatMessage } from '@lobechat/types';
|
||||
* Model capability checker functions for server-side
|
||||
*/
|
||||
export interface ServerModelCapabilities {
|
||||
/** Check if audio input is supported */
|
||||
isCanUseAudio?: (model: string, provider: string) => boolean;
|
||||
/** Check if function calling is supported */
|
||||
isCanUseFC?: (model: string, provider: string) => boolean;
|
||||
/** Check if video is supported */
|
||||
|
||||
@@ -29,12 +29,10 @@ describe('aiModelRouter', () => {
|
||||
|
||||
it('should create ai model', async () => {
|
||||
const mockCreate = vi.fn().mockResolvedValue({ id: 'model-1' });
|
||||
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
|
||||
vi.mocked(AiModelModel).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
create: mockCreate,
|
||||
findByIdAndProvider: mockFindByIdAndProvider,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
@@ -46,68 +44,12 @@ describe('aiModelRouter', () => {
|
||||
});
|
||||
|
||||
expect(result).toBe('model-1');
|
||||
expect(mockFindByIdAndProvider).toHaveBeenCalledWith('test-model', 'test-provider');
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
id: 'test-model',
|
||||
providerId: 'test-provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject duplicate ai model before creating', async () => {
|
||||
const mockCreate = vi.fn();
|
||||
const mockFindByIdAndProvider = vi.fn().mockResolvedValue({ id: 'test-model' });
|
||||
vi.mocked(AiModelModel).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
create: mockCreate,
|
||||
findByIdAndProvider: mockFindByIdAndProvider,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const caller = aiModelRouter.createCaller(mockCtx);
|
||||
|
||||
await expect(
|
||||
caller.createAiModel({
|
||||
id: 'test-model',
|
||||
providerId: 'test-provider',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
message: 'Model "test-model" already exists',
|
||||
});
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should convert duplicate insert races to conflict errors', async () => {
|
||||
const duplicateError = Object.assign(new Error('failed query'), {
|
||||
cause: Object.assign(new Error('duplicate key'), {
|
||||
code: '23505',
|
||||
constraint: 'ai_models_id_provider_id_user_id_pk',
|
||||
}),
|
||||
});
|
||||
const mockCreate = vi.fn().mockRejectedValue(duplicateError);
|
||||
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
|
||||
vi.mocked(AiModelModel).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
create: mockCreate,
|
||||
findByIdAndProvider: mockFindByIdAndProvider,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const caller = aiModelRouter.createCaller(mockCtx);
|
||||
|
||||
await expect(
|
||||
caller.createAiModel({
|
||||
id: 'test-model',
|
||||
providerId: 'test-provider',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
message: 'Model "test-model" already exists',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get ai model by id', async () => {
|
||||
const mockModel = {
|
||||
id: 'model-1',
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { asrRouter } from '../asr';
|
||||
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const transcribeMock = vi.fn();
|
||||
vi.mock('@/server/modules/ModelRuntime', () => ({
|
||||
initModelRuntimeFromDB: vi.fn(async () => ({ transcribe: transcribeMock })),
|
||||
}));
|
||||
|
||||
const findByIdMock = vi.fn();
|
||||
vi.mock('@/database/models/file', () => ({
|
||||
FileModel: vi.fn(() => ({ findById: findByIdMock })),
|
||||
}));
|
||||
|
||||
const getFileByteArrayMock = vi.fn();
|
||||
vi.mock('@/server/services/file', () => ({
|
||||
FileService: vi.fn(() => ({ getFileByteArray: getFileByteArrayMock })),
|
||||
}));
|
||||
|
||||
const caller = asrRouter.createCaller({ jwtPayload: { userId: 'u1' }, userId: 'u1' } as any);
|
||||
|
||||
beforeEach(() => {
|
||||
transcribeMock.mockResolvedValue({ text: 'hello world' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('asrRouter.transcribe', () => {
|
||||
it('transcribes inline base64 audio', async () => {
|
||||
const res = await caller.transcribe({
|
||||
audioBase64: Buffer.from('audio-bytes').toString('base64'),
|
||||
fileName: 'clip.mp3',
|
||||
model: 'whisper-1',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(res).toEqual({ text: 'hello world' });
|
||||
expect(findByIdMock).not.toHaveBeenCalled();
|
||||
|
||||
const payload = transcribeMock.mock.calls[0][0];
|
||||
expect(payload.file).toBeInstanceOf(File);
|
||||
expect(payload.fileName).toBe('clip.mp3');
|
||||
expect(await payload.file.text()).toBe('audio-bytes');
|
||||
});
|
||||
|
||||
it('resolves a fileId by downloading the bytes from storage', async () => {
|
||||
findByIdMock.mockResolvedValue({
|
||||
fileType: 'audio/mp4',
|
||||
name: 'meeting.m4a',
|
||||
url: 's3-key/meeting.m4a',
|
||||
});
|
||||
getFileByteArrayMock.mockResolvedValue(new Uint8Array(Buffer.from('from-s3')));
|
||||
|
||||
const res = await caller.transcribe({ fileId: 'file_123', model: 'whisper-1' });
|
||||
|
||||
expect(res).toEqual({ text: 'hello world' });
|
||||
expect(findByIdMock).toHaveBeenCalledWith('file_123');
|
||||
expect(getFileByteArrayMock).toHaveBeenCalledWith('s3-key/meeting.m4a');
|
||||
|
||||
const payload = transcribeMock.mock.calls[0][0];
|
||||
expect(payload.fileName).toBe('meeting.m4a');
|
||||
expect(payload.file.type).toBe('audio/mp4');
|
||||
expect(await payload.file.text()).toBe('from-s3');
|
||||
});
|
||||
|
||||
it('rejects when neither fileId nor audioBase64 is provided', async () => {
|
||||
await expect(caller.transcribe({ model: 'whisper-1' } as any)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects oversized inline base64 and guides to fileId', async () => {
|
||||
// > 3MB decoded → base64 string exceeds the cap
|
||||
const tooBig = 'A'.repeat(5 * 1024 * 1024);
|
||||
|
||||
await expect(caller.transcribe({ audioBase64: tooBig, model: 'whisper-1' })).rejects.toThrow(
|
||||
/fileId/i,
|
||||
);
|
||||
expect(transcribeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when both fileId and audioBase64 are provided', async () => {
|
||||
await expect(
|
||||
caller.transcribe({
|
||||
audioBase64: Buffer.from('x').toString('base64'),
|
||||
fileId: 'file_123',
|
||||
model: 'whisper-1',
|
||||
} as any),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws NOT_FOUND when the fileId does not exist', async () => {
|
||||
findByIdMock.mockResolvedValue(undefined);
|
||||
|
||||
await expect(caller.transcribe({ fileId: 'missing', model: 'whisper-1' })).rejects.toThrow(
|
||||
/not found/i,
|
||||
);
|
||||
expect(getFileByteArrayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NOT_FOUND when the stored object is gone (NoSuchKey)', async () => {
|
||||
findByIdMock.mockResolvedValue({
|
||||
fileType: 'audio/mp4',
|
||||
name: 'gone.m4a',
|
||||
url: 's3-key/gone.m4a',
|
||||
});
|
||||
getFileByteArrayMock.mockRejectedValue({ Code: 'NoSuchKey' });
|
||||
|
||||
await expect(caller.transcribe({ fileId: 'file_x', model: 'whisper-1' })).rejects.toThrow(
|
||||
/no longer available/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DeviceModel } from '@/database/models/device';
|
||||
|
||||
import { assertWorkspaceRootApproved } from '../deviceWorkspaceGuard';
|
||||
|
||||
const mockModel = (row: { defaultCwd?: string | null; workingDirs?: { path: string }[] } | null) =>
|
||||
({
|
||||
findByDeviceId: vi.fn().mockResolvedValue(row),
|
||||
}) as unknown as DeviceModel;
|
||||
|
||||
describe('assertWorkspaceRootApproved', () => {
|
||||
it('allows a root that exactly matches a bound workingDir', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a root nested inside a bound workingDir', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj/packages/app'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a root matching defaultCwd when no workingDirs match', async () => {
|
||||
const model = mockModel({ defaultCwd: '/Users/me/default', workingDirs: [] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/default'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a root that escapes the approved roots (filesystem root)', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(assertWorkspaceRootApproved(model, 'dev-1', '/')).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a sibling directory that shares a path prefix but is not contained', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj-evil'),
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('rejects when the device has no approved roots at all', async () => {
|
||||
const model = mockModel({ workingDirs: [] });
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('rejects when the device row is missing', async () => {
|
||||
const model = mockModel(null);
|
||||
await expect(
|
||||
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
|
||||
).rejects.toBeInstanceOf(TRPCError);
|
||||
});
|
||||
|
||||
it('rejects an empty workspace root with BAD_REQUEST before hitting the DB', async () => {
|
||||
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
|
||||
await expect(assertWorkspaceRootApproved(model, 'dev-1', '')).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(model.findByDeviceId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { type AiProviderModelListItem } from 'model-bank';
|
||||
import {
|
||||
AiModelTypeSchema,
|
||||
@@ -19,30 +18,6 @@ import { getServerGlobalConfig } from '@/server/globalConfig';
|
||||
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
|
||||
import { type ProviderConfig } from '@/types/user/settings';
|
||||
|
||||
const AI_MODEL_UNIQUE_CONSTRAINT = 'ai_models_id_provider_id_user_id_pk';
|
||||
|
||||
const getPostgresErrorField = (error: unknown, field: 'code' | 'constraint') => {
|
||||
let current = error;
|
||||
|
||||
while (current && typeof current === 'object') {
|
||||
const value = (current as Record<string, unknown>)[field];
|
||||
if (typeof value === 'string') return value;
|
||||
|
||||
current = (current as { cause?: unknown }).cause;
|
||||
}
|
||||
};
|
||||
|
||||
const isDuplicateAiModelError = (error: unknown) =>
|
||||
getPostgresErrorField(error, 'code') === '23505' &&
|
||||
getPostgresErrorField(error, 'constraint') === AI_MODEL_UNIQUE_CONSTRAINT;
|
||||
|
||||
const throwDuplicateAiModelError = (id: string): never => {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: `Model "${id}" already exists`,
|
||||
});
|
||||
};
|
||||
|
||||
const aiModelProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
@@ -107,18 +82,9 @@ export const aiModelRouter = router({
|
||||
.use(withScopedPermission('ai_model:create'))
|
||||
.input(CreateAiModelSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const existingModel = await ctx.aiModelModel.findByIdAndProvider(input.id, input.providerId);
|
||||
if (existingModel) throwDuplicateAiModelError(input.id);
|
||||
const data = await ctx.aiModelModel.create(input);
|
||||
|
||||
try {
|
||||
const data = await ctx.aiModelModel.create(input);
|
||||
|
||||
return data?.id;
|
||||
} catch (error) {
|
||||
if (isDuplicateAiModelError(error)) throwDuplicateAiModelError(input.id);
|
||||
|
||||
throw error;
|
||||
}
|
||||
return data?.id;
|
||||
}),
|
||||
|
||||
getAiModelById: aiModelProcedure
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { FileService } from '@/server/services/file';
|
||||
|
||||
const asrProcedure = wsCompatProcedure.use(serverDatabase);
|
||||
|
||||
// Inline base64 is only for short clips. The whole request must fit inside the
|
||||
// platform body limit (≈4.5MB on serverless deploys) and base64 inflates bytes
|
||||
// by ~4/3, so cap the decoded audio well under that — anything larger should be
|
||||
// uploaded and passed as `fileId`.
|
||||
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
|
||||
// base64 length ≈ ceil(bytes / 3) * 4; validating the string length lets us
|
||||
// reject oversized payloads before allocating/decoding them.
|
||||
const MAX_INLINE_AUDIO_BASE64_CHARS = Math.ceil(MAX_INLINE_AUDIO_BYTES / 3) * 4;
|
||||
|
||||
interface ResolvedAudio {
|
||||
bytes: Uint8Array;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export const asrRouter = router({
|
||||
/**
|
||||
* Automatic Speech Recognition (speech-to-text).
|
||||
*
|
||||
* Accepts the audio either as an already-uploaded `fileId` (preferred — the
|
||||
* server streams the bytes from storage, nothing large travels over tRPC) or
|
||||
* inline as base64 for short clips (capped at `MAX_INLINE_AUDIO_BYTES`;
|
||||
* larger payloads are rejected with guidance to upload and pass `fileId`).
|
||||
*
|
||||
* Note on base64: tRPC here uses an `httpLink` + superjson (JSON only), which
|
||||
* has no binary representation for a `Buffer`/`Uint8Array` — a raw buffer would
|
||||
* serialize to a per-byte JSON object, far worse than base64. So inline bytes
|
||||
* stay base64; use `fileId` to avoid inlining entirely.
|
||||
*
|
||||
* Transcription is a single request/response (not streamed), so a mutation is
|
||||
* the right shape.
|
||||
*/
|
||||
transcribe: asrProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
/** Base64-encoded audio bytes (short clips only). Mutually exclusive with `fileId`. */
|
||||
audioBase64: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MAX_INLINE_AUDIO_BASE64_CHARS, {
|
||||
message: `Inline audio is limited to ${MAX_INLINE_AUDIO_BYTES / 1024 / 1024}MB. Upload the file and pass \`fileId\` instead.`,
|
||||
})
|
||||
.optional(),
|
||||
/** Already-uploaded audio file id. Mutually exclusive with `audioBase64`. */
|
||||
fileId: z.string().min(1).optional(),
|
||||
/** Original file name (base64 path); its extension helps format detection. */
|
||||
fileName: z.string().optional(),
|
||||
/** ISO-639-1 language code (e.g. `en`, `zh`). */
|
||||
language: z.string().optional(),
|
||||
/** Audio mime type (base64 path, e.g. `audio/mp4`). */
|
||||
mimeType: z.string().optional(),
|
||||
model: z.string().min(1),
|
||||
/** Optional text to guide the model's style. */
|
||||
prompt: z.string().optional(),
|
||||
provider: z.string().default('openai'),
|
||||
responseFormat: z.enum(['json', 'srt', 'text', 'verbose_json', 'vtt']).optional(),
|
||||
})
|
||||
.refine((d) => Boolean(d.fileId) !== Boolean(d.audioBase64), {
|
||||
message: 'Provide exactly one of `fileId` or `audioBase64`.',
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }): Promise<{ text: string }> => {
|
||||
const workspaceId = ctx.workspaceId ?? undefined;
|
||||
|
||||
const { bytes, fileName, mimeType } = await resolveAudio(ctx, input, workspaceId);
|
||||
|
||||
// Resolve the user's provider config (key + baseURL) from the database,
|
||||
// falling back to server env keys, exactly like chat/embeddings do.
|
||||
const runtime = await initModelRuntimeFromDB(
|
||||
ctx.serverDB,
|
||||
ctx.userId,
|
||||
input.provider,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// `Uint8Array` is a valid BlobPart at runtime; the cast sidesteps the
|
||||
// `Uint8Array<ArrayBufferLike>` vs BlobPart generic mismatch in lib.dom.
|
||||
const file = new File([bytes as BlobPart], fileName, {
|
||||
type: mimeType || 'application/octet-stream',
|
||||
});
|
||||
|
||||
const result = await runtime.transcribe(
|
||||
{
|
||||
file,
|
||||
fileName,
|
||||
language: input.language,
|
||||
model: input.model,
|
||||
prompt: input.prompt,
|
||||
responseFormat: input.responseFormat,
|
||||
},
|
||||
{ user: ctx.userId },
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_IMPLEMENTED',
|
||||
message: `Provider "${input.provider}" does not support ASR.`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Turn the request into raw audio bytes + metadata, from either a stored file
|
||||
* (downloaded from S3, ownership enforced by the userId-scoped FileModel) or the
|
||||
* inline base64 payload.
|
||||
*/
|
||||
async function resolveAudio(
|
||||
ctx: { serverDB: LobeChatDatabase; userId: string },
|
||||
input: { audioBase64?: string; fileId?: string; fileName?: string; mimeType?: string },
|
||||
workspaceId?: string,
|
||||
): Promise<ResolvedAudio> {
|
||||
if (input.fileId) {
|
||||
const fileModel = new FileModel(ctx.serverDB, ctx.userId, workspaceId);
|
||||
const fileItem = await fileModel.findById(input.fileId);
|
||||
|
||||
if (!fileItem) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `File "${input.fileId}" not found.` });
|
||||
}
|
||||
|
||||
const fileService = new FileService(ctx.serverDB, ctx.userId, workspaceId);
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = await fileService.getFileByteArray(fileItem.url);
|
||||
} catch (error) {
|
||||
if ((error as { Code?: string }).Code === 'NoSuchKey') {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `File "${input.fileId}" is no longer available in storage.`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { bytes, fileName: fileItem.name, mimeType: fileItem.fileType };
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: new Uint8Array(Buffer.from(input.audioBase64!, 'base64')),
|
||||
fileName: input.fileName || 'audio',
|
||||
mimeType: input.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
export type AsrRouter = typeof asrRouter;
|
||||
@@ -8,7 +8,6 @@ import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
|
||||
import { preserveWorkspaceCache } from './deviceWorkingDirs';
|
||||
import { assertWorkspaceRootApproved } from './deviceWorkspaceGuard';
|
||||
|
||||
// Derive the zod enum from the canonical config so new platforms are
|
||||
// automatically covered without touching this file.
|
||||
@@ -30,23 +29,6 @@ const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
|
||||
});
|
||||
});
|
||||
|
||||
const workspaceFileInput = z.object({
|
||||
deviceId: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* `deviceProcedure` that additionally requires `workingDirectory` to be an
|
||||
* approved workspace root for the device. Builds the guard into the procedure
|
||||
* so every file-mutating route inherits it and can never forget the check —
|
||||
* see {@link assertWorkspaceRootApproved} for why the check is necessary.
|
||||
*/
|
||||
const workspaceFileProcedure = deviceProcedure.input(workspaceFileInput).use(async (opts) => {
|
||||
const { deviceId, workingDirectory } = workspaceFileInput.parse(await opts.getRawInput());
|
||||
await assertWorkspaceRootApproved(opts.ctx.deviceModel, deviceId, workingDirectory);
|
||||
return opts.next();
|
||||
});
|
||||
|
||||
export const deviceRouter = router({
|
||||
/**
|
||||
* Probe whether a specific agent platform (openclaw / hermes) is available
|
||||
@@ -352,22 +334,24 @@ export const deviceRouter = router({
|
||||
* Read-only local file preview for a file on a remote device. The web client
|
||||
* receives render data, not a `localfile://` URL; saving remains unsupported.
|
||||
*/
|
||||
getLocalFilePreview: workspaceFileProcedure
|
||||
getLocalFilePreview: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
accept: z.enum(['image']).optional(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return deviceGateway.getLocalFilePreview({
|
||||
.query(async ({ ctx, input }) =>
|
||||
deviceGateway.getLocalFilePreview({
|
||||
accept: input.accept,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
|
||||
@@ -404,62 +388,68 @@ export const deviceRouter = router({
|
||||
* Move files/folders within a directory on a remote device, via the device's
|
||||
* `moveLocalFiles` RPC. Powers the Files tree's drag-to-move in device mode.
|
||||
*/
|
||||
moveProjectFiles: workspaceFileProcedure
|
||||
moveProjectFiles: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
items: z.array(z.object({ newPath: z.string(), oldPath: z.string() })),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return deviceGateway.moveProjectFiles({
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.moveProjectFiles({
|
||||
deviceId: input.deviceId,
|
||||
items: input.items,
|
||||
userId: ctx.userId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Rename a single file/folder in a directory on a remote device, via the
|
||||
* device's `renameLocalFile` RPC.
|
||||
*/
|
||||
renameProjectFile: workspaceFileProcedure
|
||||
renameProjectFile: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
newName: z.string(),
|
||||
path: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return deviceGateway.renameProjectFile({
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.renameProjectFile({
|
||||
deviceId: input.deviceId,
|
||||
newName: input.newName,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
workingDirectory: input.workingDirectory,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Save edited content back to a file on a remote device, via the device's
|
||||
* `writeLocalFile` RPC. Powers remote save in the LocalFile editor.
|
||||
*/
|
||||
writeProjectFile: workspaceFileProcedure
|
||||
writeProjectFile: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return deviceGateway.writeProjectFile({
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
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
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import type { DeviceModel } from '@/database/models/device';
|
||||
import { isPathWithinRoot } from '@/server/services/deviceGateway';
|
||||
|
||||
/**
|
||||
* Validate that a client-supplied workspace root is actually one the user has
|
||||
* bound to this device.
|
||||
*
|
||||
* The file routes (move / rename / write / preview) receive `workingDirectory`
|
||||
* from the same untrusted browser session that supplies the file paths. The
|
||||
* gateway's `assertPathsWithinWorkspace` only proves the paths sit *inside that
|
||||
* directory* — it never proves the directory itself is legitimate. So a caller
|
||||
* could set `workingDirectory` to `/` (or `C:\`), pass that containment check
|
||||
* trivially, and reach any path on the device.
|
||||
*
|
||||
* To close that hole we re-derive the approved roots from the *server-owned*
|
||||
* device row — the `workingDirs` recent list and `defaultCwd`, both written only
|
||||
* via `device.updateDevice` / the run path, never trusted from this request —
|
||||
* and require the requested root to equal or nest inside one of them before any
|
||||
* RPC is forwarded. The picker upserts every chosen directory into `workingDirs`
|
||||
* (see `useCommitWorkingDirectory`) and run start upserts the bound cwd, so a
|
||||
* legitimately-selected workspace is always present here.
|
||||
*/
|
||||
export const assertWorkspaceRootApproved = async (
|
||||
deviceModel: DeviceModel,
|
||||
deviceId: string,
|
||||
workingDirectory: string,
|
||||
): Promise<void> => {
|
||||
if (!workingDirectory) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'A workspace root is required for file operations',
|
||||
});
|
||||
}
|
||||
|
||||
const device = await deviceModel.findByDeviceId(deviceId);
|
||||
|
||||
const approvedRoots = [
|
||||
...(device?.workingDirs ?? []).map((dir) => dir.path),
|
||||
...(device?.defaultCwd ? [device.defaultCwd] : []),
|
||||
].filter((root): root is string => Boolean(root));
|
||||
|
||||
const approved = approvedRoots.some((root) => isPathWithinRoot(root, workingDirectory));
|
||||
|
||||
if (!approved) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Working directory is not an approved workspace for this device',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -32,7 +32,6 @@ import { aiChatRouter } from './aiChat';
|
||||
import { aiModelRouter } from './aiModel';
|
||||
import { aiProviderRouter } from './aiProvider';
|
||||
import { apiKeyRouter } from './apiKey';
|
||||
import { asrRouter } from './asr';
|
||||
import { botMessageRouter } from './botMessage';
|
||||
import { briefRouter } from './brief';
|
||||
import { changelogRouter } from './changelog';
|
||||
@@ -99,7 +98,6 @@ export const lambdaRouter = router({
|
||||
aiModel: aiModelRouter,
|
||||
aiProvider: aiProviderRouter,
|
||||
apiKey: apiKeyRouter,
|
||||
asr: asrRouter,
|
||||
chunk: chunkRouter,
|
||||
comfyui: comfyuiRouter,
|
||||
config: configRouter,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEFAULT_INBOX_AVATAR, INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, desc, eq, ne, or } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
isMessengerPlatformEnabled,
|
||||
type MessengerPlatform,
|
||||
} from '@/config/messenger';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import {
|
||||
MessengerAccountLinkConflictError,
|
||||
MessengerAccountLinkModel,
|
||||
@@ -23,6 +23,7 @@ import { RbacModel } from '@/database/models/rbac';
|
||||
import { WorkspaceModel } from '@/database/models/workspace';
|
||||
import { agents, users } from '@/database/schemas';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { buildWorkspaceWhere } from '@/database/utils/workspace';
|
||||
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { getServerFeatureFlagsStateFromRuntimeConfig } from '@/server/featureFlags';
|
||||
@@ -121,12 +122,6 @@ const messengerProcedure = authedProcedure.use(serverDatabase).use(async (opts)
|
||||
// userId), and per-agent authorization happens in-handler via
|
||||
// `resolveAuthorizedAgentScope`.
|
||||
messengerLinkModel: new MessengerAccountLinkModel(ctx.serverDB, ctx.userId),
|
||||
// The bindable-agents scope is request-driven — the cascading scope
|
||||
// picker passes the workspace via input, not the ambient header — so
|
||||
// expose a workspace-parameterized AgentModel factory rather than a
|
||||
// single pre-scoped instance.
|
||||
getAgentModel: (workspaceId?: string | null) =>
|
||||
new AgentModel(ctx.serverDB, ctx.userId, workspaceId ?? undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -459,10 +454,44 @@ export const messengerRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// Inbox meta fallback, the virtual-or-inbox filter, inbox pinning, and the
|
||||
// `isInbox` flag all live in the model. Blank non-inbox titles stay null
|
||||
// here so the web picker can apply its own i18n default.
|
||||
return ctx.getAgentModel(workspaceId).listMessengerBindableAgents();
|
||||
const rows = await serverDB
|
||||
.select({
|
||||
avatar: agents.avatar,
|
||||
backgroundColor: agents.backgroundColor,
|
||||
id: agents.id,
|
||||
slug: agents.slug,
|
||||
title: agents.title,
|
||||
})
|
||||
.from(agents)
|
||||
.where(
|
||||
and(
|
||||
buildWorkspaceWhere({ userId, workspaceId: workspaceId ?? undefined }, agents),
|
||||
or(ne(agents.virtual, true), eq(agents.slug, INBOX_SESSION_ID)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agents.updatedAt));
|
||||
|
||||
const mapped = rows
|
||||
.filter((row) => row.id)
|
||||
.map((row) => ({
|
||||
avatar: row.avatar || (row.slug === INBOX_SESSION_ID ? DEFAULT_INBOX_AVATAR : null),
|
||||
backgroundColor: row.backgroundColor,
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title || (row.slug === INBOX_SESSION_ID ? 'LobeAI' : null),
|
||||
}));
|
||||
|
||||
// Pin the inbox/LobeAI agent to the top regardless of updatedAt — it's
|
||||
// the implicit "default" agent and should always be the first option.
|
||||
const inboxIdx = mapped.findIndex((row) => row.slug === INBOX_SESSION_ID);
|
||||
if (inboxIdx > 0) {
|
||||
const [inbox] = mapped.splice(inboxIdx, 1);
|
||||
mapped.unshift(inbox);
|
||||
}
|
||||
return mapped.map(({ slug, ...rest }) => ({
|
||||
...rest,
|
||||
isInbox: slug === INBOX_SESSION_ID,
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { TASK_TEMPLATE_RECOMMEND_MAX_COUNT } from '@lobechat/const';
|
||||
import { KNOWN_TASK_TEMPLATE_IDS } from '@lobechat/const';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import {
|
||||
ENABLED_TASK_TEMPLATE_CONNECTORS,
|
||||
TaskTemplateService,
|
||||
} from '@/server/services/taskTemplate';
|
||||
import { ENABLED_SKILL_SOURCES, TaskTemplateService } from '@/server/services/taskTemplate';
|
||||
|
||||
const listDailyRecommendSchema = z.object({
|
||||
count: z.number().int().min(1).max(TASK_TEMPLATE_RECOMMEND_MAX_COUNT).optional(),
|
||||
count: z.number().int().min(1).optional(),
|
||||
interestKeys: z.array(z.string().max(64)).max(32),
|
||||
locale: z.string().max(32).optional(),
|
||||
refreshSeed: z.string().min(1).max(32).optional(),
|
||||
});
|
||||
|
||||
const templateIdSchema = z.object({
|
||||
templateId: z.number().int().positive(),
|
||||
templateId: z
|
||||
.string()
|
||||
.max(64)
|
||||
.refine((id) => KNOWN_TASK_TEMPLATE_IDS.has(id), { message: 'Unknown task template id' }),
|
||||
});
|
||||
|
||||
export const taskTemplateRouter = router({
|
||||
@@ -29,8 +28,7 @@ export const taskTemplateRouter = router({
|
||||
const service = new TaskTemplateService(ctx.userId);
|
||||
const data = await service.listDailyRecommend(input.interestKeys, {
|
||||
count: input.count,
|
||||
enabledConnectors: ENABLED_TASK_TEMPLATE_CONNECTORS,
|
||||
locale: input.locale,
|
||||
enabledSkillSources: ENABLED_SKILL_SOURCES,
|
||||
refreshSeed: input.refreshSeed,
|
||||
});
|
||||
return { data, success: true };
|
||||
|
||||
@@ -4,21 +4,19 @@ import {
|
||||
type RecentTopicGroupMember,
|
||||
} from '@lobechat/types';
|
||||
import { cleanObject } from '@lobechat/utils';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { after } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { ChatGroupModel } from '@/database/models/chatGroup';
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import { TopicShareModel } from '@/database/models/topicShare';
|
||||
import { AgentMigrationRepo } from '@/database/repositories/agentMigration';
|
||||
import { TopicImporterRepo } from '@/database/repositories/topicImporter';
|
||||
import { chatGroups } from '@/database/schemas';
|
||||
import { agents, chatGroups, chatGroupsAgents } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { type BatchTaskResult } from '@/types/service';
|
||||
@@ -37,9 +35,7 @@ const topicProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
|
||||
return opts.next({
|
||||
ctx: {
|
||||
agentMigrationRepo: new AgentMigrationRepo(ctx.serverDB, ctx.userId, wsId),
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
agentOperationModel: new AgentOperationModel(ctx.serverDB, ctx.userId, wsId),
|
||||
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
|
||||
topicImporterRepo: new TopicImporterRepo(ctx.serverDB, ctx.userId, wsId),
|
||||
topicModel: new TopicModel(ctx.serverDB, ctx.userId, wsId),
|
||||
topicShareModel: new TopicShareModel(ctx.serverDB, ctx.userId, wsId),
|
||||
@@ -449,14 +445,22 @@ export const topicRouter = router({
|
||||
// Collect all agentIds to fetch agent info
|
||||
const allAgentIds = [...new Set(topicAgentIdMap.values())];
|
||||
|
||||
// Batch query agent info (already normalized for the inbox agent)
|
||||
// Batch query agent info
|
||||
const agentInfoMap = new Map<
|
||||
string,
|
||||
{ avatar: string | null; backgroundColor: string | null; id: string; title: string | null }
|
||||
>();
|
||||
|
||||
if (allAgentIds.length > 0) {
|
||||
const agentInfos = await ctx.agentModel.getAgentAvatarsByIds(allAgentIds);
|
||||
const agentInfos = await ctx.serverDB
|
||||
.select({
|
||||
avatar: agents.avatar,
|
||||
backgroundColor: agents.backgroundColor,
|
||||
id: agents.id,
|
||||
title: agents.title,
|
||||
})
|
||||
.from(agents)
|
||||
.where(inArray(agents.id, allAgentIds));
|
||||
|
||||
for (const agent of agentInfos) {
|
||||
agentInfoMap.set(agent.id, agent);
|
||||
@@ -477,9 +481,28 @@ export const topicRouter = router({
|
||||
.from(chatGroups)
|
||||
.where(inArray(chatGroups.id, allGroupIds));
|
||||
|
||||
// Query group member avatars (already normalized for the inbox agent)
|
||||
const groupMembersMap: Map<string, RecentTopicGroupMember[]> =
|
||||
await ctx.chatGroupModel.getMemberAvatarsByGroupIds(allGroupIds);
|
||||
// Query group member agents (get avatar info)
|
||||
const groupMembersRaw = await ctx.serverDB
|
||||
.select({
|
||||
agentAvatar: agents.avatar,
|
||||
agentBackgroundColor: agents.backgroundColor,
|
||||
chatGroupId: chatGroupsAgents.chatGroupId,
|
||||
order: chatGroupsAgents.order,
|
||||
})
|
||||
.from(chatGroupsAgents)
|
||||
.leftJoin(agents, eq(chatGroupsAgents.agentId, agents.id))
|
||||
.where(inArray(chatGroupsAgents.chatGroupId, allGroupIds));
|
||||
|
||||
// Group members by chatGroupId
|
||||
const groupMembersMap = new Map<string, RecentTopicGroupMember[]>();
|
||||
for (const member of groupMembersRaw) {
|
||||
const members = groupMembersMap.get(member.chatGroupId) || [];
|
||||
members.push({
|
||||
avatar: member.agentAvatar,
|
||||
backgroundColor: member.agentBackgroundColor,
|
||||
});
|
||||
groupMembersMap.set(member.chatGroupId, members);
|
||||
}
|
||||
|
||||
// Build group info map
|
||||
for (const group of chatGroupInfos) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { DEFAULT_AGENT_CONFIG, DEFAULT_INBOX_AVATAR, DEFAULT_INBOX_TITLE } from '@lobechat/const';
|
||||
import { DEFAULT_AGENT_CONFIG } from '@lobechat/const';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
@@ -190,12 +190,10 @@ describe('AgentService', () => {
|
||||
expect(result?.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should fallback inbox title and avatar', async () => {
|
||||
it('should merge avatar from builtin-agents package definition', async () => {
|
||||
const mockAgent = {
|
||||
avatar: null,
|
||||
id: 'agent-1',
|
||||
slug: 'inbox',
|
||||
title: null,
|
||||
model: 'gpt-4',
|
||||
};
|
||||
|
||||
@@ -209,8 +207,8 @@ describe('AgentService', () => {
|
||||
const newService = new AgentService(mockDb, mockUserId);
|
||||
const result = await newService.getBuiltinAgent('inbox');
|
||||
|
||||
expect((result as any)?.avatar).toBe(DEFAULT_INBOX_AVATAR);
|
||||
expect((result as any)?.title).toBe(DEFAULT_INBOX_TITLE);
|
||||
// Avatar should be merged from BUILTIN_AGENTS definition
|
||||
expect((result as any)?.avatar).toBe('/avatars/lobe-ai.png');
|
||||
});
|
||||
|
||||
it('should not include avatar for non-builtin agents', async () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { type PartialDeep } from 'type-fest';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import { SessionModel } from '@/database/models/session';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import { normalizeInboxAgentAvatar, normalizeInboxAgentTitle } from '@/database/utils/inboxAgent';
|
||||
import { getRedisConfig } from '@/envs/redis';
|
||||
import {
|
||||
getJSONFromRedis,
|
||||
@@ -84,20 +83,14 @@ export class AgentService {
|
||||
|
||||
const mergedConfig = this.mergeDefaultConfig(agent, defaultAgentConfig);
|
||||
if (!mergedConfig) return null;
|
||||
const identity = { slug: (mergedConfig as { slug?: string | null }).slug ?? slug };
|
||||
const normalizedConfig = {
|
||||
...mergedConfig,
|
||||
avatar: normalizeInboxAgentAvatar(mergedConfig.avatar, identity),
|
||||
title: normalizeInboxAgentTitle(mergedConfig.title, identity),
|
||||
};
|
||||
|
||||
// Use builtin avatar as fallback only when DB has no custom avatar
|
||||
const builtinAgent = BUILTIN_AGENTS[slug as BuiltinAgentSlug];
|
||||
if (builtinAgent?.avatar && !normalizedConfig.avatar) {
|
||||
return { ...normalizedConfig, avatar: builtinAgent.avatar };
|
||||
if (builtinAgent?.avatar && !mergedConfig.avatar) {
|
||||
return { ...mergedConfig, avatar: builtinAgent.avatar };
|
||||
}
|
||||
|
||||
return normalizedConfig;
|
||||
return mergedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,78 +3,69 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDefaultSnapshotStore, shouldUseAgentS3Tracing } from '../snapshotStore';
|
||||
|
||||
const s3Store = { kind: 's3' } as any;
|
||||
const fileStore = { kind: 'file' } as any;
|
||||
const createS3 = vi.fn(() => s3Store);
|
||||
const createFile = vi.fn(() => fileStore);
|
||||
const factories = { createFile, createS3 };
|
||||
const s3SnapshotStoreMock = vi.fn(() => ({ kind: 's3' }));
|
||||
const fileSnapshotStoreMock = vi.fn(() => ({ kind: 'file' }));
|
||||
|
||||
const setEnv = (nodeEnv: string, agentS3Tracing?: string) => {
|
||||
vi.stubEnv('NODE_ENV', nodeEnv);
|
||||
vi.stubEnv('ENABLE_AGENT_S3_TRACING', agentS3Tracing);
|
||||
};
|
||||
|
||||
const loadModule = vi.fn((moduleName: string) => {
|
||||
if (moduleName === '@/server/modules/AgentTracing') {
|
||||
return { S3SnapshotStore: s3SnapshotStoreMock };
|
||||
}
|
||||
|
||||
if (moduleName === '@lobechat/agent-tracing') {
|
||||
return { FileSnapshotStore: fileSnapshotStoreMock };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected module: ${moduleName}`);
|
||||
});
|
||||
|
||||
describe('agent runtime snapshot store defaults', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('enables S3 tracing by default in production when env is unset', () => {
|
||||
setEnv('production');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(true);
|
||||
expect(createDefaultSnapshotStore(factories)).toBe(s3Store);
|
||||
expect(createS3).toHaveBeenCalledTimes(1);
|
||||
expect(createFile).not.toHaveBeenCalled();
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
|
||||
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the local file snapshot store in development when env is unset', () => {
|
||||
setEnv('development');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(false);
|
||||
expect(createDefaultSnapshotStore(factories)).toBe(fileStore);
|
||||
expect(createS3).not.toHaveBeenCalled();
|
||||
expect(createFile).toHaveBeenCalledTimes(1);
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 'file' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@lobechat/agent-tracing');
|
||||
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(fileSnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets ENABLE_AGENT_S3_TRACING=1 force S3 tracing outside production', () => {
|
||||
setEnv('development', '1');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(true);
|
||||
expect(createDefaultSnapshotStore(factories)).toBe(s3Store);
|
||||
expect(createS3).toHaveBeenCalledTimes(1);
|
||||
expect(createFile).not.toHaveBeenCalled();
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
|
||||
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an explicit ENABLE_AGENT_S3_TRACING value disable the production default', () => {
|
||||
setEnv('production', '0');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(false);
|
||||
expect(createDefaultSnapshotStore(factories)).toBeNull();
|
||||
expect(createS3).not.toHaveBeenCalled();
|
||||
expect(createFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('degrades to null (never throws) when S3 store construction fails', () => {
|
||||
setEnv('production');
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const boom = vi.fn(() => {
|
||||
throw new Error('missing S3 creds');
|
||||
});
|
||||
|
||||
expect(createDefaultSnapshotStore({ createS3: boom })).toBeNull();
|
||||
expect(boom).toHaveBeenCalledTimes(1);
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('constructs a real store via the default (non-injected) path', () => {
|
||||
// Guards the regression: the default path must build a store with NO dynamic
|
||||
// require. In dev that is the statically-imported FileSnapshotStore
|
||||
// (S3 needs creds, so dev is the safe env to assert a non-null default).
|
||||
setEnv('development');
|
||||
|
||||
expect(createDefaultSnapshotStore()).not.toBeNull();
|
||||
expect(createDefaultSnapshotStore(loadModule)).toBeNull();
|
||||
expect(loadModule).not.toHaveBeenCalled();
|
||||
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { FileSnapshotStore, type ISnapshotStore } from '@lobechat/agent-tracing';
|
||||
|
||||
import { S3SnapshotStore } from '@/server/modules/AgentTracing';
|
||||
import type { ISnapshotStore } from '@lobechat/agent-tracing';
|
||||
|
||||
const ENABLE_AGENT_S3_TRACING_VALUE = '1';
|
||||
|
||||
type SnapshotStoreConstructor = new () => ISnapshotStore;
|
||||
type SnapshotStoreModuleLoader = (moduleName: string) => unknown;
|
||||
|
||||
interface FileSnapshotStoreModule {
|
||||
FileSnapshotStore: SnapshotStoreConstructor;
|
||||
}
|
||||
|
||||
interface S3SnapshotStoreModule {
|
||||
S3SnapshotStore: SnapshotStoreConstructor;
|
||||
}
|
||||
|
||||
const nodeRequire: SnapshotStoreModuleLoader = (moduleName) => require(moduleName);
|
||||
|
||||
export const shouldUseAgentS3Tracing = () => {
|
||||
const explicitValue = process.env.ENABLE_AGENT_S3_TRACING;
|
||||
|
||||
@@ -12,18 +23,6 @@ export const shouldUseAgentS3Tracing = () => {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor injection for tests. The defaults are the statically-imported
|
||||
* stores — never load them via a dynamic `require(moduleName)`: the module name
|
||||
* goes through an indirection the bundler can't statically analyze, so the `@/`
|
||||
* build-time alias fails to resolve at runtime and the store silently becomes
|
||||
* `null` (this once disabled ALL production snapshots).
|
||||
*/
|
||||
export interface SnapshotStoreFactories {
|
||||
createFile?: () => ISnapshotStore;
|
||||
createS3?: () => ISnapshotStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default snapshot store based on environment.
|
||||
* - ENABLE_AGENT_S3_TRACING=1 -> S3SnapshotStore
|
||||
@@ -32,22 +31,28 @@ export interface SnapshotStoreFactories {
|
||||
* - Otherwise -> null (no tracing)
|
||||
*/
|
||||
export const createDefaultSnapshotStore = (
|
||||
factories: SnapshotStoreFactories = {},
|
||||
loadModule: SnapshotStoreModuleLoader = nodeRequire,
|
||||
): ISnapshotStore | null => {
|
||||
if (shouldUseAgentS3Tracing()) {
|
||||
try {
|
||||
return (factories.createS3 ?? (() => new S3SnapshotStore()))();
|
||||
} catch (e) {
|
||||
// Tracing is best-effort — a misconfigured S3 (e.g. missing creds) must
|
||||
// never break the agent run. But surface it loudly: a swallowed failure
|
||||
// here previously disabled all production snapshots without a trace.
|
||||
console.error('[snapshotStore] failed to create S3SnapshotStore, tracing disabled:', e);
|
||||
return null;
|
||||
const { S3SnapshotStore } = loadModule(
|
||||
'@/server/modules/AgentTracing',
|
||||
) as S3SnapshotStoreModule;
|
||||
return new S3SnapshotStore();
|
||||
} catch {
|
||||
// S3SnapshotStore not available
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return (factories.createFile ?? (() => new FileSnapshotStore()))();
|
||||
try {
|
||||
const { FileSnapshotStore } = loadModule(
|
||||
'@lobechat/agent-tracing',
|
||||
) as FileSnapshotStoreModule;
|
||||
return new FileSnapshotStore();
|
||||
} catch {
|
||||
// agent-tracing not available
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -27,7 +27,6 @@ import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import { isRemoteHeterogeneousType } from '@lobechat/heterogeneous-agents';
|
||||
import { buildTaskManagerDefaultsPrompt } from '@lobechat/prompts';
|
||||
import type {
|
||||
ChatAudioItem,
|
||||
ChatFileItem,
|
||||
ChatTopicBotContext,
|
||||
ChatVideoItem,
|
||||
@@ -477,7 +476,6 @@ export class AiAgentService {
|
||||
files?: InternalExecAgentParams['files'];
|
||||
throwIfAborted: (stage: string) => Promise<void>;
|
||||
}): Promise<{
|
||||
audioList?: ChatAudioItem[];
|
||||
fileIds?: string[];
|
||||
fileList?: ChatFileItem[];
|
||||
imageList?: Array<{ alt: string; id: string; url: string }>;
|
||||
@@ -488,15 +486,13 @@ export class AiAgentService {
|
||||
let fileIds: string[] | undefined;
|
||||
let imageList: Array<{ alt: string; id: string; url: string }> | undefined;
|
||||
let videoList: ChatVideoItem[] | undefined;
|
||||
let audioList: ChatAudioItem[] | undefined;
|
||||
let fileList: ChatFileItem[] | undefined;
|
||||
|
||||
// Upload raw bot/IM files to S3 and classify them (image / video / audio / document).
|
||||
// Upload raw bot/IM files to S3 and classify them (image / video / document).
|
||||
if (files && files.length > 0) {
|
||||
fileIds = [];
|
||||
imageList = [];
|
||||
videoList = [];
|
||||
audioList = [];
|
||||
fileList = [];
|
||||
const fileService = new FileService(this.db, this.userId, this.workspaceId);
|
||||
const documentService = new DocumentService(this.db, this.userId, this.workspaceId);
|
||||
@@ -526,16 +522,7 @@ export class AiAgentService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.isAudio) {
|
||||
audioList.push({
|
||||
alt: file.name || 'audio',
|
||||
id: result.fileId,
|
||||
url: result.resolvedUrl,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-image / non-video / non-audio: parse file content into the documents table so
|
||||
// Non-image / non-video: parse file content into the documents table so
|
||||
// the MessageContentProcessor can inject it via filesPrompts(). Mirrors
|
||||
// what the web upload path does, ensuring bot-uploaded PDFs / text /
|
||||
// JSON / .skill files are actually visible to the LLM (instead of
|
||||
@@ -572,17 +559,15 @@ export class AiAgentService {
|
||||
|
||||
if (fileIds.length > 0) {
|
||||
log(
|
||||
'execAgent: uploaded %d files to S3 (%d images, %d videos, %d audios, %d documents)',
|
||||
'execAgent: uploaded %d files to S3 (%d images, %d videos, %d documents)',
|
||||
fileIds.length,
|
||||
imageList.length,
|
||||
videoList.length,
|
||||
audioList.length,
|
||||
fileList.length,
|
||||
);
|
||||
}
|
||||
if (imageList.length === 0) imageList = undefined;
|
||||
if (videoList.length === 0) videoList = undefined;
|
||||
if (audioList.length === 0) audioList = undefined;
|
||||
if (fileList.length === 0) fileList = undefined;
|
||||
}
|
||||
|
||||
@@ -612,9 +597,6 @@ export class AiAgentService {
|
||||
if (resolved.videoList.length > 0) {
|
||||
videoList = [...(videoList ?? []), ...resolved.videoList];
|
||||
}
|
||||
if (resolved.audioList.length > 0) {
|
||||
audioList = [...(audioList ?? []), ...resolved.audioList];
|
||||
}
|
||||
if (resolved.fileList.length > 0) {
|
||||
fileList = [...(fileList ?? []), ...resolved.fileList];
|
||||
}
|
||||
@@ -632,7 +614,7 @@ export class AiAgentService {
|
||||
// an empty messagesFiles relation.
|
||||
if (fileIds && fileIds.length === 0) fileIds = undefined;
|
||||
|
||||
return { audioList, fileIds, fileList, imageList, videoList, warnings };
|
||||
return { fileIds, fileList, imageList, videoList, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1916,17 +1898,9 @@ export class AiAgentService {
|
||||
// `isDesktop` uses `gatewayConfigured` as a proxy: a device-gateway
|
||||
// deployment serves desktop-class users, so the unset-target default
|
||||
// resolves to `local` there and `none` otherwise.
|
||||
//
|
||||
// Chat mode is orthogonal to `executionTarget` (the UI toggle only writes
|
||||
// `enableAgentMode`), so a default/stored `local` target would otherwise
|
||||
// resolve a device and `buildStepToolDelta` would re-inject local-system.
|
||||
// Pass `chatConfig` so the plan degrades to `none` in chat mode — the
|
||||
// chat-mode derivation lives in `resolveExecutionPlan` (`resolveToolMode`),
|
||||
// the same source of truth the tools engine uses.
|
||||
executionPlan = resolveExecutionPlan({
|
||||
agencyConfig: agentConfig.agencyConfig,
|
||||
canUseDevice,
|
||||
chatConfig: agentConfig.chatConfig ?? undefined,
|
||||
isDesktop: gatewayConfigured,
|
||||
onlineDeviceIds: onlineDevices.map((device) => device.deviceId),
|
||||
requestedDeviceId,
|
||||
@@ -2442,10 +2416,8 @@ export class AiAgentService {
|
||||
// row created above).
|
||||
// - imageList: vision models render these as image_url parts
|
||||
// - videoList: video-capable models render these as video parts
|
||||
// - audioList: audio-capable models render these as audio parts
|
||||
// - fileList: MessageContentProcessor injects content via filesPrompts() XML
|
||||
const userMessage = {
|
||||
audioList: runAttachments.audioList,
|
||||
content: ephemeralUserMessage ?? prompt,
|
||||
fileList: runAttachments.fileList,
|
||||
id: userMessageRecord?.id,
|
||||
|
||||
@@ -33,7 +33,6 @@ export interface AttachmentSource {
|
||||
|
||||
export interface IngestResult {
|
||||
fileId: string;
|
||||
isAudio: boolean;
|
||||
isImage: boolean;
|
||||
isVideo: boolean;
|
||||
key: string;
|
||||
@@ -150,17 +149,12 @@ export async function ingestAttachment(
|
||||
// MessageContentProcessor can pass the video to vision/video-capable models.
|
||||
const isVideo = !isImage && mimeType.startsWith('video/');
|
||||
|
||||
// Audio is passed through untouched; audio-capable models (e.g. Gemini) receive
|
||||
// it as an inline/file media part instead of being parsed into document text.
|
||||
const isAudio = !isImage && !isVideo && mimeType.startsWith('audio/');
|
||||
|
||||
log(
|
||||
'ingestAttachment: classified name=%s, finalMimeType=%s, isImage=%s, isVideo=%s, isAudio=%s, bufferSize=%d',
|
||||
'ingestAttachment: classified name=%s, finalMimeType=%s, isImage=%s, isVideo=%s, bufferSize=%d',
|
||||
source.name,
|
||||
mimeType,
|
||||
isImage,
|
||||
isVideo,
|
||||
isAudio,
|
||||
buffer.length,
|
||||
);
|
||||
|
||||
@@ -170,11 +164,9 @@ export async function ingestAttachment(
|
||||
const pathname = `files/${userId}/${nanoid()}/${source.name || `file.${ext}`}`;
|
||||
const { fileId, key } = await fileService.uploadFromBuffer(buffer, mimeType, pathname);
|
||||
|
||||
// 5. Resolve access URL for images, videos and audio.
|
||||
// 5. Resolve access URL for images and videos.
|
||||
const resolvedUrl =
|
||||
isImage || isVideo || isAudio
|
||||
? await fileService.getFileAccessUrl({ id: fileId, url: key })
|
||||
: '';
|
||||
isImage || isVideo ? await fileService.getFileAccessUrl({ id: fileId, url: key }) : '';
|
||||
|
||||
log(
|
||||
'ingestAttachment: uploaded fileId=%s, key=%s, resolvedUrl=%s',
|
||||
@@ -183,5 +175,5 @@ export async function ingestAttachment(
|
||||
resolvedUrl ? 'set' : '(empty)',
|
||||
);
|
||||
|
||||
return { fileId, isAudio, isImage, isVideo, key, resolvedUrl };
|
||||
return { fileId, isImage, isVideo, key, resolvedUrl };
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
*/
|
||||
export const isPathWithinRoot = (root: string, target: string): boolean => {
|
||||
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) {
|
||||
// Diagnostic: distinguishes "no-op because workspaceId is
|
||||
// missing at runtime" from "lock actually evaluated".
|
||||
// TEMP DIAGNOSTIC (LOBE-10470): distinguishes "no-op because workspaceId is
|
||||
// missing at runtime" from "lock actually evaluated". Remove once verified.
|
||||
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);
|
||||
// Diagnostic: surfaces workspaceId/holder/acquire for debugging lock issues.
|
||||
// TEMP DIAGNOSTIC (LOBE-10470): one reproduction reveals workspaceId/holder/acquire.
|
||||
log(
|
||||
'runWithDocumentLock: id=%s userId=%s ws=%s holderBefore=%s acquired=%o',
|
||||
id,
|
||||
|
||||
@@ -171,9 +171,6 @@ 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,13 +203,9 @@ 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. 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.
|
||||
// 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).
|
||||
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 { ChatAudioItem, ChatFileItem, ChatImageItem, ChatVideoItem } from '@lobechat/types';
|
||||
import type { ChatFileItem, ChatImageItem, ChatVideoItem } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { FileModel } from '@/database/models/file';
|
||||
@@ -9,7 +9,6 @@ import { FileService } from '@/server/services/file';
|
||||
const log = debug('lobe-server:resolveAttachments');
|
||||
|
||||
export interface ResolvedAttachments {
|
||||
audioList: ChatAudioItem[];
|
||||
fileList: ChatFileItem[];
|
||||
imageList: ChatImageItem[];
|
||||
/**
|
||||
@@ -46,7 +45,6 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
workspaceId,
|
||||
}: ResolveArgs): Promise<ResolvedAttachments> => {
|
||||
const result: ResolvedAttachments = {
|
||||
audioList: [],
|
||||
fileList: [],
|
||||
imageList: [],
|
||||
orderedFileIds: [],
|
||||
@@ -78,11 +76,7 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
}
|
||||
const resolvedUrl = (await fileService.getFullFileUrl(file.url)) || file.url;
|
||||
const fileType = file.fileType || '';
|
||||
if (
|
||||
fileType.startsWith('image') ||
|
||||
fileType.startsWith('video') ||
|
||||
fileType.startsWith('audio')
|
||||
) {
|
||||
if (fileType.startsWith('image') || fileType.startsWith('video')) {
|
||||
return { file, fileType, id, resolvedUrl };
|
||||
}
|
||||
let content: string | undefined;
|
||||
@@ -112,10 +106,6 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
result.videoList.push({ alt: file.name || 'video', id: file.id, url: resolvedUrl });
|
||||
continue;
|
||||
}
|
||||
if (fileType.startsWith('audio')) {
|
||||
result.audioList.push({ alt: file.name || 'audio', id: file.id, url: resolvedUrl });
|
||||
continue;
|
||||
}
|
||||
if (entry.parseError) {
|
||||
log('parseFile failed for %s (id=%s): %O', file.name, file.id, entry.parseError);
|
||||
result.warnings.push(
|
||||
@@ -133,11 +123,10 @@ export const resolveAttachmentsByFileIds = async ({
|
||||
}
|
||||
|
||||
log(
|
||||
'resolved %d attachment(s) (%d images, %d videos, %d audios, %d documents)',
|
||||
'resolved %d attachment(s) (%d images, %d videos, %d documents)',
|
||||
fileRecords.length,
|
||||
result.imageList.length,
|
||||
result.videoList.length,
|
||||
result.audioList.length,
|
||||
result.fileList.length,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createIoRedisState } from '@chat-adapter/state-ioredis';
|
||||
import { INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import {
|
||||
Chat,
|
||||
ConsoleLogger,
|
||||
@@ -7,14 +8,16 @@ import {
|
||||
type SlashCommandEvent,
|
||||
} from 'chat';
|
||||
import debug from 'debug';
|
||||
import { and, desc, eq, ne, or } from 'drizzle-orm';
|
||||
|
||||
import type { MessengerPlatform } from '@/config/messenger';
|
||||
import { getServerDB } from '@/database/core/db-adaptor';
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import { MessengerAccountLinkModel } from '@/database/models/messengerAccountLink';
|
||||
import { WorkspaceModel } from '@/database/models/workspace';
|
||||
import type { MessengerAccountLinkItem } from '@/database/schemas';
|
||||
import { agents } from '@/database/schemas';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { buildWorkspaceWhere } from '@/database/utils/workspace';
|
||||
import { getServerFeatureFlagsStateFromRuntimeConfig } from '@/server/featureFlags';
|
||||
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
|
||||
import { AiAgentService } from '@/server/services/aiAgent';
|
||||
@@ -1508,17 +1511,33 @@ export class MessengerRouter {
|
||||
userId: string,
|
||||
workspaceId?: string | null,
|
||||
): Promise<AgentSummary[]> {
|
||||
// The filter, ordering, pinning, and title fallback all live in the model.
|
||||
// This text-only channel has no client-side i18n default, so it asks the
|
||||
// model to fill blank titles with a generic "Custom Agent" label.
|
||||
const rows = await new AgentModel(
|
||||
serverDB,
|
||||
userId,
|
||||
workspaceId ?? undefined,
|
||||
).listMessengerBindableAgents({ fallbackTitle: 'Custom Agent' });
|
||||
const rows = await serverDB
|
||||
.select({ id: agents.id, slug: agents.slug, title: agents.title })
|
||||
.from(agents)
|
||||
.where(
|
||||
and(
|
||||
buildWorkspaceWhere({ userId, workspaceId: workspaceId ?? undefined }, agents),
|
||||
or(ne(agents.virtual, true), eq(agents.slug, INBOX_SESSION_ID)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agents.updatedAt));
|
||||
|
||||
// `fallbackTitle` guarantees a non-null title for every row.
|
||||
return rows.map((row) => ({ id: row.id, title: row.title! }));
|
||||
const mapped = rows
|
||||
.filter((row) => row.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title:
|
||||
(row.title && row.title.trim()) ||
|
||||
(row.slug === INBOX_SESSION_ID ? 'LobeAI' : 'Custom Agent'),
|
||||
}));
|
||||
|
||||
const inboxIdx = mapped.findIndex((row) => row.slug === INBOX_SESSION_ID);
|
||||
if (inboxIdx > 0) {
|
||||
const [inbox] = mapped.splice(inboxIdx, 1);
|
||||
mapped.unshift(inbox);
|
||||
}
|
||||
return mapped.map(({ slug: _slug, ...rest }) => rest);
|
||||
}
|
||||
|
||||
private async dispatchToAgent(
|
||||
|
||||
@@ -1,162 +1,303 @@
|
||||
// @vitest-environment node
|
||||
import { TASK_TEMPLATE_RECOMMEND_MAX_COUNT } from '@lobechat/const';
|
||||
import type { TaskTemplate } from '@lobehub/market-sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TaskTemplate } from '@lobechat/const';
|
||||
import {
|
||||
TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES,
|
||||
TASK_TEMPLATE_RECOMMEND_COUNT,
|
||||
taskTemplates,
|
||||
} from '@lobechat/const';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TaskTemplateService } from './index';
|
||||
import { isTemplateSkillSourceEligible, TaskTemplateService } from './index';
|
||||
|
||||
const { mockGetTaskTemplateRecommendations, mockMarket } = vi.hoisted(() => {
|
||||
const market: {
|
||||
taskTemplates?: {
|
||||
getTaskTemplateRecommendations: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
} = {
|
||||
taskTemplates: {
|
||||
getTaskTemplateRecommendations: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
mockGetTaskTemplateRecommendations: vi.fn(),
|
||||
mockMarket: market,
|
||||
};
|
||||
const makeTemplate = (overrides: Partial<TaskTemplate>): TaskTemplate => ({
|
||||
category: 'engineering',
|
||||
cronPattern: '0 9 * * *',
|
||||
id: 't',
|
||||
interests: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
vi.mock('@/server/services/market', () => ({
|
||||
MarketService: vi.fn(() => ({ market: mockMarket })),
|
||||
}));
|
||||
|
||||
vi.mock('@/config/composio', () => ({
|
||||
composioEnv: { COMPOSIO_API_KEY: 'composio-key' },
|
||||
}));
|
||||
|
||||
vi.mock('@/envs/app', () => ({
|
||||
appEnv: {
|
||||
MARKET_TRUSTED_CLIENT_ID: 'client-id',
|
||||
MARKET_TRUSTED_CLIENT_SECRET: 'secret',
|
||||
},
|
||||
}));
|
||||
|
||||
const template = {
|
||||
category: 'engineering',
|
||||
connectors: [],
|
||||
createdAt: '2026-06-17T00:00:00.000Z',
|
||||
cronPattern: '0 9 * * *',
|
||||
description: 'Description',
|
||||
id: 101,
|
||||
identifier: 'daily-engineering',
|
||||
instruction: 'Instruction',
|
||||
interests: ['coding'],
|
||||
title: 'Title',
|
||||
updatedAt: '2026-06-17T00:00:00.000Z',
|
||||
version: '1.0.0',
|
||||
versionNumber: 1,
|
||||
} satisfies TaskTemplate;
|
||||
const UTC_DAY_1 = new Date('2026-04-24T10:00:00Z');
|
||||
const UTC_DAY_2 = new Date('2026-04-25T10:00:00Z');
|
||||
|
||||
describe('TaskTemplateService.listDailyRecommend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMarket.taskTemplates = {
|
||||
getTaskTemplateRecommendations: mockGetTaskTemplateRecommendations,
|
||||
};
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: [template] });
|
||||
it('returns the default recommendation count when user has matching interests', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
const codingMatches = taskTemplates.filter((t) => t.interests.includes('coding'));
|
||||
expect(picked.some((p) => codingMatches.some((m) => m.id === p.id))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns Market recommendation items', async () => {
|
||||
it('is stable for the same (userId, utcDate)', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([template]);
|
||||
});
|
||||
|
||||
it('returns an empty list when Market returns no recommendation items', async () => {
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: [] });
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes recommendation inputs to Market', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
|
||||
await service.listDailyRecommend(['coding'], {
|
||||
count: 10,
|
||||
enabledConnectors: [
|
||||
{ identifier: 'github', source: 'lobehub' },
|
||||
{ identifier: 'gmail', source: 'composio' },
|
||||
],
|
||||
excludeIds: [101],
|
||||
locale: 'zh-CN',
|
||||
refreshSeed: 'refresh-1',
|
||||
const a = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
const b = await service.listDailyRecommend(['coding'], {
|
||||
now: new Date('2026-04-24T23:59:00Z'), // still same UTC day
|
||||
});
|
||||
|
||||
expect(mockGetTaskTemplateRecommendations).toHaveBeenCalledWith({
|
||||
count: 10,
|
||||
enabledConnectors: [
|
||||
{ identifier: 'github', source: 'lobehub' },
|
||||
{ identifier: 'gmail', source: 'composio' },
|
||||
],
|
||||
excludeIds: [101],
|
||||
interestKeys: ['coding'],
|
||||
locale: 'zh-CN',
|
||||
refreshSeed: 'refresh-1',
|
||||
});
|
||||
expect(a.map((t) => t.id)).toEqual(b.map((t) => t.id));
|
||||
});
|
||||
|
||||
it('clamps oversized recommendation counts before calling Market', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
it('changes across UTC days', async () => {
|
||||
let matches = 0;
|
||||
for (const suffix of ['a', 'b', 'c', 'd', 'e']) {
|
||||
const service = new TaskTemplateService(`user-${suffix}`);
|
||||
const d1 = await service.listDailyRecommend([], { now: UTC_DAY_1 });
|
||||
const d2 = await service.listDailyRecommend([], { now: UTC_DAY_2 });
|
||||
if (JSON.stringify(d1) === JSON.stringify(d2)) matches += 1;
|
||||
}
|
||||
expect(matches).toBeLessThan(5);
|
||||
});
|
||||
|
||||
await service.listDailyRecommend(['coding'], { count: 25 });
|
||||
|
||||
expect(mockGetTaskTemplateRecommendations).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ count: TASK_TEMPLATE_RECOMMEND_MAX_COUNT }),
|
||||
it('differs across users on the same day', async () => {
|
||||
const results = await Promise.all(
|
||||
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((s) =>
|
||||
new TaskTemplateService(`user-${s}`)
|
||||
.listDailyRecommend([], { now: UTC_DAY_1 })
|
||||
.then((r) => r.map((t) => t.id).join(',')),
|
||||
),
|
||||
);
|
||||
expect(new Set(results).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('returns an empty list when Market recommendations fail', async () => {
|
||||
mockGetTaskTemplateRecommendations.mockRejectedValue(new Error('market down'));
|
||||
it('falls back to fallback categories when user has no interests', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend([], { now: UTC_DAY_1 });
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
for (const p of picked) {
|
||||
expect(taskTemplates.some((t) => t.id === p.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an empty list when Market returns malformed recommendations', async () => {
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({});
|
||||
it('intersection is case-insensitive and trims whitespace', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend([' CoDing '], { now: UTC_DAY_1 });
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
const codingMatches = taskTemplates.filter((t) => t.interests.includes('coding'));
|
||||
expect(picked.some((p) => codingMatches.some((m) => m.id === p.id))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns official connector fields from Market recommendation items', async () => {
|
||||
const templateWithConnectors = {
|
||||
...template,
|
||||
connectors: [
|
||||
{ identifier: 'github', required: true, source: 'lobehub' },
|
||||
{ identifier: 'gmail', required: false, source: 'composio' },
|
||||
],
|
||||
id: 102,
|
||||
} satisfies TaskTemplate;
|
||||
mockGetTaskTemplateRecommendations.mockResolvedValue({ items: [templateWithConnectors] });
|
||||
it('unrecognized interest strings fall back to non-matched pool', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
// Freeform custom input won't match any template's interests and should still return defaults.
|
||||
const picked = await service.listDailyRecommend(['my special hobby'], { now: UTC_DAY_1 });
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
|
||||
expect(result).toEqual([templateWithConnectors]);
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
});
|
||||
|
||||
it('returns an empty list when the SDK has no taskTemplates namespace', async () => {
|
||||
mockMarket.taskTemplates = undefined;
|
||||
it('excludes templates listed in excludeIds', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
expect(baseline.length).toBeGreaterThan(0);
|
||||
|
||||
const result = await service.listDailyRecommend(['coding']);
|
||||
const excludedId = baseline[0].id;
|
||||
const picked = await service.listDailyRecommend(['coding'], {
|
||||
excludeIds: [excludedId],
|
||||
now: UTC_DAY_1,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(picked.some((t) => t.id === excludedId)).toBe(false);
|
||||
expect(picked).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
});
|
||||
|
||||
it('drops templates whose required skill sources are not all enabled', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
// Without `enabledSkillSources`, any template with `requiresSkills` is filtered out.
|
||||
// Since current catalog has none, this should match the baseline (no-op).
|
||||
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
expect(baseline).toHaveLength(TASK_TEMPLATE_RECOMMEND_COUNT);
|
||||
});
|
||||
|
||||
it('returns only non-excluded templates when most are excluded', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const allIds = taskTemplates.map((t) => t.id);
|
||||
const keepIds = allIds.slice(0, 2);
|
||||
const excludeIds = allIds.slice(2);
|
||||
|
||||
const picked = await service.listDailyRecommend(['coding'], {
|
||||
excludeIds,
|
||||
now: UTC_DAY_1,
|
||||
});
|
||||
|
||||
expect(picked.map((t) => t.id).sort()).toEqual([...keepIds].sort());
|
||||
});
|
||||
|
||||
it('matches baseline when refreshSeed is undefined', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
const withUndefined = await service.listDailyRecommend(['coding'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: undefined,
|
||||
});
|
||||
const withEmpty = await service.listDailyRecommend(['coding'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: '',
|
||||
});
|
||||
|
||||
expect(withUndefined.map((t) => t.id)).toEqual(baseline.map((t) => t.id));
|
||||
expect(withEmpty.map((t) => t.id)).toEqual(baseline.map((t) => t.id));
|
||||
});
|
||||
|
||||
it('is stable for the same (userId, utcDay, refreshSeed)', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const a = await service.listDailyRecommend(['coding'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: 'seed-x',
|
||||
});
|
||||
const b = await service.listDailyRecommend(['coding'], {
|
||||
now: new Date('2026-04-24T23:59:00Z'), // same UTC day
|
||||
refreshSeed: 'seed-x',
|
||||
});
|
||||
expect(a.map((t) => t.id)).toEqual(b.map((t) => t.id));
|
||||
});
|
||||
|
||||
it('differs when refreshSeed changes', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const seeds = ['s1', 's2', 's3', 's4', 's5'];
|
||||
const results = await Promise.all(
|
||||
seeds.map((s) =>
|
||||
service
|
||||
.listDailyRecommend([], { now: UTC_DAY_1, refreshSeed: s })
|
||||
.then((r) => r.map((t) => t.id).join(',')),
|
||||
),
|
||||
);
|
||||
expect(new Set(results).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('refreshSeed does not bypass excludeIds', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
const excludedId = baseline[0].id;
|
||||
|
||||
for (const seed of ['s1', 's2', 's3']) {
|
||||
const picked = await service.listDailyRecommend(['coding'], {
|
||||
excludeIds: [excludedId],
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: seed,
|
||||
});
|
||||
expect(picked.some((t) => t.id === excludedId)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops personal-only categories in workspace mode', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const personalCategories = new Set(TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES);
|
||||
|
||||
// Use a personal interest that would otherwise match personal-life templates.
|
||||
const picked = await service.listDailyRecommend(['personal'], {
|
||||
now: UTC_DAY_1,
|
||||
workspaceMode: true,
|
||||
});
|
||||
for (const p of picked) {
|
||||
expect(personalCategories.has(p.category), `template ${p.id} category`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('shuffles broadly across refreshSeeds in workspace mode with empty interests', async () => {
|
||||
// The original narrow workspace fallback (operations + learning-research)
|
||||
// resolved to ~4 templates after skill gating, locking "换一批" to a
|
||||
// permutation of the same 3-of-4. Workspace fallback must draw from the
|
||||
// full non-personal candidate set so refresh actually rotates.
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const seenIds = new Set<string>();
|
||||
for (const seed of ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8']) {
|
||||
const picked = await service.listDailyRecommend([], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: seed,
|
||||
workspaceMode: true,
|
||||
});
|
||||
for (const p of picked) seenIds.add(p.id);
|
||||
}
|
||||
// 8 refreshes × 3 picks = 24 slots. Across this many seeds the pool
|
||||
// should clearly exceed the old 4-template ceiling.
|
||||
expect(seenIds.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('keeps personal-only categories in personal mode (default)', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const personalCategories = new Set(TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES);
|
||||
|
||||
// Sample enough seeds so the personal fallback pool surfaces.
|
||||
const reached = new Set<string>();
|
||||
for (const seed of ['p1', 'p2', 'p3', 'p4', 'p5', 'p6']) {
|
||||
const picked = await service.listDailyRecommend(['personal'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: seed,
|
||||
});
|
||||
for (const p of picked) {
|
||||
if (personalCategories.has(p.category)) reached.add(p.id);
|
||||
}
|
||||
}
|
||||
expect(reached.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produces a different shuffle seed for workspace mode vs personal mode', async () => {
|
||||
// Seed namespaces are isolated so workspace recommendations don't mirror
|
||||
// the personal lineup for the same user/day.
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const personal = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
const workspace = await service.listDailyRecommend(['coding'], {
|
||||
now: UTC_DAY_1,
|
||||
workspaceMode: true,
|
||||
});
|
||||
expect(personal.map((t) => t.id).join(',')).not.toBe(workspace.map((t) => t.id).join(','));
|
||||
});
|
||||
|
||||
it('changes the first item across refreshSeeds when matched candidates are fewer than the default recommendation count', async () => {
|
||||
// Repro for: `health` interest matches only one template (`diet-log-companion`),
|
||||
// so the legacy "matched-first" logic locked it to position 0 regardless of seed.
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const firstItems = new Set<string>();
|
||||
for (const seed of ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8']) {
|
||||
const picked = await service.listDailyRecommend(['health'], {
|
||||
now: UTC_DAY_1,
|
||||
refreshSeed: seed,
|
||||
});
|
||||
firstItems.add(picked[0]?.id ?? '');
|
||||
}
|
||||
expect(firstItems.size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTemplateSkillSourceEligible', () => {
|
||||
it('treats templates without requiresSkills as always eligible', () => {
|
||||
expect(isTemplateSkillSourceEligible(makeTemplate({}))).toBe(true);
|
||||
expect(isTemplateSkillSourceEligible(makeTemplate({}), new Set())).toBe(true);
|
||||
});
|
||||
|
||||
it('filters out skill-dependent templates when enabledSkillSources is undefined', () => {
|
||||
const t = makeTemplate({ requiresSkills: [{ provider: 'github', source: 'lobehub' }] });
|
||||
expect(isTemplateSkillSourceEligible(t, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps templates whose only source is enabled', () => {
|
||||
const t = makeTemplate({ requiresSkills: [{ provider: 'notion', source: 'lobehub' }] });
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops templates whose source is not in enabledSkillSources', () => {
|
||||
const t = makeTemplate({ requiresSkills: [{ provider: 'notion', source: 'lobehub' }] });
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['composio']))).toBe(false);
|
||||
});
|
||||
|
||||
it('requires every source for multi-skill templates', () => {
|
||||
const t = makeTemplate({
|
||||
requiresSkills: [
|
||||
{ provider: 'notion', source: 'lobehub' },
|
||||
{ provider: 'google-calendar', source: 'composio' },
|
||||
],
|
||||
});
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(false);
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['composio']))).toBe(false);
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub', 'composio']))).toBe(true);
|
||||
});
|
||||
|
||||
it('treats empty requiresSkills array same as undefined (always eligible)', () => {
|
||||
const t = makeTemplate({ requiresSkills: [] });
|
||||
expect(isTemplateSkillSourceEligible(t, undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,78 +1,150 @@
|
||||
import type { TaskTemplate, TaskTemplateSkillSource } from '@lobechat/const';
|
||||
import {
|
||||
COMPOSIO_APP_TYPES,
|
||||
LOBEHUB_SKILL_PROVIDERS,
|
||||
TASK_TEMPLATE_FALLBACK_CATEGORIES,
|
||||
TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES,
|
||||
TASK_TEMPLATE_RECOMMEND_COUNT,
|
||||
TASK_TEMPLATE_RECOMMEND_MAX_COUNT,
|
||||
taskTemplates,
|
||||
} from '@lobechat/const';
|
||||
import type { TaskTemplate, TaskTemplateConnectorReference } from '@lobehub/market-sdk';
|
||||
|
||||
import { composioEnv } from '@/config/composio';
|
||||
import { appEnv } from '@/envs/app';
|
||||
import { MarketService } from '@/server/services/market';
|
||||
|
||||
export const ENABLED_TASK_TEMPLATE_CONNECTORS: TaskTemplateConnectorReference[] = (() => {
|
||||
const connectors: TaskTemplateConnectorReference[] = [];
|
||||
|
||||
if (composioEnv.COMPOSIO_API_KEY) {
|
||||
connectors.push(
|
||||
...COMPOSIO_APP_TYPES.map((app) => ({
|
||||
identifier: app.identifier,
|
||||
source: 'composio' as const,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export const ENABLED_SKILL_SOURCES: ReadonlySet<TaskTemplateSkillSource> = (() => {
|
||||
const sources = new Set<TaskTemplateSkillSource>();
|
||||
if (composioEnv.COMPOSIO_API_KEY) sources.add('composio');
|
||||
if (appEnv.MARKET_TRUSTED_CLIENT_ID && appEnv.MARKET_TRUSTED_CLIENT_SECRET) {
|
||||
connectors.push(
|
||||
...LOBEHUB_SKILL_PROVIDERS.map((provider) => ({
|
||||
identifier: provider.id,
|
||||
source: 'lobehub' as const,
|
||||
})),
|
||||
);
|
||||
sources.add('lobehub');
|
||||
}
|
||||
|
||||
return connectors;
|
||||
return sources;
|
||||
})();
|
||||
|
||||
const clampRecommendationCount = (count?: number) =>
|
||||
Math.min(Math.max(1, count ?? TASK_TEMPLATE_RECOMMEND_COUNT), TASK_TEMPLATE_RECOMMEND_MAX_COUNT);
|
||||
const hashString = (str: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
/** mulberry32 — pure function of seed, used so recommendations are stable per user/day. */
|
||||
const mulberry32 = (seed: number) => {
|
||||
let t = seed >>> 0;
|
||||
return () => {
|
||||
t = (t + 0x6d_2b_79_f5) | 0;
|
||||
let r = Math.imul(t ^ (t >>> 15), 1 | t);
|
||||
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
|
||||
return ((r ^ (r >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
};
|
||||
|
||||
const seededShuffle = <T>(items: T[], seed: number): T[] => {
|
||||
const arr = [...items];
|
||||
const rand = mulberry32(seed);
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
const normalize = (s: string) => s.trim().toLowerCase();
|
||||
|
||||
const hasIntersection = (template: TaskTemplate, userInterests: string[]): boolean => {
|
||||
if (userInterests.length === 0) return false;
|
||||
const normalized = new Set(userInterests.map(normalize));
|
||||
return template.interests.some((i) => normalized.has(normalize(i)));
|
||||
};
|
||||
|
||||
const getUtcDateStr = (now: Date): string => now.toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* A template is eligible only if every `requiresSkills[].source` is enabled
|
||||
* server-side. When a template declares no skill requirement, it is always
|
||||
* eligible. When the caller passes no `enabledSkillSources` set, any template
|
||||
* with skill requirements is filtered out (conservative default).
|
||||
*/
|
||||
export const isTemplateSkillSourceEligible = (
|
||||
template: TaskTemplate,
|
||||
enabledSkillSources?: ReadonlySet<TaskTemplateSkillSource>,
|
||||
): boolean => {
|
||||
if (!template.requiresSkills || template.requiresSkills.length === 0) return true;
|
||||
if (!enabledSkillSources) return false;
|
||||
return template.requiresSkills.every((s) => enabledSkillSources.has(s.source));
|
||||
};
|
||||
|
||||
export class TaskTemplateService {
|
||||
private marketService: MarketService;
|
||||
|
||||
constructor(private userId: string) {
|
||||
this.marketService = new MarketService({ userInfo: { userId } });
|
||||
}
|
||||
constructor(private userId: string) {}
|
||||
|
||||
async listDailyRecommend(
|
||||
interestKeys: string[],
|
||||
options: {
|
||||
count?: number;
|
||||
enabledConnectors?: readonly TaskTemplateConnectorReference[];
|
||||
excludeIds?: number[];
|
||||
locale?: string;
|
||||
enabledSkillSources?: ReadonlySet<TaskTemplateSkillSource>;
|
||||
excludeIds?: string[];
|
||||
now?: Date;
|
||||
refreshSeed?: string;
|
||||
/**
|
||||
* When true, drop every template under `TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES`
|
||||
* and use the workspace-flavored fallback pool. Used by the cloud router
|
||||
* whenever the request is bound to a workspace context.
|
||||
*/
|
||||
workspaceMode?: boolean;
|
||||
} = {},
|
||||
): Promise<TaskTemplate[]> {
|
||||
try {
|
||||
const result = await this.marketService.market.taskTemplates.getTaskTemplateRecommendations({
|
||||
count: clampRecommendationCount(options.count),
|
||||
enabledConnectors: options.enabledConnectors ? [...options.enabledConnectors] : undefined,
|
||||
excludeIds: options.excludeIds,
|
||||
interestKeys,
|
||||
locale: options.locale,
|
||||
refreshSeed: options.refreshSeed,
|
||||
});
|
||||
const {
|
||||
count = TASK_TEMPLATE_RECOMMEND_COUNT,
|
||||
enabledSkillSources,
|
||||
excludeIds,
|
||||
now = new Date(),
|
||||
refreshSeed,
|
||||
workspaceMode = false,
|
||||
} = options;
|
||||
const limit = Math.max(1, count);
|
||||
const excluded = new Set(excludeIds ?? []);
|
||||
const seedBase = workspaceMode
|
||||
? `${this.userId}:ws:${getUtcDateStr(now)}`
|
||||
: `${this.userId}:${getUtcDateStr(now)}`;
|
||||
const seed = hashString(refreshSeed ? `${seedBase}:${refreshSeed}` : seedBase);
|
||||
|
||||
if (!Array.isArray(result.items)) {
|
||||
console.error('[taskTemplate:listDailyRecommend] Market recommendations returned no items');
|
||||
return [];
|
||||
}
|
||||
const personalOnly = new Set<string>(TASK_TEMPLATE_PERSONAL_ONLY_CATEGORIES);
|
||||
|
||||
return result.items;
|
||||
} catch (error) {
|
||||
console.error('[taskTemplate:listDailyRecommend] Market recommendations failed', error);
|
||||
return [];
|
||||
const candidates = taskTemplates.filter(
|
||||
(t) =>
|
||||
!excluded.has(t.id) &&
|
||||
isTemplateSkillSourceEligible(t, enabledSkillSources) &&
|
||||
(!workspaceMode || !personalOnly.has(t.category)),
|
||||
);
|
||||
const matched = candidates.filter((t) => hasIntersection(t, interestKeys));
|
||||
const result: TaskTemplate[] = [];
|
||||
|
||||
if (matched.length >= limit) {
|
||||
result.push(...seededShuffle(matched, seed).slice(0, limit));
|
||||
} else {
|
||||
// Not enough interest matches: fold the fallback pool in so refreshSeed
|
||||
// can reorder the whole batch — otherwise a single-match interest pins
|
||||
// that template to position 0 forever.
|
||||
//
|
||||
// Personal mode keeps the narrow `personal-life + learning-research`
|
||||
// fallback (it's the existing vibe). Workspace mode uses the full
|
||||
// non-personal candidate set — the original 2-category workspace
|
||||
// fallback resolved to ~4 templates after skill gating and made
|
||||
// "换一批" a no-op.
|
||||
const matchedIds = new Set(matched.map((t) => t.id));
|
||||
const fallback = workspaceMode
|
||||
? candidates.filter((t) => !matchedIds.has(t.id))
|
||||
: candidates.filter(
|
||||
(t) => TASK_TEMPLATE_FALLBACK_CATEGORIES.includes(t.category) && !matchedIds.has(t.id),
|
||||
);
|
||||
const pool = [...matched, ...fallback];
|
||||
result.push(...seededShuffle(pool, seed).slice(0, limit));
|
||||
}
|
||||
|
||||
if (result.length < limit) {
|
||||
const seen = new Set(result.map((t) => t.id));
|
||||
const remaining = candidates.filter((t) => !seen.has(t.id));
|
||||
result.push(...seededShuffle(remaining, seed).slice(0, limit - result.length));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -220,9 +220,7 @@ describe('agentDocumentsRuntime auto-pin to task', () => {
|
||||
{ agentId: 'agent-1' },
|
||||
);
|
||||
|
||||
expect(result.content).toBe(
|
||||
'Created document "Daily Brief" (internal id: agent-doc-assoc-id).',
|
||||
);
|
||||
expect(result.content).toBe('Created document "Daily Brief" (agent-doc-assoc-id).');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -285,9 +283,7 @@ describe('AgentDocumentsExecutionRuntime.createDocument', () => {
|
||||
expect(result.content).toContain(
|
||||
'https://app.example.com/agent/agent-1/docs/docs_document-row-id',
|
||||
);
|
||||
expect(result.content).toContain('clickable markdown link');
|
||||
expect(result.content).toContain('Internal id agent-doc-assoc-id');
|
||||
expect(result.content).toContain('never show it to the user');
|
||||
expect(result.content).toContain('Use id agent-doc-assoc-id for further edits');
|
||||
});
|
||||
|
||||
it('refuses to run without agentId', async () => {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
[
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-06-17",
|
||||
"version": "2.2.6"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-05-29",
|
||||
|
||||
+170
-342
@@ -1,384 +1,212 @@
|
||||
/**
|
||||
* Mock data for Discover/Community module.
|
||||
*
|
||||
* Community E2E tests should not depend on the live marketplace service. These
|
||||
* fixtures mirror the data shape returned by the app's tRPC market router.
|
||||
* Mock data for Discover/Community module
|
||||
*/
|
||||
import type {
|
||||
AssistantListResponse,
|
||||
DiscoverAssistantItem,
|
||||
DiscoverMcpItem,
|
||||
DiscoverModelItem,
|
||||
DiscoverProviderItem,
|
||||
McpListResponse,
|
||||
ModelListResponse,
|
||||
ProviderListResponse,
|
||||
} from './types';
|
||||
|
||||
const CREATED_AT = '2026-01-01T00:00:00.000Z';
|
||||
const UPDATED_AT = '2026-01-10T00:00:00.000Z';
|
||||
|
||||
// ============================================
|
||||
// Assistant Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockAssistantItems: DiscoverAssistantItem[] = [
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🤖',
|
||||
backgroundColor: '#1890ff',
|
||||
category: 'general',
|
||||
config: {
|
||||
openingMessage: 'Hello, I am your general assistant.',
|
||||
openingQuestions: ['What can you do?'],
|
||||
params: {},
|
||||
plugins: [],
|
||||
systemRole: 'You are a helpful general-purpose assistant for E2E tests.',
|
||||
},
|
||||
createdAt: CREATED_AT,
|
||||
description: 'A versatile AI assistant for general tasks and conversations.',
|
||||
identifier: 'general-assistant',
|
||||
installCount: 1000,
|
||||
knowledgeCount: 1,
|
||||
pluginCount: 0,
|
||||
summary: 'General-purpose assistant fixture.',
|
||||
tags: ['general', 'fixture'],
|
||||
title: 'General Assistant',
|
||||
tokenUsage: 4096,
|
||||
type: 'agent',
|
||||
updatedAt: UPDATED_AT,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '💻',
|
||||
backgroundColor: '#52c41a',
|
||||
category: 'programming',
|
||||
config: {
|
||||
openingMessage: 'Ready to help with development tasks.',
|
||||
openingQuestions: ['Review this function'],
|
||||
params: {},
|
||||
plugins: [],
|
||||
systemRole: 'You are an expert coding assistant for E2E tests.',
|
||||
},
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Developer and coding assistant for software engineering workflows.',
|
||||
identifier: 'code-assistant',
|
||||
installCount: 800,
|
||||
knowledgeCount: 2,
|
||||
pluginCount: 1,
|
||||
summary: 'Developer assistant fixture.',
|
||||
tags: ['developer', 'programming'],
|
||||
title: 'Code Assistant',
|
||||
tokenUsage: 8192,
|
||||
type: 'agent',
|
||||
updatedAt: UPDATED_AT,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🎓',
|
||||
backgroundColor: '#faad14',
|
||||
category: 'academic',
|
||||
config: {
|
||||
openingMessage: 'Let us study together.',
|
||||
openingQuestions: ['Explain this concept'],
|
||||
params: {},
|
||||
plugins: [],
|
||||
systemRole: 'You are an academic tutor for E2E tests.',
|
||||
},
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Academic research and study assistant for reliable category filtering.',
|
||||
identifier: 'academic-tutor',
|
||||
installCount: 640,
|
||||
knowledgeCount: 3,
|
||||
pluginCount: 0,
|
||||
summary: 'Academic assistant fixture.',
|
||||
tags: ['academic', 'education'],
|
||||
title: 'Academic Tutor',
|
||||
tokenUsage: 4096,
|
||||
type: 'agent',
|
||||
updatedAt: UPDATED_AT,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '✍️',
|
||||
backgroundColor: '#722ed1',
|
||||
category: 'copywriting',
|
||||
config: {
|
||||
openingMessage: 'Tell me what you want to write.',
|
||||
openingQuestions: ['Draft a product intro'],
|
||||
params: {},
|
||||
plugins: [],
|
||||
systemRole: 'You are a writing assistant for E2E tests.',
|
||||
},
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Professional writing assistant for content creation.',
|
||||
identifier: 'writing-assistant',
|
||||
installCount: 600,
|
||||
knowledgeCount: 1,
|
||||
pluginCount: 0,
|
||||
summary: 'Writing assistant fixture.',
|
||||
tags: ['copywriting'],
|
||||
title: 'Writing Assistant',
|
||||
tokenUsage: 4096,
|
||||
type: 'agent',
|
||||
updatedAt: UPDATED_AT,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockAssistantList: AssistantListResponse = {
|
||||
currentPage: 1,
|
||||
items: mockAssistantItems,
|
||||
pageSize: 21,
|
||||
totalCount: 42,
|
||||
totalPages: 2,
|
||||
items: [
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🤖',
|
||||
backgroundColor: '#1890ff',
|
||||
category: 'general',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
description: 'A versatile AI assistant for general tasks and conversations.',
|
||||
identifier: 'general-assistant',
|
||||
installCount: 1000,
|
||||
knowledgeCount: 5,
|
||||
pluginCount: 3,
|
||||
title: 'General Assistant',
|
||||
tokenUsage: 4096,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '💻',
|
||||
backgroundColor: '#52c41a',
|
||||
category: 'programming',
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
description: 'Expert coding assistant for software development.',
|
||||
identifier: 'code-assistant',
|
||||
installCount: 800,
|
||||
knowledgeCount: 10,
|
||||
pluginCount: 5,
|
||||
title: 'Code Assistant',
|
||||
tokenUsage: 8192,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '✍️',
|
||||
backgroundColor: '#722ed1',
|
||||
category: 'copywriting',
|
||||
createdAt: '2024-01-03T00:00:00.000Z',
|
||||
description: 'Professional writing assistant for content creation.',
|
||||
identifier: 'writing-assistant',
|
||||
installCount: 600,
|
||||
knowledgeCount: 3,
|
||||
pluginCount: 2,
|
||||
title: 'Writing Assistant',
|
||||
tokenUsage: 4096,
|
||||
userName: 'lobehub',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockAssistantCategories = [
|
||||
{ category: 'general', count: 12 },
|
||||
{ category: 'programming', count: 10 },
|
||||
{ category: 'academic', count: 8 },
|
||||
{ category: 'copywriting', count: 6 },
|
||||
{ id: 'general', name: 'General' },
|
||||
{ id: 'programming', name: 'Programming' },
|
||||
{ id: 'copywriting', name: 'Copywriting' },
|
||||
{ id: 'education', name: 'Education' },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// Model Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockModelItems: DiscoverModelItem[] = [
|
||||
{
|
||||
abilities: { functionCall: true, reasoning: true, vision: true },
|
||||
contextWindowTokens: 128_000,
|
||||
description: 'Most capable fixture model for complex tasks.',
|
||||
displayName: 'GPT-4o',
|
||||
id: 'gpt-4o',
|
||||
identifier: 'gpt-4o',
|
||||
providerCount: 2,
|
||||
providers: ['openai', 'lobehub'],
|
||||
releasedAt: CREATED_AT,
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: { functionCall: true, reasoning: true, vision: false },
|
||||
contextWindowTokens: 200_000,
|
||||
description: 'Advanced AI assistant fixture by Anthropic.',
|
||||
displayName: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
identifier: 'claude-3-5-sonnet-20241022',
|
||||
providerCount: 1,
|
||||
providers: ['anthropic'],
|
||||
releasedAt: CREATED_AT,
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: { functionCall: false, reasoning: false, vision: false },
|
||||
contextWindowTokens: 32_768,
|
||||
description: 'Open source language model fixture.',
|
||||
displayName: 'Llama 3.1 70B',
|
||||
id: 'llama-3.1-70b',
|
||||
identifier: 'llama-3.1-70b',
|
||||
providerCount: 1,
|
||||
providers: ['meta'],
|
||||
releasedAt: CREATED_AT,
|
||||
type: 'chat',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockModelList: ModelListResponse = {
|
||||
currentPage: 1,
|
||||
items: mockModelItems,
|
||||
pageSize: 21,
|
||||
totalCount: mockModelItems.length,
|
||||
totalPages: 1,
|
||||
items: [
|
||||
{
|
||||
abilities: { functionCall: true, reasoning: true, vision: true },
|
||||
contextWindowTokens: 128_000,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
description: 'Most capable model for complex tasks',
|
||||
displayName: 'GPT-4o',
|
||||
id: 'gpt-4o',
|
||||
providerId: 'openai',
|
||||
providerName: 'OpenAI',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: { functionCall: true, reasoning: true, vision: false },
|
||||
contextWindowTokens: 200_000,
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
description: 'Advanced AI assistant by Anthropic',
|
||||
displayName: 'Claude 3.5 Sonnet',
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
providerId: 'anthropic',
|
||||
providerName: 'Anthropic',
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: { functionCall: false, reasoning: false, vision: false },
|
||||
contextWindowTokens: 32_768,
|
||||
createdAt: '2024-01-03T00:00:00.000Z',
|
||||
description: 'Open source language model',
|
||||
displayName: 'Llama 3.1 70B',
|
||||
id: 'llama-3.1-70b',
|
||||
providerId: 'meta',
|
||||
providerName: 'Meta',
|
||||
type: 'chat',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Provider Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockProviderItems: DiscoverProviderItem[] = [
|
||||
{
|
||||
description: 'Leading AI research company fixture.',
|
||||
identifier: 'openai',
|
||||
modelCount: 2,
|
||||
models: ['gpt-4o', 'gpt-4o-mini'],
|
||||
name: 'OpenAI',
|
||||
url: 'https://openai.com',
|
||||
},
|
||||
{
|
||||
description: 'AI safety focused research company fixture.',
|
||||
identifier: 'anthropic',
|
||||
modelCount: 1,
|
||||
models: ['claude-3-5-sonnet-20241022'],
|
||||
name: 'Anthropic',
|
||||
url: 'https://anthropic.com',
|
||||
},
|
||||
{
|
||||
description: 'Open source AI leader fixture.',
|
||||
identifier: 'meta',
|
||||
modelCount: 1,
|
||||
models: ['llama-3.1-70b'],
|
||||
name: 'Meta',
|
||||
url: 'https://ai.meta.com',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockProviderList: ProviderListResponse = {
|
||||
currentPage: 1,
|
||||
items: mockProviderItems,
|
||||
pageSize: 21,
|
||||
totalCount: mockProviderItems.length,
|
||||
totalPages: 1,
|
||||
items: [
|
||||
{
|
||||
description: 'Leading AI research company',
|
||||
id: 'openai',
|
||||
logo: 'https://example.com/openai.png',
|
||||
modelCount: 10,
|
||||
name: 'OpenAI',
|
||||
},
|
||||
{
|
||||
description: 'AI safety focused research company',
|
||||
id: 'anthropic',
|
||||
logo: 'https://example.com/anthropic.png',
|
||||
modelCount: 5,
|
||||
name: 'Anthropic',
|
||||
},
|
||||
{
|
||||
description: 'Open source AI leader',
|
||||
id: 'meta',
|
||||
logo: 'https://example.com/meta.png',
|
||||
modelCount: 8,
|
||||
name: 'Meta',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MCP Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockMcpItems: DiscoverMcpItem[] = [
|
||||
{
|
||||
author: 'LobeHub',
|
||||
capabilities: { prompts: false, resources: false, tools: true },
|
||||
category: 'business',
|
||||
connectionType: 'stdio',
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Business automation MCP tool fixture.',
|
||||
github: { stars: 1200, url: 'https://github.com/lobehub/e2e-business-mcp' },
|
||||
icon: '📊',
|
||||
identifier: 'business-automation',
|
||||
installCount: 500,
|
||||
installationMethods: 'npm',
|
||||
isClaimed: true,
|
||||
isFeatured: true,
|
||||
isOfficial: true,
|
||||
isValidated: true,
|
||||
manifestUrl: 'https://example.com/business-automation/manifest.json',
|
||||
name: 'Business Automation',
|
||||
toolsCount: 3,
|
||||
updatedAt: UPDATED_AT,
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
capabilities: { prompts: false, resources: true, tools: true },
|
||||
category: 'developer',
|
||||
connectionType: 'stdio',
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Developer file-system MCP fixture.',
|
||||
github: { stars: 900, url: 'https://github.com/lobehub/e2e-file-mcp' },
|
||||
icon: '📁',
|
||||
identifier: 'file-manager',
|
||||
installCount: 300,
|
||||
installationMethods: 'npm',
|
||||
isClaimed: true,
|
||||
isFeatured: false,
|
||||
isOfficial: false,
|
||||
isValidated: true,
|
||||
manifestUrl: 'https://example.com/file-manager/manifest.json',
|
||||
name: 'File Manager',
|
||||
resourcesCount: 2,
|
||||
toolsCount: 5,
|
||||
updatedAt: UPDATED_AT,
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
capabilities: { prompts: true, resources: false, tools: true },
|
||||
category: 'productivity',
|
||||
connectionType: 'http',
|
||||
createdAt: CREATED_AT,
|
||||
description: 'Productivity search MCP fixture.',
|
||||
github: { stars: 600, url: 'https://github.com/lobehub/e2e-search-mcp' },
|
||||
icon: '🔍',
|
||||
identifier: 'web-search',
|
||||
installCount: 260,
|
||||
installationMethods: 'docker',
|
||||
isClaimed: false,
|
||||
isFeatured: false,
|
||||
isOfficial: false,
|
||||
isValidated: true,
|
||||
manifestUrl: 'https://example.com/web-search/manifest.json',
|
||||
name: 'Web Search',
|
||||
promptsCount: 1,
|
||||
toolsCount: 2,
|
||||
updatedAt: UPDATED_AT,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockMcpList: McpListResponse = {
|
||||
categories: ['business', 'developer', 'productivity'],
|
||||
currentPage: 1,
|
||||
items: mockMcpItems,
|
||||
pageSize: 21,
|
||||
totalCount: mockMcpItems.length,
|
||||
totalPages: 1,
|
||||
items: [
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🔍',
|
||||
category: 'search',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
description: 'Web search capabilities for AI assistants',
|
||||
identifier: 'web-search',
|
||||
installCount: 500,
|
||||
title: 'Web Search',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '📁',
|
||||
category: 'file',
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
description: 'File system operations and management',
|
||||
identifier: 'file-manager',
|
||||
installCount: 300,
|
||||
title: 'File Manager',
|
||||
},
|
||||
{
|
||||
author: 'LobeHub',
|
||||
avatar: '🗄️',
|
||||
category: 'database',
|
||||
createdAt: '2024-01-03T00:00:00.000Z',
|
||||
description: 'Database query and management tools',
|
||||
identifier: 'db-tools',
|
||||
installCount: 200,
|
||||
title: 'Database Tools',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
total: 3,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockMcpCategories = [
|
||||
{ category: 'business', count: 7 },
|
||||
{ category: 'developer', count: 5 },
|
||||
{ category: 'productivity', count: 3 },
|
||||
{ id: 'search', name: 'Search' },
|
||||
{ id: 'file', name: 'File' },
|
||||
{ id: 'database', name: 'Database' },
|
||||
{ id: 'utility', name: 'Utility' },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// Detail Mock Data
|
||||
// ============================================
|
||||
|
||||
export const mockAssistantDetails = mockAssistantItems.map((item) => ({
|
||||
...item,
|
||||
currentVersion: '1.0.0',
|
||||
related: mockAssistantItems
|
||||
.filter((related) => related.identifier !== item.identifier)
|
||||
.slice(0, 3),
|
||||
versions: [
|
||||
{
|
||||
createdAt: item.createdAt,
|
||||
isLatest: true,
|
||||
isValidated: true,
|
||||
status: 'published',
|
||||
version: '1.0.0',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
export const mockMcpDetails = mockMcpItems.map((item) => ({
|
||||
...item,
|
||||
author: { name: item.author ?? 'LobeHub', url: 'https://lobehub.com' },
|
||||
deploymentOptions: [
|
||||
{
|
||||
connection: { command: 'npx', type: item.connectionType ?? 'stdio' },
|
||||
installationMethod: item.installationMethods ?? 'npm',
|
||||
title: 'E2E recommended deployment',
|
||||
},
|
||||
],
|
||||
overview: {
|
||||
readme: `# ${item.name}\n\n${item.description}`,
|
||||
summary: item.description,
|
||||
},
|
||||
related: mockMcpItems.filter((related) => related.identifier !== item.identifier).slice(0, 2),
|
||||
tools: [{ description: 'Fixture tool for E2E tests', name: 'fixtureTool' }],
|
||||
version: '1.0.0',
|
||||
versions: [{ isLatest: true, version: '1.0.0' }],
|
||||
}));
|
||||
|
||||
export const mockModelDetails = mockModelItems.map((item) => ({
|
||||
...item,
|
||||
providers: mockProviderItems.map((provider) => ({
|
||||
...provider,
|
||||
id: provider.identifier,
|
||||
model: item,
|
||||
})),
|
||||
related: mockModelItems.filter((related) => related.identifier !== item.identifier).slice(0, 2),
|
||||
}));
|
||||
|
||||
export const mockProviderDetails = mockProviderItems.map((item) => ({
|
||||
...item,
|
||||
models: mockModelItems
|
||||
.filter((model) => item.models.includes(model.identifier))
|
||||
.map((model) => ({ ...model, maxOutput: 4096 })),
|
||||
readme: `# ${item.name}\n\n${item.description}`,
|
||||
related: mockProviderItems
|
||||
.filter((related) => related.identifier !== item.identifier)
|
||||
.slice(0, 2),
|
||||
}));
|
||||
|
||||
+152
-319
@@ -1,346 +1,179 @@
|
||||
/**
|
||||
* Mock handlers for Discover/Community API endpoints.
|
||||
* Mock handlers for Discover/Community API endpoints
|
||||
*/
|
||||
import type { Request, Route } from 'playwright';
|
||||
import superjson from 'superjson';
|
||||
import type { Route } from 'playwright';
|
||||
|
||||
import type { MockHandler } from '../index';
|
||||
import { type MockHandler, createTrpcResponse } from '../index';
|
||||
import {
|
||||
mockAssistantCategories,
|
||||
mockAssistantDetails,
|
||||
mockAssistantItems,
|
||||
mockAssistantList,
|
||||
mockMcpCategories,
|
||||
mockMcpDetails,
|
||||
mockMcpItems,
|
||||
mockMcpList,
|
||||
mockModelDetails,
|
||||
mockModelItems,
|
||||
mockModelList,
|
||||
mockProviderDetails,
|
||||
mockProviderItems,
|
||||
mockProviderList,
|
||||
} from './data';
|
||||
|
||||
interface IdentifierEntry {
|
||||
identifier: string;
|
||||
lastModified: string;
|
||||
// ============================================
|
||||
// Helper to parse tRPC batch requests
|
||||
// ============================================
|
||||
|
||||
function parseTrpcUrl(url: string): { input?: Record<string, unknown>; procedure: string } {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
|
||||
// Extract procedure name from path like /trpc/lambda.market.getAssistantList
|
||||
const procedureMatch = pathname.match(/lambda\.market\.(\w+)/);
|
||||
const procedure = procedureMatch ? procedureMatch[1] : '';
|
||||
|
||||
// Parse input from query string
|
||||
let input: Record<string, unknown> | undefined;
|
||||
const inputParam = urlObj.searchParams.get('input');
|
||||
if (inputParam) {
|
||||
try {
|
||||
input = JSON.parse(inputParam);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return { input, procedure };
|
||||
}
|
||||
|
||||
const SUCCESS_RESPONSE = { success: true };
|
||||
// ============================================
|
||||
// Mock Handlers
|
||||
// ============================================
|
||||
|
||||
const createTrpcResult = <T>(data: T) => ({
|
||||
result: {
|
||||
data: superjson.serialize(data),
|
||||
},
|
||||
});
|
||||
|
||||
const createTrpcResponse = <T>(data: T): string => JSON.stringify(createTrpcResult(data));
|
||||
|
||||
const createTrpcBatchResponse = <T>(data: T[]): string =>
|
||||
JSON.stringify(data.map((item) => createTrpcResult(item)));
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const getStringInput = (input: unknown, key: string): string | undefined => {
|
||||
if (!isRecord(input)) return undefined;
|
||||
const value = input[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
};
|
||||
|
||||
const getNumberInput = (input: unknown, key: string): number | undefined => {
|
||||
if (!isRecord(input)) return undefined;
|
||||
const value = input[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : undefined;
|
||||
};
|
||||
|
||||
const createIdentifiers = (
|
||||
items: { identifier: string; updatedAt?: string }[],
|
||||
): IdentifierEntry[] =>
|
||||
items.map((item) => ({ identifier: item.identifier, lastModified: item.updatedAt ?? '' }));
|
||||
|
||||
const unwrapTrpcInput = (input: unknown): unknown => {
|
||||
if (!isRecord(input)) return input;
|
||||
|
||||
if ('json' in input) return input.json;
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
const parseRequestInput = (request: Request, url: URL): unknown => {
|
||||
const input = url.searchParams.get('input');
|
||||
|
||||
if (input) {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return request.postDataJSON();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getProcedureInputs = (request: Request, url: URL, count: number): unknown[] => {
|
||||
const rawInput = parseRequestInput(request, url);
|
||||
const isBatch = url.searchParams.get('batch') === '1' || count > 1;
|
||||
|
||||
if (isBatch && isRecord(rawInput)) {
|
||||
return Array.from({ length: count }, (_, index) => unwrapTrpcInput(rawInput[String(index)]));
|
||||
}
|
||||
|
||||
return [unwrapTrpcInput(rawInput)];
|
||||
};
|
||||
|
||||
const getProcedures = (url: URL): string[] => {
|
||||
const marker = '/trpc/lambda/';
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
const markerIndex = pathname.indexOf(marker);
|
||||
|
||||
if (markerIndex === -1) return [];
|
||||
|
||||
const procedureSegment = pathname.slice(markerIndex + marker.length);
|
||||
return procedureSegment.split(',').filter(Boolean);
|
||||
};
|
||||
|
||||
const isMarketProcedure = (procedure: string): boolean => procedure.startsWith('market.');
|
||||
|
||||
const matchesText = (value: string | undefined, query: string) =>
|
||||
value?.toLowerCase().includes(query.toLowerCase()) ?? false;
|
||||
|
||||
const paginate = <T>(items: T[], input: unknown, fallbackTotal = items.length) => {
|
||||
const page = getNumberInput(input, 'page') ?? 1;
|
||||
const pageSize = getNumberInput(input, 'pageSize') ?? 21;
|
||||
|
||||
return {
|
||||
currentPage: page,
|
||||
items,
|
||||
pageSize,
|
||||
totalCount: Math.max(fallbackTotal, items.length),
|
||||
totalPages: Math.max(1, Math.ceil(Math.max(fallbackTotal, items.length) / pageSize)),
|
||||
};
|
||||
};
|
||||
|
||||
const getAssistantList = (input: unknown) => {
|
||||
const category = getStringInput(input, 'category');
|
||||
const query = getStringInput(input, 'q');
|
||||
|
||||
let items = mockAssistantItems;
|
||||
|
||||
if (category && !['all', 'discover'].includes(category)) {
|
||||
const filtered = items.filter((item) => item.category === category);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
matchesText(item.title, query) ||
|
||||
matchesText(item.description, query) ||
|
||||
matchesText(item.identifier, query) ||
|
||||
matchesText(item.tags?.join(' '), query),
|
||||
);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
return { ...mockAssistantList, ...paginate(items, input, 42) };
|
||||
};
|
||||
|
||||
const getMcpList = (input: unknown) => {
|
||||
const category = getStringInput(input, 'category');
|
||||
const query = getStringInput(input, 'q');
|
||||
|
||||
let items = mockMcpItems;
|
||||
|
||||
if (category && !['all', 'discover'].includes(category)) {
|
||||
const filtered = items.filter((item) => item.category === category);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
matchesText(item.name, query) ||
|
||||
matchesText(item.description, query) ||
|
||||
matchesText(item.identifier, query),
|
||||
);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
return { ...mockMcpList, ...paginate(items, input), categories: mockMcpList.categories };
|
||||
};
|
||||
|
||||
const getModelList = (input: unknown) => {
|
||||
const query = getStringInput(input, 'q');
|
||||
|
||||
let items = mockModelItems;
|
||||
if (query) {
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
matchesText(item.displayName, query) ||
|
||||
matchesText(item.description, query) ||
|
||||
matchesText(item.identifier, query),
|
||||
);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
return { ...mockModelList, ...paginate(items, input) };
|
||||
};
|
||||
|
||||
const getProviderList = (input: unknown) => {
|
||||
const query = getStringInput(input, 'q');
|
||||
|
||||
let items = mockProviderItems;
|
||||
if (query) {
|
||||
const filtered = items.filter(
|
||||
(item) =>
|
||||
matchesText(item.name, query) ||
|
||||
matchesText(item.description, query) ||
|
||||
matchesText(item.identifier, query),
|
||||
);
|
||||
if (filtered.length > 0) items = filtered;
|
||||
}
|
||||
|
||||
return { ...mockProviderList, ...paginate(items, input) };
|
||||
};
|
||||
|
||||
const findByIdentifier = <T extends { identifier: string }>(items: T[], input: unknown): T => {
|
||||
const identifier = getStringInput(input, 'identifier');
|
||||
return items.find((item) => item.identifier === identifier) ?? items[0];
|
||||
};
|
||||
|
||||
const getMockResponse = (procedure: string, input: unknown): unknown => {
|
||||
switch (procedure) {
|
||||
case 'market.getAssistantCategories': {
|
||||
return mockAssistantCategories;
|
||||
}
|
||||
|
||||
case 'market.getAssistantDetail': {
|
||||
return findByIdentifier(mockAssistantDetails, input);
|
||||
}
|
||||
|
||||
case 'market.getAssistantIdentifiers': {
|
||||
return createIdentifiers(mockAssistantItems);
|
||||
}
|
||||
|
||||
case 'market.getAssistantList': {
|
||||
return getAssistantList(input);
|
||||
}
|
||||
|
||||
case 'market.getMcpCategories': {
|
||||
return mockMcpCategories;
|
||||
}
|
||||
|
||||
case 'market.getMcpDetail': {
|
||||
return findByIdentifier(mockMcpDetails, input);
|
||||
}
|
||||
|
||||
case 'market.getMcpList': {
|
||||
return getMcpList(input);
|
||||
}
|
||||
|
||||
case 'market.getModelCategories': {
|
||||
return [];
|
||||
}
|
||||
|
||||
case 'market.getModelDetail': {
|
||||
return findByIdentifier(mockModelDetails, input);
|
||||
}
|
||||
|
||||
case 'market.getModelIdentifiers': {
|
||||
return createIdentifiers(mockModelItems);
|
||||
}
|
||||
|
||||
case 'market.getModelList': {
|
||||
return getModelList(input);
|
||||
}
|
||||
|
||||
case 'market.getProviderDetail': {
|
||||
return findByIdentifier(mockProviderDetails, input);
|
||||
}
|
||||
|
||||
case 'market.getProviderIdentifiers': {
|
||||
return createIdentifiers(mockProviderItems);
|
||||
}
|
||||
|
||||
case 'market.getProviderList': {
|
||||
return getProviderList(input);
|
||||
}
|
||||
|
||||
case 'market.registerClientInMarketplace': {
|
||||
return { clientId: 'e2e-market-client', clientSecret: 'e2e-market-secret' };
|
||||
}
|
||||
|
||||
case 'market.registerM2MToken': {
|
||||
return SUCCESS_RESPONSE;
|
||||
}
|
||||
|
||||
case 'market.reportAgentEvent':
|
||||
case 'market.reportAgentInstall':
|
||||
case 'market.reportCall':
|
||||
case 'market.reportGroupAgentEvent':
|
||||
case 'market.reportGroupAgentInstall':
|
||||
case 'market.reportMcpEvent':
|
||||
case 'market.reportMcpInstallResult': {
|
||||
return SUCCESS_RESPONSE;
|
||||
}
|
||||
|
||||
case 'plugin.getPlugins': {
|
||||
return [];
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log(` ⚠️ Unhandled mocked lambda endpoint: ${procedure}`);
|
||||
return SUCCESS_RESPONSE;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const marketHandler: MockHandler = {
|
||||
/**
|
||||
* Handler for assistant list endpoint
|
||||
*/
|
||||
const assistantListHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const procedures = getProcedures(url);
|
||||
|
||||
if (!procedures.some(isMarketProcedure)) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
const inputs = getProcedureInputs(request, url, procedures.length);
|
||||
|
||||
// Keep tRPC batch positions intact. Community pages can batch mocked
|
||||
// market.* calls with normal app calls, such as plugin.getPlugins on the MCP
|
||||
// detail page; returning only market responses would make the batch client
|
||||
// read the wrong result for subsequent procedures.
|
||||
const responses = procedures.map((procedure, index) =>
|
||||
getMockResponse(procedure, inputs[index]),
|
||||
);
|
||||
const isBatch = url.searchParams.get('batch') === '1' || procedures.length > 1;
|
||||
|
||||
await route.fulfill({
|
||||
body: isBatch ? createTrpcBatchResponse(responses) : createTrpcResponse(responses[0]),
|
||||
body: createTrpcResponse(mockAssistantList),
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Set-Cookie': 'mp_token_status=active; Path=/; SameSite=Lax',
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/**',
|
||||
pattern: '**/trpc/lambda/market.getAssistantList**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for assistant categories endpoint
|
||||
*/
|
||||
const assistantCategoriesHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockAssistantCategories),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getAssistantCategories**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for model list endpoint
|
||||
*/
|
||||
const modelListHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockModelList),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getModelList**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for provider list endpoint
|
||||
*/
|
||||
const providerListHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockProviderList),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getProviderList**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for MCP list endpoint
|
||||
*/
|
||||
const mcpListHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockMcpList),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getMcpList**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for MCP categories endpoint
|
||||
*/
|
||||
const mcpCategoriesHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse(mockMcpCategories),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.getMcpCategories**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Debug handler to log all trpc requests
|
||||
*/
|
||||
const trpcDebugHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
const url = route.request().url();
|
||||
console.log(` 🔍 TRPC Request: ${url}`);
|
||||
await route.continue();
|
||||
},
|
||||
pattern: '**/trpc/**',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fallback handler for any unhandled market endpoints
|
||||
* Returns empty data to prevent hanging requests
|
||||
*/
|
||||
const marketFallbackHandler: MockHandler = {
|
||||
handler: async (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const { procedure } = parseTrpcUrl(url);
|
||||
|
||||
console.log(` ⚠️ Unhandled market endpoint: ${procedure}`);
|
||||
|
||||
// Return empty response to prevent timeout
|
||||
await route.fulfill({
|
||||
body: createTrpcResponse({ items: [], pagination: { page: 1, pageSize: 12, total: 0 } }),
|
||||
contentType: 'application/json',
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
pattern: '**/trpc/lambda/market.**',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Export all handlers
|
||||
// ============================================
|
||||
|
||||
export const discoverHandlers: MockHandler[] = [marketHandler];
|
||||
export const discoverHandlers: MockHandler[] = [
|
||||
// Debug handler first to log all requests
|
||||
trpcDebugHandler,
|
||||
// Specific handlers (order matters - more specific first)
|
||||
assistantListHandler,
|
||||
assistantCategoriesHandler,
|
||||
modelListHandler,
|
||||
providerListHandler,
|
||||
mcpListHandler,
|
||||
mcpCategoriesHandler,
|
||||
// Fallback handler (should be last)
|
||||
marketFallbackHandler,
|
||||
];
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
/**
|
||||
* Type definitions for Discover mock data.
|
||||
*
|
||||
* Keep these small and E2E-focused: they only include fields the Community UI
|
||||
* reads while rendering list and detail pages.
|
||||
* Type definitions for Discover mock data
|
||||
* These mirror the actual types from the application
|
||||
*/
|
||||
|
||||
export interface ListResponse<T> {
|
||||
currentPage: number;
|
||||
items: T[];
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@@ -22,25 +19,21 @@ export interface DiscoverAssistantItem {
|
||||
avatar: string;
|
||||
backgroundColor?: string;
|
||||
category: string;
|
||||
config?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
examples?: Record<string, unknown>[];
|
||||
identifier: string;
|
||||
installCount?: number;
|
||||
knowledgeCount?: number;
|
||||
pluginCount?: number;
|
||||
related?: DiscoverAssistantItem[];
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
title: string;
|
||||
tokenUsage?: number;
|
||||
type?: 'agent' | 'agent-group';
|
||||
updatedAt?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export type AssistantListResponse = ListResponse<DiscoverAssistantItem>;
|
||||
export interface AssistantListResponse {
|
||||
items: DiscoverAssistantItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Model Types
|
||||
@@ -53,17 +46,19 @@ export interface DiscoverModelItem {
|
||||
vision?: boolean;
|
||||
};
|
||||
contextWindowTokens: number;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
displayName: string;
|
||||
id: string;
|
||||
identifier: string;
|
||||
providerCount: number;
|
||||
providers: string[];
|
||||
releasedAt?: string;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type ModelListResponse = ListResponse<DiscoverModelItem>;
|
||||
export interface ModelListResponse {
|
||||
items: DiscoverModelItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Provider Types
|
||||
@@ -71,50 +66,33 @@ export type ModelListResponse = ListResponse<DiscoverModelItem>;
|
||||
|
||||
export interface DiscoverProviderItem {
|
||||
description: string;
|
||||
identifier: string;
|
||||
id: string;
|
||||
logo?: string;
|
||||
modelCount: number;
|
||||
models: string[];
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type ProviderListResponse = ListResponse<DiscoverProviderItem>;
|
||||
export interface ProviderListResponse {
|
||||
items: DiscoverProviderItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MCP Types
|
||||
// ============================================
|
||||
|
||||
export interface DiscoverMcpItem {
|
||||
author?: string;
|
||||
capabilities: {
|
||||
prompts: boolean;
|
||||
resources: boolean;
|
||||
tools: boolean;
|
||||
};
|
||||
author: string;
|
||||
avatar: string;
|
||||
category: string;
|
||||
connectionType?: 'http' | 'stdio';
|
||||
createdAt: string;
|
||||
description: string;
|
||||
github?: {
|
||||
stars?: number;
|
||||
url: string;
|
||||
};
|
||||
icon?: string;
|
||||
identifier: string;
|
||||
installationMethods?: string;
|
||||
installCount?: number;
|
||||
isClaimed?: boolean;
|
||||
isFeatured?: boolean;
|
||||
isOfficial?: boolean;
|
||||
isValidated?: boolean;
|
||||
manifestUrl: string;
|
||||
name: string;
|
||||
promptsCount?: number;
|
||||
resourcesCount?: number;
|
||||
toolsCount?: number;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface McpListResponse extends ListResponse<DiscoverMcpItem> {
|
||||
categories: string[];
|
||||
export interface McpListResponse {
|
||||
items: DiscoverMcpItem[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
+5
-17
@@ -5,7 +5,6 @@
|
||||
* It uses Playwright's route interception to mock tRPC and REST API calls.
|
||||
*/
|
||||
import type { Page, Route } from 'playwright';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { discoverMocks } from './community';
|
||||
|
||||
@@ -125,23 +124,12 @@ export class MockManager {
|
||||
/**
|
||||
* Create a JSON response for tRPC endpoints
|
||||
*/
|
||||
export function createTrpcResult<T>(data: T) {
|
||||
return {
|
||||
result: {
|
||||
data: superjson.serialize(data),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTrpcResponse<T>(data: T): string {
|
||||
return JSON.stringify(createTrpcResult(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSON response for batched tRPC endpoints
|
||||
*/
|
||||
export function createTrpcBatchResponse<T>(data: T[]): string {
|
||||
return JSON.stringify(data.map((item) => createTrpcResult(item)));
|
||||
return JSON.stringify({
|
||||
result: {
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-11
@@ -1,7 +1,6 @@
|
||||
import { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber';
|
||||
import { chromium, type Cookie } from 'playwright';
|
||||
|
||||
import { mockManager } from '../mocks';
|
||||
import { seedTestUser, TEST_USER } from '../support/seedTestUser';
|
||||
import { startWebServer, stopWebServer } from '../support/webServer';
|
||||
import type { CustomWorld } from '../support/world';
|
||||
@@ -107,16 +106,8 @@ Before(async function (this: CustomWorld, { pickle }) {
|
||||
);
|
||||
console.log(`\n📝 Running: ${pickle.name}${testId ? ` (${testId.name.replace('@', '')})` : ''}`);
|
||||
|
||||
// Setup Community API mocks before any page navigation. These PR E2E scenarios
|
||||
// are the user-experience baseline for Community UI flows (list/search/filter/
|
||||
// detail navigation), not a live marketplace availability check. The live
|
||||
// marketplace rate-limits anonymous CI traffic, so Community scenarios use
|
||||
// deterministic fixtures while the rest of the E2E suite keeps real app APIs.
|
||||
// If we need to validate the real marketplace contract, cover that in a
|
||||
// separate integration/nightly suite with dedicated credentials and SLA.
|
||||
if (pickle.tags.some((tag) => tag.name === '@community')) {
|
||||
await mockManager.setup(this.page);
|
||||
}
|
||||
// Setup API mocks before any page navigation
|
||||
// await mockManager.setup(this.page);
|
||||
|
||||
// Set cached session cookies to skip login
|
||||
if (sessionCookies.length > 0) {
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي.\n\nهل ترغب في أن أتناسب مع سير عملك بشكل أفضل؟ انتقل إلى [إعدادات الوكيل]({{url}}) واملأ ملف تعريف الوكيل (يمكنك تعديله في أي وقت).",
|
||||
"agentDefaultMessageWithSystemRole": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي — أنت المتحكم.",
|
||||
"agentDefaultMessageWithoutEdit": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي — أنت المتحكم.",
|
||||
"agentDocument.backToChat": "العودة إلى الدردشة",
|
||||
"agentDocument.linkCopied": "تم نسخ الرابط",
|
||||
"agentDocument.openAsPage": "افتح كصفحة كاملة",
|
||||
"agentProfile.files_one": "{{count}} ملف",
|
||||
"agentProfile.files_other": "{{count}} ملفات",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} قاعدة معرفة",
|
||||
@@ -168,9 +165,7 @@
|
||||
"extendParams.urlContext.title": "استخراج محتوى رابط الويب",
|
||||
"followUpPlaceholder": "متابعة. @ لإسناد مهام لوكلاء آخرين.",
|
||||
"followUpPlaceholderHeterogeneous": "تابع.",
|
||||
"gatewayMode.beta": "تجريبي",
|
||||
"gatewayMode.cardTitle": "وضع بوابة الوكيل",
|
||||
"gatewayMode.desc": "قم بتشغيل الوكلاء في السحابة من خلال بوابة الوكلاء الخاصة بـ LobeHub. تستمر المهام في العمل حتى بعد إغلاق الصفحة.",
|
||||
"gatewayMode.title": "وضع البوابة",
|
||||
"group.desc": "ادفع المهمة للأمام مع عدة وكلاء في مساحة مشتركة واحدة.",
|
||||
"group.memberTooltip": "يوجد {{count}} عضو في المجموعة",
|
||||
"group.orchestratorThinking": "المنسق يفكر...",
|
||||
@@ -882,7 +877,6 @@
|
||||
"toolAuth.authorize": "تفويض",
|
||||
"toolAuth.authorizing": "جارٍ التفويض...",
|
||||
"toolAuth.hint": "بدون التفويض أو الإعداد، قد لا تعمل المهارات. قد يؤدي ذلك إلى تقييد الوكيل أو حدوث أخطاء.",
|
||||
"toolAuth.remove": "إزالة",
|
||||
"toolAuth.signIn": "تسجيل الدخول",
|
||||
"toolAuth.title": "تفويض المهارات لهذا الوكيل",
|
||||
"topic.checkOpenNewTopic": "هل تريد بدء موضوع جديد؟",
|
||||
@@ -1124,5 +1118,6 @@
|
||||
"workingPanel.skills.title": "المهارات",
|
||||
"workingPanel.space": "مسافة",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "أنت"
|
||||
"you": "أنت",
|
||||
"zenMode": "وضع التركيز"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,18 @@
|
||||
"fleet.allShown": "تم عرض جميع المهام الجارية",
|
||||
"fleet.backToHome": "العودة إلى الصفحة الرئيسية",
|
||||
"fleet.closeColumn": "إغلاق العمود",
|
||||
"fleet.closeIdleColumns": "إغلاق الأعمدة الخاملة",
|
||||
"fleet.closeIdleColumnsCount": "إغلاق {{count}} من الأعمدة الخاملة",
|
||||
"fleet.collapseReply": "طي",
|
||||
"fleet.createTask": "إنشاء مهمة",
|
||||
"fleet.dragHint": "اسحب لإعادة الترتيب",
|
||||
"fleet.empty": "لا توجد مهام مفتوحة",
|
||||
"fleet.emptyDesc": "اختر مهمة جارية على اليسار، أو استخدم + لإضافة عمود.",
|
||||
"fleet.noRunningTasks": "لا توجد مهام جارية",
|
||||
"fleet.openInChat": "فتح في الدردشة",
|
||||
"fleet.pin": "تثبيت العمود",
|
||||
"fleet.reply": "رد",
|
||||
"fleet.rows.one": "صف واحد",
|
||||
"fleet.rows.two": "صفان",
|
||||
"fleet.runningBoard": "لوحة التشغيل",
|
||||
"fleet.runningTasks": "المهام الجارية",
|
||||
"fleet.status.idle": "خامل",
|
||||
"fleet.status.paused": "متوقف مؤقتًا",
|
||||
"fleet.status.running": "قيد التشغيل",
|
||||
"fleet.status.scheduled": "مجدول",
|
||||
"fleet.tooltip": "عرض جميع الوكلاء جنبًا إلى جنب",
|
||||
"fleet.unpin": "إلغاء تثبيت العمود",
|
||||
"gateway.description": "الوصف",
|
||||
"gateway.descriptionPlaceholder": "اختياري",
|
||||
"gateway.deviceName": "اسم الجهاز",
|
||||
@@ -38,7 +30,6 @@
|
||||
"navigation.discoverMcp": "اكتشف MCP",
|
||||
"navigation.discoverModels": "اكتشف النماذج",
|
||||
"navigation.discoverProviders": "اكتشف المزودين",
|
||||
"navigation.document": "مستند",
|
||||
"navigation.group": "مجموعة",
|
||||
"navigation.groupChat": "محادثة جماعية",
|
||||
"navigation.home": "الرئيسية",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "المنتهية الصلاحية",
|
||||
"credits.packages.tabs.expiredCount": "منتهية الصلاحية ({{count}})",
|
||||
"credits.packages.title": "حزم الأرصدة الخاصة بي",
|
||||
"credits.topUp.bestValue.cta": "عرض السنوي النهائي",
|
||||
"credits.topUp.bestValue.savings": "وفر ${{savings}} على هذا الشراء",
|
||||
"credits.topUp.bestValue.title": "الخطة السنوية {{plan}} تتيح أقل معدل شحن: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "إلغاء",
|
||||
"credits.topUp.custom": "مخصص",
|
||||
"credits.topUp.freeFeeHint": "تشمل تعبئة الخطة المجانية رسوم خدمة بقيمة {{fee}} لكل مليون رصيد.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "معالجة المكافآت: سيتم توزيع الأرصدة خلال ساعة واحدة بعد أن يكمل المدعو الدفع ويجتاز التحقق",
|
||||
"referral.rules.title": "قواعد البرنامج",
|
||||
"referral.rules.validInvitation": "دعوة صالحة: يسجل المدعو باستخدام رمز الإحالة الخاص بك، وينفذ إجراءً صالحًا، ويكمل الدفع (اشتراك أو شحن أرصدة)",
|
||||
"referral.rules.validOperation": "معايير الإجراء الصالح: إرسال رسالة واحدة أو إنشاء صورة واحدة",
|
||||
"referral.stats.availableBalance": "الرصيد المتاح",
|
||||
"referral.stats.description": "عرض إحصائيات الإحالة الخاصة بك",
|
||||
"referral.stats.title": "نظرة عامة على الإحالة",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"management.status.archived": "مؤرشف",
|
||||
"management.status.completed": "مكتمل",
|
||||
"management.status.failed": "فشل",
|
||||
"management.status.idle": "خامل",
|
||||
"management.status.paused": "متوقف مؤقتًا",
|
||||
"management.status.running": "قيد التشغيل",
|
||||
"management.status.waitingForHuman": "في انتظار الإدخال",
|
||||
@@ -155,8 +154,6 @@
|
||||
"projectStatus.failed_other": "{{count}} مواضيع فشلت",
|
||||
"projectStatus.loading_one": "{{count}} موضوع قيد التحميل",
|
||||
"projectStatus.loading_other": "{{count}} مواضيع قيد التحميل",
|
||||
"projectStatus.unread_one": "{{count}} موضوع يحتوي على رد غير مقروء",
|
||||
"projectStatus.unread_other": "{{count}} مواضيع تحتوي على ردود غير مقروءة",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} موضوع ينتظر الإدخال",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} مواضيع تنتظر الإدخال",
|
||||
"renameModal.description": "يُفضَّل أن يكون قصيرًا وسهل التعرّف.",
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно.\n\nИскате да се адаптирам по-добре към вашия работен процес? Отидете в [Настройки на Агента]({{url}}) и попълнете Профила на Агента (можете да го редактирате по всяко време).",
|
||||
"agentDefaultMessageWithSystemRole": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно — вие контролирате.",
|
||||
"agentDefaultMessageWithoutEdit": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно — вие контролирате.",
|
||||
"agentDocument.backToChat": "Обратно към чата",
|
||||
"agentDocument.linkCopied": "Връзката е копирана",
|
||||
"agentDocument.openAsPage": "Отвори като цяла страница",
|
||||
"agentProfile.files_one": "{{count}} файл",
|
||||
"agentProfile.files_other": "{{count}} файла",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} база знания",
|
||||
@@ -168,9 +165,7 @@
|
||||
"extendParams.urlContext.title": "Извличане на съдържание от уеб връзки",
|
||||
"followUpPlaceholder": "Последващо действие. Използвайте @, за да възлагате задачи на други агенти.",
|
||||
"followUpPlaceholderHeterogeneous": "Последващ въпрос.",
|
||||
"gatewayMode.beta": "Бета",
|
||||
"gatewayMode.cardTitle": "Режим на шлюза за агенти",
|
||||
"gatewayMode.desc": "Стартирайте агенти в облака чрез шлюза за агенти на LobeHub. Задачите продължават да се изпълняват дори след като затворите страницата.",
|
||||
"gatewayMode.title": "Режим на шлюз",
|
||||
"group.desc": "Придвижете задача напред с няколко Агента в едно споделено пространство.",
|
||||
"group.memberTooltip": "Групата има {{count}} член(а)",
|
||||
"group.orchestratorThinking": "Оркестраторът мисли...",
|
||||
@@ -882,7 +877,6 @@
|
||||
"toolAuth.authorize": "Упълномощи",
|
||||
"toolAuth.authorizing": "Упълномощаване...",
|
||||
"toolAuth.hint": "Без упълномощаване или конфигурация, уменията може да не работят. Това може да ограничи агента или да доведе до грешки.",
|
||||
"toolAuth.remove": "Премахни",
|
||||
"toolAuth.signIn": "Вход",
|
||||
"toolAuth.title": "Упълномощи уменията за този агент",
|
||||
"topic.checkOpenNewTopic": "Да започнем нова тема?",
|
||||
@@ -1124,5 +1118,6 @@
|
||||
"workingPanel.skills.title": "Умения",
|
||||
"workingPanel.space": "Пространство",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "Вие"
|
||||
"you": "Вие",
|
||||
"zenMode": "Режим Зен"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,18 @@
|
||||
"fleet.allShown": "Всички текущи задачи са показани",
|
||||
"fleet.backToHome": "Обратно към началната страница",
|
||||
"fleet.closeColumn": "Затваряне на колона",
|
||||
"fleet.closeIdleColumns": "Затвори неактивни колони",
|
||||
"fleet.closeIdleColumnsCount": "Затвори {{count}} неактивни колони",
|
||||
"fleet.collapseReply": "Свий",
|
||||
"fleet.createTask": "Създаване на задача",
|
||||
"fleet.dragHint": "Плъзнете, за да пренаредите",
|
||||
"fleet.empty": "Няма отворени задачи",
|
||||
"fleet.emptyDesc": "Изберете текуща задача отляво или използвайте +, за да добавите колона.",
|
||||
"fleet.noRunningTasks": "Няма текущи задачи",
|
||||
"fleet.openInChat": "Отваряне в чата",
|
||||
"fleet.pin": "Закачи колона",
|
||||
"fleet.reply": "Отговор",
|
||||
"fleet.rows.one": "Един ред",
|
||||
"fleet.rows.two": "Два реда",
|
||||
"fleet.runningBoard": "Текуща дъска",
|
||||
"fleet.runningTasks": "Текущи задачи",
|
||||
"fleet.status.idle": "Неактивен",
|
||||
"fleet.status.paused": "Пауза",
|
||||
"fleet.status.running": "В процес на изпълнение",
|
||||
"fleet.status.scheduled": "Планирано",
|
||||
"fleet.tooltip": "Преглед на всички агенти един до друг",
|
||||
"fleet.unpin": "Откачи колона",
|
||||
"gateway.description": "Описание",
|
||||
"gateway.descriptionPlaceholder": "По избор",
|
||||
"gateway.deviceName": "Име на устройството",
|
||||
@@ -38,7 +30,6 @@
|
||||
"navigation.discoverMcp": "Откриване на MCP",
|
||||
"navigation.discoverModels": "Откриване на Модели",
|
||||
"navigation.discoverProviders": "Откриване на Доставчици",
|
||||
"navigation.document": "Документ",
|
||||
"navigation.group": "Група",
|
||||
"navigation.groupChat": "Групов Чат",
|
||||
"navigation.home": "Начало",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "Изтекли",
|
||||
"credits.packages.tabs.expiredCount": "Изтекли ({{count}})",
|
||||
"credits.packages.title": "Моите пакети с кредити",
|
||||
"credits.topUp.bestValue.cta": "Вижте Ultimate годишен",
|
||||
"credits.topUp.bestValue.savings": "Спестете ${{savings}} при тази покупка",
|
||||
"credits.topUp.bestValue.title": "{{plan}} годишен отключва най-ниската ставка за зареждане: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "Отказ",
|
||||
"credits.topUp.custom": "По избор",
|
||||
"credits.topUp.freeFeeHint": "Зарежданията на безплатния план включват такса за услуга от {{fee}} на 1M кредити.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "Обработка на наградите: Кредитите ще бъдат разпределени в рамките на 1 час след като поканеният завърши плащане и премине проверка.",
|
||||
"referral.rules.title": "Правила на програмата",
|
||||
"referral.rules.validInvitation": "Валидна покана: Поканеният се регистрира с вашия код за препоръка, извършва едно валидно действие и завършва плащане (абонамент или зареждане на кредити).",
|
||||
"referral.rules.validOperation": "Критерии за валидно действие: Изпращане на съобщение в Chat страницата или генериране на изображение",
|
||||
"referral.stats.availableBalance": "Налично салдо",
|
||||
"referral.stats.description": "Вижте статистиката на вашите покани",
|
||||
"referral.stats.title": "Обзор на поканите",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"management.status.archived": "Архивирани",
|
||||
"management.status.completed": "Завършени",
|
||||
"management.status.failed": "Неуспешни",
|
||||
"management.status.idle": "Неактивен",
|
||||
"management.status.paused": "Паузирани",
|
||||
"management.status.running": "В процес",
|
||||
"management.status.waitingForHuman": "Очаква въвеждане",
|
||||
@@ -155,8 +154,6 @@
|
||||
"projectStatus.failed_other": "{{count}} неуспешни теми",
|
||||
"projectStatus.loading_one": "{{count}} зареждаща се тема",
|
||||
"projectStatus.loading_other": "{{count}} зареждащи се теми",
|
||||
"projectStatus.unread_one": "{{count}} тема с непрочетен отговор",
|
||||
"projectStatus.unread_other": "{{count}} теми с непрочетени отговори",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} тема, очакваща въвеждане",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} теми, очакващи въвеждане",
|
||||
"renameModal.description": "Поддържайте го кратко и лесно за разпознаване.",
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "Hallo, ich bin **{{name}}**. Ein Satz genügt.\n\nMöchten Sie, dass ich besser zu Ihrem Arbeitsablauf passe? Gehen Sie zu [Agenteneinstellungen]({{url}}) und füllen Sie das Agentenprofil aus (Sie können es jederzeit bearbeiten).",
|
||||
"agentDefaultMessageWithSystemRole": "Hallo, ich bin **{{name}}**. Ein Satz genügt – Sie haben die Kontrolle.",
|
||||
"agentDefaultMessageWithoutEdit": "Hallo, ich bin **{{name}}**. Ein Satz genügt – Sie haben die Kontrolle.",
|
||||
"agentDocument.backToChat": "Zurück zum Chat",
|
||||
"agentDocument.linkCopied": "Link kopiert",
|
||||
"agentDocument.openAsPage": "Als vollständige Seite öffnen",
|
||||
"agentProfile.files_one": "{{count}} Datei",
|
||||
"agentProfile.files_other": "{{count}} Dateien",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} Wissensbasis",
|
||||
@@ -168,9 +165,7 @@
|
||||
"extendParams.urlContext.title": "Webseiteninhalte extrahieren",
|
||||
"followUpPlaceholder": "Folgen Sie nach. @, um Aufgaben anderen Agenten zuzuweisen.",
|
||||
"followUpPlaceholderHeterogeneous": "Weiter ausführen.",
|
||||
"gatewayMode.beta": "Beta",
|
||||
"gatewayMode.cardTitle": "Agent-Gateway-Modus",
|
||||
"gatewayMode.desc": "Führen Sie Agenten in der Cloud über LobeHubs Agent-Gateway aus. Aufgaben laufen weiter, auch nachdem Sie die Seite geschlossen haben.",
|
||||
"gatewayMode.title": "Gateway-Modus",
|
||||
"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...",
|
||||
@@ -882,7 +877,6 @@
|
||||
"toolAuth.authorize": "Autorisieren",
|
||||
"toolAuth.authorizing": "Autorisierung läuft...",
|
||||
"toolAuth.hint": "Ohne Autorisierung oder Konfiguration funktionieren Skills möglicherweise nicht. Dies kann den Agenten einschränken oder zu Fehlern führen.",
|
||||
"toolAuth.remove": "Entfernen",
|
||||
"toolAuth.signIn": "Anmelden",
|
||||
"toolAuth.title": "Skills für diesen Agenten autorisieren",
|
||||
"topic.checkOpenNewTopic": "Neues Thema starten?",
|
||||
@@ -1124,5 +1118,6 @@
|
||||
"workingPanel.skills.title": "Fähigkeiten",
|
||||
"workingPanel.space": "Leerzeichen",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "Du"
|
||||
"you": "Du",
|
||||
"zenMode": "Zen-Modus"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,18 @@
|
||||
"fleet.allShown": "Alle laufenden Aufgaben werden angezeigt",
|
||||
"fleet.backToHome": "Zurück zur Startseite",
|
||||
"fleet.closeColumn": "Spalte schließen",
|
||||
"fleet.closeIdleColumns": "Leere Spalten schließen",
|
||||
"fleet.closeIdleColumnsCount": "{{count}} leere Spalten schließen",
|
||||
"fleet.collapseReply": "Einklappen",
|
||||
"fleet.createTask": "Aufgabe erstellen",
|
||||
"fleet.dragHint": "Ziehen, um neu anzuordnen",
|
||||
"fleet.empty": "Keine offenen Aufgaben",
|
||||
"fleet.emptyDesc": "Wählen Sie eine laufende Aufgabe links aus oder verwenden Sie +, um eine Spalte hinzuzufügen.",
|
||||
"fleet.noRunningTasks": "Keine laufenden Aufgaben",
|
||||
"fleet.openInChat": "Im Chat öffnen",
|
||||
"fleet.pin": "Spalte anheften",
|
||||
"fleet.reply": "Antworten",
|
||||
"fleet.rows.one": "Einzelne Zeile",
|
||||
"fleet.rows.two": "Zwei Zeilen",
|
||||
"fleet.runningBoard": "Laufendes Board",
|
||||
"fleet.runningTasks": "Laufende Aufgaben",
|
||||
"fleet.status.idle": "Leerlauf",
|
||||
"fleet.status.paused": "Pausiert",
|
||||
"fleet.status.running": "Läuft",
|
||||
"fleet.status.scheduled": "Geplant",
|
||||
"fleet.tooltip": "Alle Agenten nebeneinander anzeigen",
|
||||
"fleet.unpin": "Spalte lösen",
|
||||
"gateway.description": "Beschreibung",
|
||||
"gateway.descriptionPlaceholder": "Optional",
|
||||
"gateway.deviceName": "Gerätename",
|
||||
@@ -38,7 +30,6 @@
|
||||
"navigation.discoverMcp": "MCP entdecken",
|
||||
"navigation.discoverModels": "Modelle entdecken",
|
||||
"navigation.discoverProviders": "Anbieter entdecken",
|
||||
"navigation.document": "Dokument",
|
||||
"navigation.group": "Gruppe",
|
||||
"navigation.groupChat": "Gruppen-Chat",
|
||||
"navigation.home": "Startseite",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "Abgelaufen",
|
||||
"credits.packages.tabs.expiredCount": "Abgelaufen ({{count}})",
|
||||
"credits.packages.title": "Meine Guthabenpakete",
|
||||
"credits.topUp.bestValue.cta": "Ultimatives Jahresabo ansehen",
|
||||
"credits.topUp.bestValue.savings": "Sparen Sie ${{savings}} bei diesem Kauf",
|
||||
"credits.topUp.bestValue.title": "{{plan}} Jahresabo bietet den niedrigsten Aufladepreis: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "Abbrechen",
|
||||
"credits.topUp.custom": "Benutzerdefiniert",
|
||||
"credits.topUp.freeFeeHint": "Aufladungen im kostenlosen Tarif beinhalten eine {{fee}} Servicegebühr pro 1M Credits.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "Belohnungsverarbeitung: Credits werden innerhalb von 1 Stunde verteilt, nachdem der Eingeladene eine Zahlung abgeschlossen und die Verifizierung bestanden hat.",
|
||||
"referral.rules.title": "Programmregeln",
|
||||
"referral.rules.validInvitation": "Gültige Einladung: Der Eingeladene registriert sich mit Ihrem Einladungscode, führt eine gültige Aktion aus und schließt eine Zahlung ab (Abonnement oder Credit-Aufladung).",
|
||||
"referral.rules.validOperation": "Gültige Aktion: Eine Nachricht senden oder ein Bild generieren",
|
||||
"referral.stats.availableBalance": "Verfügbares Guthaben",
|
||||
"referral.stats.description": "Sehen Sie Ihre Empfehlungsstatistiken",
|
||||
"referral.stats.title": "Empfehlungsübersicht",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"management.status.archived": "Archiviert",
|
||||
"management.status.completed": "Abgeschlossen",
|
||||
"management.status.failed": "Fehlgeschlagen",
|
||||
"management.status.idle": "Leerlauf",
|
||||
"management.status.paused": "Pausiert",
|
||||
"management.status.running": "Laufend",
|
||||
"management.status.waitingForHuman": "Wartet auf Eingabe",
|
||||
@@ -155,8 +154,6 @@
|
||||
"projectStatus.failed_other": "{{count}} fehlgeschlagene Themen",
|
||||
"projectStatus.loading_one": "{{count}} ladendes Thema",
|
||||
"projectStatus.loading_other": "{{count}} ladende Themen",
|
||||
"projectStatus.unread_one": "{{count}} Thema mit ungelesener Antwort",
|
||||
"projectStatus.unread_other": "{{count}} Themen mit ungelesenen Antworten",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} Thema wartet auf Eingabe",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} Themen warten auf Eingabe",
|
||||
"renameModal.description": "Kurz und leicht erkennbar halten.",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"channel.devWebhookProxyUrl": "HTTPS Tunnel URL",
|
||||
"channel.devWebhookProxyUrlHint": "Optional. HTTPS tunnel URL for forwarding webhook requests to local dev server.",
|
||||
"channel.disabled": "Disabled",
|
||||
"channel.discord.description": "Connect this agent to Discord server for channel chat and direct messages.",
|
||||
"channel.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.",
|
||||
"channel.displayToolCalls": "Display Tool Calls",
|
||||
"channel.displayToolCallsHint": "Show tool call details during AI responses. When disabled, only the final response is displayed for a cleaner experience.",
|
||||
"channel.dm": "Direct Messages",
|
||||
@@ -79,7 +79,7 @@
|
||||
"channel.endpointUrl": "Webhook URL",
|
||||
"channel.endpointUrlHint": "Please copy this URL and paste it into the <bold>{{fieldName}}</bold> field in the {{name}} Developer Portal.",
|
||||
"channel.exportConfig": "Export Configuration",
|
||||
"channel.feishu.description": "Connect this agent to Feishu for private and group chats.",
|
||||
"channel.feishu.description": "Connect this assistant to Feishu for private and group chats.",
|
||||
"channel.feishu.webhookMigrationDesc": "WebSocket mode provides real-time event delivery without needing a public callback URL. To migrate, switch the Connection Mode to WebSocket in Advanced Settings. No additional configuration is needed on the Feishu/Lark Open Platform.",
|
||||
"channel.feishu.webhookMigrationTitle": "Consider migrating to WebSocket mode",
|
||||
"channel.groupAllowFrom": "Allowed Channels",
|
||||
@@ -135,7 +135,7 @@
|
||||
"channel.imessage.bridgeTestDisabledHint": "Enable the bridge service first.",
|
||||
"channel.imessage.bridgeTestFailed": "BlueBubbles test failed",
|
||||
"channel.imessage.bridgeTestSuccess": "BlueBubbles connection passed",
|
||||
"channel.imessage.description": "Connect this agent to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
|
||||
"channel.imessage.description": "Connect this assistant to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
|
||||
"channel.imessage.desktopBridge": "Desktop Bridge",
|
||||
"channel.imessage.desktopDeviceId": "Desktop Device ID",
|
||||
"channel.imessage.desktopDeviceIdHint": "The LobeHub Desktop device that runs the local BlueBubbles bridge. Find it in Desktop Gateway settings.",
|
||||
@@ -145,12 +145,12 @@
|
||||
"channel.importFailed": "Failed to import configuration",
|
||||
"channel.importInvalidFormat": "Invalid configuration file format",
|
||||
"channel.importSuccess": "Configuration imported successfully",
|
||||
"channel.lark.description": "Connect this agent to Lark for private and group chats.",
|
||||
"channel.lark.description": "Connect this assistant to Lark for private and group chats.",
|
||||
"channel.line.channelAccessToken": "Channel Access Token",
|
||||
"channel.line.channelAccessTokenHint": "Long-lived token issued under the Messaging API tab. Token will be encrypted and stored securely.",
|
||||
"channel.line.channelSecret": "Channel Secret",
|
||||
"channel.line.channelSecretHint": "From the Basic settings tab. Required — used to verify X-Line-Signature on every inbound webhook.",
|
||||
"channel.line.description": "Connect this agent to LINE Messaging API for direct and group chats.",
|
||||
"channel.line.description": "Connect this assistant to LINE Messaging API for direct and group chats.",
|
||||
"channel.line.destinationUserId": "Destination User ID",
|
||||
"channel.line.destinationUserIdHint": "The bot's own user ID (`U` + 32 chars) — click \"Fetch from LINE\" below to auto-fill. Not the personal \"Your user ID\" shown in LINE's Basic settings.",
|
||||
"channel.line.destinationUserIdPlaceholder": "e.g. U1234567890abcdef1234567890abcdef",
|
||||
@@ -169,7 +169,7 @@
|
||||
"channel.publicKeyHint": "Optional. Used to verify interaction requests from Discord.",
|
||||
"channel.publicKeyPlaceholder": "Required for interaction verification",
|
||||
"channel.qq.appIdHint": "Your QQ Bot App ID from QQ Open Platform",
|
||||
"channel.qq.description": "Connect this agent to QQ for group chats and direct messages.",
|
||||
"channel.qq.description": "Connect this assistant to QQ for group chats and direct messages.",
|
||||
"channel.qq.webhookMigrationDesc": "WebSocket mode provides real-time event delivery and automatic reconnection without needing a callback URL. To migrate, create a new bot on QQ Open Platform without configuring a callback URL, then switch the Connection Mode to WebSocket in Advanced Settings.",
|
||||
"channel.qq.webhookMigrationTitle": "Consider migrating to WebSocket mode",
|
||||
"channel.refreshStatus": "Refresh status",
|
||||
@@ -199,7 +199,7 @@
|
||||
"channel.slack.appIdHint": "Your Slack App ID from the Slack API dashboard (starts with A).",
|
||||
"channel.slack.appToken": "App-Level Token",
|
||||
"channel.slack.appTokenHint": "Required for Socket Mode (WebSocket). Generate an app-level token (xapp-...) under Basic Information in your Slack app settings.",
|
||||
"channel.slack.description": "Connect this agent to Slack for channel conversations and direct messages.",
|
||||
"channel.slack.description": "Connect this assistant to Slack for channel conversations and direct messages.",
|
||||
"channel.slack.webhookMigrationDesc": "Socket Mode provides real-time event delivery via WebSocket without exposing a public HTTP endpoint. To migrate, enable Socket Mode in your Slack app settings, generate an App-Level Token, then switch the Connection Mode to WebSocket in Advanced Settings.",
|
||||
"channel.slack.webhookMigrationTitle": "Consider migrating to Socket Mode (WebSocket)",
|
||||
"channel.statusConnected": "Connected",
|
||||
@@ -208,7 +208,7 @@
|
||||
"channel.statusFailed": "Failed",
|
||||
"channel.statusQueued": "Queued",
|
||||
"channel.statusStarting": "Starting",
|
||||
"channel.telegram.description": "Connect this agent to Telegram for private and group chats.",
|
||||
"channel.telegram.description": "Connect this assistant to Telegram for private and group chats.",
|
||||
"channel.testConnection": "Test Connection",
|
||||
"channel.testFailed": "Connection test failed",
|
||||
"channel.testSuccess": "Connection test passed",
|
||||
@@ -236,7 +236,7 @@
|
||||
"channel.watchKeywordsAdd": "Add keyword",
|
||||
"channel.watchKeywordsEmpty": "No keywords added yet — bot only wakes on @mention or DM in subscribed channels.",
|
||||
"channel.watchKeywordsHint": "A keyword match wakes the bot without an @mention; its instruction is prepended to the user message. Whole-word, case-insensitive.",
|
||||
"channel.wechat.description": "Connect this agent to WeChat via iLink Bot for private and group chats.",
|
||||
"channel.wechat.description": "Connect this assistant to WeChat via iLink Bot for private and group chats.",
|
||||
"channel.wechatBotId": "Bot ID",
|
||||
"channel.wechatBotIdHint": "Bot identifier assigned after QR code authorization.",
|
||||
"channel.wechatConnectedInfo": "Connected WeChat Account",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"apikey.list.columns.status": "Enabled Status",
|
||||
"apikey.list.title": "API Key List",
|
||||
"apikey.validation.required": "This field cannot be empty",
|
||||
"authModal.description": "Your sign-in session has expired. Please sign in again to continue using cloud sync features.",
|
||||
"authModal.description": "Your login session has expired. Please sign in again to continue using cloud sync features.",
|
||||
"authModal.later": "Later",
|
||||
"authModal.signIn": "Sign In Again",
|
||||
"authModal.signingIn": "Signing in...",
|
||||
@@ -45,7 +45,7 @@
|
||||
"betterAuth.errors.emailRequired": "Please enter your email address or username",
|
||||
"betterAuth.errors.firstNameRequired": "Please enter your first name",
|
||||
"betterAuth.errors.lastNameRequired": "Please enter your last name",
|
||||
"betterAuth.errors.loginFailed": "Sign in failed, please check your email and password",
|
||||
"betterAuth.errors.loginFailed": "Login failed, please check your email and password",
|
||||
"betterAuth.errors.passwordFormat": "Password must contain both letters and numbers",
|
||||
"betterAuth.errors.passwordMaxLength": "Password must not exceed 64 characters",
|
||||
"betterAuth.errors.passwordMinLength": "Password must be at least 8 characters",
|
||||
@@ -155,13 +155,13 @@
|
||||
"heatmaps.tooltipTokens": "{{count}} tokens were used on {{date}}",
|
||||
"heatmaps.totalCount": "A total of {{count}} messages sent in the past year",
|
||||
"heatmaps.totalCountTokens": "A total of {{count}} tokens used in the past year",
|
||||
"login": "Sign In",
|
||||
"login": "Log In",
|
||||
"loginGuide.f1": "Get free usage",
|
||||
"loginGuide.f2": "Sync messages across devices",
|
||||
"loginGuide.f3": "Access a wealth of agents",
|
||||
"loginGuide.f4": "Explore powerful plugins",
|
||||
"loginGuide.title": "After signing in, you can:",
|
||||
"loginOrSignup": "Sign In / Sign Up",
|
||||
"loginGuide.title": "After logging in, you can:",
|
||||
"loginOrSignup": "Log In / Sign Up",
|
||||
"profile.account": "Account",
|
||||
"profile.authorizations.actions.revoke": "Revoke",
|
||||
"profile.authorizations.revoke.description": "After revoking, the tool will no longer have access to your data. Re-authorization is required to use it again.",
|
||||
@@ -186,7 +186,7 @@
|
||||
"profile.sso.loading": "Loading linked third-party accounts",
|
||||
"profile.sso.providers": "Connected Accounts",
|
||||
"profile.sso.unlink.description": "Re-authorization or re-linking is required to sign in with {{provider}} again after unlinking.",
|
||||
"profile.sso.unlink.forbidden": "You must retain at least one sign-in method.",
|
||||
"profile.sso.unlink.forbidden": "You must retain at least one login method.",
|
||||
"profile.sso.unlink.title": "Unlink {{provider}} account?",
|
||||
"profile.title": "Profile",
|
||||
"profile.updateAvatar": "Update avatar",
|
||||
@@ -201,9 +201,9 @@
|
||||
"profile.usernameRule": "Username can only contain letters, numbers, or underscores",
|
||||
"profile.usernameTooLong": "Username cannot exceed 64 characters",
|
||||
"profile.usernameUpdateFailed": "Failed to update username, please try again later",
|
||||
"signin.subtitle": "Sign up or sign in to your {{appName}} account",
|
||||
"signin.subtitle": "Sign up or log in to your {{appName}} account",
|
||||
"signin.title": "Agent teammates that grow with you",
|
||||
"signout": "Sign Out",
|
||||
"signout": "Log Out",
|
||||
"signup": "Sign Up",
|
||||
"stats.aiheatmaps": "Activity Index",
|
||||
"stats.assistants": "Agents",
|
||||
@@ -224,7 +224,7 @@
|
||||
"stats.loginGuide.f2": "Sync messages across devices",
|
||||
"stats.loginGuide.f3": "Access a wealth of agents",
|
||||
"stats.loginGuide.f4": "Explore powerful skills",
|
||||
"stats.loginGuide.title": "After signing in, you can:",
|
||||
"stats.loginGuide.title": "After logging in, you can:",
|
||||
"stats.messages": "Messages",
|
||||
"stats.modelsRank.left": "Model",
|
||||
"stats.modelsRank.right": "Messages",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions.discord": "Go to Discord for feedback",
|
||||
"actions.home": "Return to Home",
|
||||
"actions.retry": "Sign in Again",
|
||||
"actions.retry": "Log in Again",
|
||||
"codes.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER": "This account is already linked to another user",
|
||||
"codes.ACCOUNT_NOT_FOUND": "Account not found",
|
||||
"codes.CREDENTIAL_ACCOUNT_NOT_FOUND": "Credential account does not exist",
|
||||
@@ -25,7 +25,7 @@
|
||||
"codes.PASSWORD_TOO_SHORT": "Password is too short",
|
||||
"codes.PROVIDER_NOT_FOUND": "Identity provider configuration not found",
|
||||
"codes.RATE_LIMIT_EXCEEDED": "Too many requests, please try again later",
|
||||
"codes.SESSION_EXPIRED": "Session has expired, please sign in again",
|
||||
"codes.SESSION_EXPIRED": "Session has expired, please log in again",
|
||||
"codes.SOCIAL_ACCOUNT_ALREADY_LINKED": "This social account is already linked to another user",
|
||||
"codes.TEMPORARY_EMAIL_NOT_ALLOWED": "Temporary email addresses are not supported. Please use a regular email address. Repeated attempts may block this network.",
|
||||
"codes.UNEXPECTED_ERROR": "An unexpected error occurred, please try again",
|
||||
|
||||
+13
-13
@@ -25,8 +25,8 @@
|
||||
"agentDocument.openAsPage": "Open as full page",
|
||||
"agentProfile.files_one": "{{count}} file",
|
||||
"agentProfile.files_other": "{{count}} files",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} library",
|
||||
"agentProfile.knowledgeBases_other": "{{count}} libraries",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} knowledge base",
|
||||
"agentProfile.knowledgeBases_other": "{{count}} knowledge bases",
|
||||
"agentProfile.skills_one": "{{count}} skill",
|
||||
"agentProfile.skills_other": "{{count}} skills",
|
||||
"agentSignal.receipts.agentSignalLabel": "Agent Signal",
|
||||
@@ -67,7 +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 conversation messages",
|
||||
"clearCurrentMessages": "Clear current session messages",
|
||||
"cliAuthGuide.actions.openDocs": "Open Sign-in Guide",
|
||||
"cliAuthGuide.actions.openSystemTools": "Open System Tools",
|
||||
"cliAuthGuide.afterLogin": "After signing in again or refreshing credentials, retry your message. You can also re-detect in System Tools.",
|
||||
@@ -110,7 +110,7 @@
|
||||
"compression.cancelConfirm": "Are you sure you want to uncompress? This will restore the original messages.",
|
||||
"compression.history": "History",
|
||||
"compression.summary": "Summary",
|
||||
"confirmClearCurrentMessages": "You are about to clear the current conversation messages. Once cleared, they cannot be retrieved. Please confirm your action.",
|
||||
"confirmClearCurrentMessages": "You are about to clear the current session messages. Once cleared, they cannot be retrieved. Please confirm your action.",
|
||||
"confirmRemoveChatGroupItemAlert": "This Group will be deleted. Group-specific assistants will also be deleted, while external assistants will not be affected.",
|
||||
"confirmRemoveGroupItemAlert": "You are about to delete this category. After deletion, its agents will be moved to the default list. Please confirm your action.",
|
||||
"confirmRemoveGroupSuccess": "Group deleted successfully",
|
||||
@@ -169,8 +169,8 @@
|
||||
"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 +178,8 @@
|
||||
"group.profile.external": "External",
|
||||
"group.profile.externalAgentWarning": "This is an external agent. Changes made here will directly modify the original agent configuration.",
|
||||
"group.profile.groupSettings": "Group Settings",
|
||||
"group.profile.supervisor": "Orchestrator",
|
||||
"group.profile.supervisorPlaceholder": "The Orchestrator coordinates different agents. Setting Orchestrator information here enables more precise workflow coordination.",
|
||||
"group.profile.supervisor": "Supervisor",
|
||||
"group.profile.supervisorPlaceholder": "The supervisor coordinates different agents. Setting supervisor information here enables more precise workflow coordination.",
|
||||
"group.removeMember": "Remove Member",
|
||||
"group.title": "Group",
|
||||
"groupDescription": "Group description",
|
||||
@@ -586,7 +586,7 @@
|
||||
"stt.action": "Voice Input",
|
||||
"stt.loading": "Recognizing...",
|
||||
"stt.prettifying": "Polishing...",
|
||||
"supervisor.label": "Orchestrator",
|
||||
"supervisor.label": "Supervisor",
|
||||
"supervisor.todoList.allComplete": "All tasks completed",
|
||||
"supervisor.todoList.title": "Tasks Completed",
|
||||
"tab.groupProfile": "Group Profile",
|
||||
@@ -839,7 +839,7 @@
|
||||
"tokenDetails.chats": "Chat Messages",
|
||||
"tokenDetails.historySummary": "History Summary",
|
||||
"tokenDetails.rest": "Remaining",
|
||||
"tokenDetails.supervisor": "Orchestrator",
|
||||
"tokenDetails.supervisor": "Group Host",
|
||||
"tokenDetails.systemRole": "Role Settings",
|
||||
"tokenDetails.title": "Context Details",
|
||||
"tokenDetails.tools": "Skill Settings",
|
||||
@@ -890,7 +890,7 @@
|
||||
"topic.defaultTitle": "Untitled Topic",
|
||||
"topic.openNewTopic": "Open New Topic",
|
||||
"topic.recent": "Recent Topics",
|
||||
"topic.saveCurrentMessages": "Save current conversation as topic",
|
||||
"topic.saveCurrentMessages": "Save current session as topic",
|
||||
"topic.viewAll": "View All Topics",
|
||||
"translate.action": "Translate",
|
||||
"translate.clear": "Clear Translation",
|
||||
@@ -986,7 +986,7 @@
|
||||
"workflow.toolDisplayName.saveUserQuestion": "Recorded info",
|
||||
"workflow.toolDisplayName.search": "Searched the web",
|
||||
"workflow.toolDisplayName.searchAgent": "Searched agents",
|
||||
"workflow.toolDisplayName.searchKnowledgeBase": "Searched library",
|
||||
"workflow.toolDisplayName.searchKnowledgeBase": "Searched knowledge base",
|
||||
"workflow.toolDisplayName.searchLocalFiles": "Searched files",
|
||||
"workflow.toolDisplayName.searchSkill": "Searched skills",
|
||||
"workflow.toolDisplayName.searchUserMemory": "Searched memory",
|
||||
@@ -1043,7 +1043,6 @@
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.deleteTitleMulti": "Delete {{count}} items?",
|
||||
"workingPanel.resources.empty": "No webpages. Webpages crawled in this agent will show up here.",
|
||||
"workingPanel.resources.emptyDocuments": "No documents yet. Create one with the + above.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.documents": "Documents",
|
||||
"workingPanel.resources.filter.skills": "Skills",
|
||||
@@ -1125,5 +1124,6 @@
|
||||
"workingPanel.skills.title": "Skills",
|
||||
"workingPanel.space": "Space",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "You"
|
||||
"you": "You",
|
||||
"zenMode": "Zen Mode"
|
||||
}
|
||||
|
||||
@@ -168,8 +168,8 @@
|
||||
"cmdk.search.agents": "Agents",
|
||||
"cmdk.search.assistant": "Agent",
|
||||
"cmdk.search.assistants": "Agents",
|
||||
"cmdk.search.chatGroup": "Group",
|
||||
"cmdk.search.chatGroups": "Groups",
|
||||
"cmdk.search.chatGroup": "Agent Team",
|
||||
"cmdk.search.chatGroups": "Agent Teams",
|
||||
"cmdk.search.communityAgent": "Community Agent",
|
||||
"cmdk.search.file": "File",
|
||||
"cmdk.search.files": "Files",
|
||||
@@ -370,7 +370,7 @@
|
||||
"navPanel.visible": "Visible",
|
||||
"new": "New",
|
||||
"noContent": "No content",
|
||||
"oauth": "SSO Sign-in",
|
||||
"oauth": "SSO Login",
|
||||
"officialSite": "Official Website",
|
||||
"ok": "OK",
|
||||
"or": "or",
|
||||
|
||||
@@ -101,8 +101,7 @@
|
||||
"LocalFile.action.open": "Open",
|
||||
"LocalFile.action.showInFolder": "Show in Folder",
|
||||
"MaxTokenSlider.unlimited": "Unlimited",
|
||||
"ModelSelect.featureTag.audio": "This model supports audio input recognition.",
|
||||
"ModelSelect.featureTag.custom": "Custom model, by default, supports both tool calls and visual recognition. Please verify the availability of the above capabilities based on actual situations.",
|
||||
"ModelSelect.featureTag.custom": "Custom model, by default, supports both function calls and visual recognition. Please verify the availability of the above capabilities based on actual situations.",
|
||||
"ModelSelect.featureTag.file": "This model supports file upload for reading and recognition.",
|
||||
"ModelSelect.featureTag.functionCall": "This model supports tool calls.",
|
||||
"ModelSelect.featureTag.imageOutput": "This model supports image generation.",
|
||||
@@ -115,7 +114,6 @@
|
||||
"ModelSwitchPanel.byModel": "By Model",
|
||||
"ModelSwitchPanel.byProvider": "By Provider",
|
||||
"ModelSwitchPanel.detail.abilities": "Abilities",
|
||||
"ModelSwitchPanel.detail.abilities.audio": "Audio",
|
||||
"ModelSwitchPanel.detail.abilities.files": "Files",
|
||||
"ModelSwitchPanel.detail.abilities.functionCall": "Tool Calling",
|
||||
"ModelSwitchPanel.detail.abilities.imageOutput": "Image Output",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"authResult.failed.desc": "Please try again or switch to a different sign-in method",
|
||||
"authResult.failed.desc": "Please try again or switch to a different login method",
|
||||
"authResult.failed.title": "Authorization Failed",
|
||||
"authResult.success.desc": "Please click the Start button below to continue using LobeHub Desktop",
|
||||
"authResult.success.title": "Authorization Successful",
|
||||
|
||||
@@ -620,7 +620,7 @@
|
||||
"user.githubUrlInvalid": "Please enter a valid GitHub repository URL",
|
||||
"user.githubUrlRequired": "Please enter a GitHub repository URL",
|
||||
"user.login": "Become a Creator",
|
||||
"user.logout": "Sign out",
|
||||
"user.logout": "Logout",
|
||||
"user.myProfile": "My Profile",
|
||||
"user.noAgents": "This user hasn’t published any Agents yet",
|
||||
"user.noAgents.ownerDescription": "Create your first Agent and share it with the Community.",
|
||||
@@ -630,11 +630,11 @@
|
||||
"user.noForkedAgentGroups": "No forked Agent Groups yet",
|
||||
"user.noForkedAgents": "No forked Agents yet",
|
||||
"user.noGroups.title": "No Agent Groups yet",
|
||||
"user.noPlugins": "This user hasn't published any Skills yet",
|
||||
"user.noPlugins": "This user hasn't published any Plugins yet",
|
||||
"user.noSkills": "This user hasn't published any Skills yet",
|
||||
"user.openWorkspacePublicProfile": "Open Public Link",
|
||||
"user.org.noAgents": "This organization hasn’t published any Agents yet",
|
||||
"user.plugins": "Skills",
|
||||
"user.plugins": "Plugins",
|
||||
"user.publishedAgents": "Created Agents",
|
||||
"user.publishedGroups": "Created Groups",
|
||||
"user.searchPlaceholder": "Search by name or description...",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"gateway.title": "Device Gateway",
|
||||
"navigation.chat": "Chat",
|
||||
"navigation.discover": "Discover",
|
||||
"navigation.discoverAssistants": "Discover Agents",
|
||||
"navigation.discoverAssistants": "Discover Assistants",
|
||||
"navigation.discoverMcp": "Discover MCP",
|
||||
"navigation.discoverModels": "Discover Models",
|
||||
"navigation.discoverProviders": "Discover Providers",
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"import.importConfigFile.title": "Import Failed",
|
||||
"import.incompatible.description": "This file was exported from a higher version. Please try upgrading to the latest version and then re-importing.",
|
||||
"import.incompatible.title": "Current application does not support importing this file",
|
||||
"loginRequired.desc": "You will be redirected to the sign-in page shortly",
|
||||
"loginRequired.title": "Please sign in to use this feature",
|
||||
"loginRequired.desc": "You will be redirected to the login page shortly",
|
||||
"loginRequired.title": "Please log in to use this feature",
|
||||
"notFound.backHome": "Back to Home",
|
||||
"notFound.check": "Please check if your URL is correct.",
|
||||
"notFound.desc": "We couldn't find the page you were looking for.",
|
||||
@@ -124,9 +124,9 @@
|
||||
"unlock.comfyui.title": "Verify your {{name}} credentials",
|
||||
"unlock.confirm": "Confirm and Retry",
|
||||
"unlock.goToSettings": "Go to Settings",
|
||||
"unlock.oauth.description": "The administrator has enabled unified sign-in authentication. Click the button below to sign in and unlock the application.",
|
||||
"unlock.oauth.success": "Sign-in successful",
|
||||
"unlock.oauth.title": "Sign in to your account",
|
||||
"unlock.oauth.description": "The administrator has enabled unified login authentication. Click the button below to log in and unlock the application.",
|
||||
"unlock.oauth.success": "Login successful",
|
||||
"unlock.oauth.title": "Log in to your account",
|
||||
"unlock.oauth.welcome": "Welcome!",
|
||||
"unlock.password.description": "The application encryption has been enabled by the administrator. Enter the application password to unlock the application. The password only needs to be filled in once.",
|
||||
"unlock.password.placeholder": "Please enter password",
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
"library.hierarchy.empty.desc": "Add files or create a folder to get started",
|
||||
"library.hierarchy.empty.title": "Nothing here yet",
|
||||
"library.import.action": "Import to workspace…",
|
||||
"library.import.failed": "Failed to import library.",
|
||||
"library.import.success": "Library imported to {{name}}.",
|
||||
"library.import.tooltip": "Fork this library into a workspace. Files are shared by reference; the original stays in your personal space.",
|
||||
"library.import.failed": "Failed to import knowledge base.",
|
||||
"library.import.success": "Knowledge base imported to {{name}}.",
|
||||
"library.import.tooltip": "Fork this knowledge base into a workspace. Files are shared by reference; the original stays in your personal space.",
|
||||
"library.list.confirmRemoveLibrary": "You are about to delete this library. The files within it will not be deleted but moved to All Files. This action cannot be undone, so please proceed with caution.",
|
||||
"library.list.copyDescription": "Clone this library and all of its contents into another workspace.",
|
||||
"library.list.copyFailed": "Failed to copy library",
|
||||
|
||||
@@ -38,5 +38,7 @@
|
||||
"toggleLeftPanel.desc": "Show or hide the left panel",
|
||||
"toggleLeftPanel.title": "Toggle Left Panel",
|
||||
"toggleRightPanel.desc": "Show or hide the right panel",
|
||||
"toggleRightPanel.title": "Toggle Right Panel"
|
||||
"toggleRightPanel.title": "Toggle Right Panel",
|
||||
"toggleZenMode.desc": "In focus mode, only display the current conversation and hide other UI elements",
|
||||
"toggleZenMode.title": "Toggle Focus Mode"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"features.agentDocumentFloatingChatPanel.desc": "Show the floating chat panel in agent document preview only when this lab feature is enabled.",
|
||||
"features.agentDocumentFloatingChatPanel.title": "Agent Document Floating Chat Panel",
|
||||
"features.agentSelfIteration.desc": "Allow the agent to reflect, build self-awareness, and continuously iterate through ongoing attempts and interactions.",
|
||||
"features.agentSelfIteration.desc": "Allow the assistant to reflect, build self-awareness, and continuously iterate through ongoing attempts and interactions.",
|
||||
"features.agentSelfIteration.title": "Agent Self-iteration",
|
||||
"features.assistantMessageGroup.desc": "Group agent messages and their tool call results together for display",
|
||||
"features.assistantMessageGroup.title": "Agent Message Grouping",
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
"authorize.footer.terms": "Terms of Service",
|
||||
"authorize.scenes.mcp.subtitle": "Create a community profile to install and run this skill from the community.",
|
||||
"authorize.scenes.mcp.title": "Install Community Skill",
|
||||
"authorize.scenes.publish.subtitle": "Create a community profile to publish and manage your listing within the community.",
|
||||
"authorize.scenes.publish.title": "Publish to the Community",
|
||||
"authorize.scenes.sandbox.subtitle": "Create a community profile to run this tool in the community sandbox.",
|
||||
"authorize.scenes.sandbox.title": "Try the Community Sandbox",
|
||||
"authorize.subtitle": "Create a community profile to use community features.",
|
||||
"authorize.subtitle": "Create a community profile to submit and manage listings within the community.",
|
||||
"authorize.title": "Create Community Profile",
|
||||
"callback.buttons.close": "Close Window",
|
||||
"callback.messages.authFailed": "Authorization failed: {{error}}",
|
||||
@@ -32,7 +34,7 @@
|
||||
"errors.authorizationFailed": "Authorization failed, please try again.",
|
||||
"errors.browserOnly": "The authorization process can only be initiated in a browser.",
|
||||
"errors.codeConsumed": "The authorization code has already been used. Please try again.",
|
||||
"errors.codeVerifierMissing": "Invalid authorization session. Please restart the sign-in process.",
|
||||
"errors.codeVerifierMissing": "Invalid authorization session. Please restart the login process.",
|
||||
"errors.general": "An error occurred during authorization. Please try again.",
|
||||
"errors.handoffFailed": "Failed to retrieve authorization result. Please try again.",
|
||||
"errors.handoffTimeout": "Authorization timed out. Please complete the process in your browser and try again.",
|
||||
@@ -40,7 +42,7 @@
|
||||
"errors.openBrowserFailed": "Failed to open the system browser. Please try again.",
|
||||
"errors.openPopupFailed": "Failed to open authorization popup. Please check your browser's popup blocker settings.",
|
||||
"errors.popupClosed": "The authorization window was closed before completion.",
|
||||
"errors.sessionExpired": "Authorization session has expired. Please sign in again.",
|
||||
"errors.sessionExpired": "Authorization session has expired. Please log in again.",
|
||||
"errors.stateMismatch": "Authorization state mismatch. Please try again.",
|
||||
"errors.stateMissing": "Authorization state not found. Please try again.",
|
||||
"messages.authorizationFailed": "Authorization ran into an issue. Retry, or check if you finished signing in in your browser.",
|
||||
@@ -48,6 +50,8 @@
|
||||
"messages.handoffTimeout": "Authorization timed out. Finish it in your browser, then retry.",
|
||||
"messages.loading": "Starting authorization process...",
|
||||
"messages.success.cloudMcpInstall": "Authorization successful! You can now install the Cloud MCP skill.",
|
||||
"messages.success.submit": "Authorization successful! You can now publish your agent.",
|
||||
"messages.success.upload": "Authorization successful! You can now publish a new version.",
|
||||
"profileSetup.cancel": "Cancel",
|
||||
"profileSetup.confirmChangeUserId.cancel": "Cancel",
|
||||
"profileSetup.confirmChangeUserId.confirm": "Change User ID",
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
"activity.notes": "Notes",
|
||||
"analysis.action.button": "Request memory analysis",
|
||||
"analysis.modal.cancel": "Cancel",
|
||||
"analysis.modal.helper": "By default Lobe AI will analyze all unprocessed conversations. It's optional to select a date range to analyze.",
|
||||
"analysis.modal.helper": "By default Lobe AI will analyze all unprocessed chats. It's optional to select a date range to analyze.",
|
||||
"analysis.modal.rangePlaceholder": "No range selected; all conversations will be analyzed.",
|
||||
"analysis.modal.rangeSelected": "Analyzing conversations from {{start}} to {{end}}",
|
||||
"analysis.modal.rangeSelected": "Analyzing chats from {{start}} to {{end}}",
|
||||
"analysis.modal.submit": "Request memory analysis",
|
||||
"analysis.modal.title": "Analyze conversations to generate memories",
|
||||
"analysis.modal.title": "Analyze chats to generate memories",
|
||||
"analysis.range.all": "All conversations",
|
||||
"analysis.range.end": "Today",
|
||||
"analysis.range.start": "Beginning",
|
||||
"analysis.status.errorTitle": "Memory analysis request failed",
|
||||
"analysis.status.progress": "Processed {{completed}} / {{total}} conversations",
|
||||
"analysis.status.progressUnknown": "Processed {{completed}} conversations so far",
|
||||
"analysis.status.progress": "Processed {{completed}} / {{total}} topics",
|
||||
"analysis.status.progressUnknown": "Processed {{completed}} topics so far",
|
||||
"analysis.status.tip": "We are processing your conversations to build personal memories. This may take a few minutes.",
|
||||
"analysis.status.title": "Memory analysis in progress",
|
||||
"analysis.toast.deduped": "A memory request is already running, continuing progress…",
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"createNewAiProvider.createSuccess": "Creation successful",
|
||||
"createNewAiProvider.description.placeholder": "Provider description (optional)",
|
||||
"createNewAiProvider.description.title": "Provider Description",
|
||||
"createNewAiProvider.id.desc": "Unique identifier for the provider, which cannot be modified after creation",
|
||||
"createNewAiProvider.id.desc": "Unique identifier for the service provider, which cannot be modified after creation",
|
||||
"createNewAiProvider.id.duplicate": "Provider ID already exists",
|
||||
"createNewAiProvider.id.format": "Can only contain numbers, lowercase letters, hyphens (-), and underscores (_) ",
|
||||
"createNewAiProvider.id.placeholder": "Suggested all lowercase, e.g., openai, cannot be modified after creation",
|
||||
@@ -222,7 +222,6 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.effort.hint": "For Claude Opus 4.6; controls effort level (low/medium/high/max).",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "For Claude Opus 4.6; toggles adaptive thinking on or off.",
|
||||
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "For Claude, DeepSeek and other reasoning models; unlock deeper thinking.",
|
||||
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "For GLM-5.2; controls reasoning effort with High and Max levels.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "For GPT-5 series; controls reasoning intensity.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "For GPT-5.1 series; controls reasoning intensity.",
|
||||
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "For GPT-5.2 Pro series; controls reasoning intensity.",
|
||||
@@ -256,8 +255,7 @@
|
||||
"providerModels.item.modelConfig.files.extra": "The current file upload implementation is just a hack solution, limited to self-experimentation. Please wait for complete file upload capabilities in future implementations.",
|
||||
"providerModels.item.modelConfig.files.title": "File Upload Support",
|
||||
"providerModels.item.modelConfig.functionCall.extra": "This configuration will only enable the model's ability to use tools, allowing for the addition of tool-type skills. However, whether the model can truly use the tools depends entirely on the model itself; please test for usability on your own.",
|
||||
"providerModels.item.modelConfig.functionCall.title": "Support for Tool Calling",
|
||||
"providerModels.item.modelConfig.id.duplicate": "A model with this ID already exists. Use a different model ID.",
|
||||
"providerModels.item.modelConfig.functionCall.title": "Support for Tool Usage",
|
||||
"providerModels.item.modelConfig.id.extra": "This cannot be modified after creation and will be used as the model ID when calling AI",
|
||||
"providerModels.item.modelConfig.id.placeholder": "Please enter the model ID, e.g., gpt-4o or claude-3.5-sonnet",
|
||||
"providerModels.item.modelConfig.id.title": "Model ID",
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
"handoff.desc.success": "An attempt has been made to open the desktop application. If it does not open automatically, please switch manually. You can close this browser window later.",
|
||||
"handoff.title.processing": "Authorization in progress...",
|
||||
"handoff.title.success": "Authorization completed",
|
||||
"login.button": "Confirm Sign In",
|
||||
"login.description": "The application {{clientName}} is requesting to use your account for sign-in",
|
||||
"login.title": "Sign in to {{clientName}}",
|
||||
"login.button": "Confirm Login",
|
||||
"login.description": "The application {{clientName}} is requesting to use your account for login",
|
||||
"login.title": "Login to {{clientName}}",
|
||||
"login.userWelcome": "Welcome back, ",
|
||||
"success.subTitle": "You have successfully authorized the application to access your account. You may now close this page.",
|
||||
"success.title": "Authorization Successful"
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"builtins.lobe-agent-management.apiName.deleteAgent": "Delete agent",
|
||||
"builtins.lobe-agent-management.apiName.duplicateAgent": "Duplicate agent",
|
||||
"builtins.lobe-agent-management.apiName.getAgentDetail": "Get agent detail",
|
||||
"builtins.lobe-agent-management.apiName.installPlugin": "Install Skill",
|
||||
"builtins.lobe-agent-management.apiName.installPlugin": "Install plugin",
|
||||
"builtins.lobe-agent-management.apiName.searchAgent": "Search agents",
|
||||
"builtins.lobe-agent-management.apiName.updateAgent": "Update agent",
|
||||
"builtins.lobe-agent-management.apiName.updatePrompt": "Update prompt",
|
||||
@@ -84,7 +84,7 @@
|
||||
"builtins.lobe-agent-management.inspector.createAgent.title": "Creating agent:",
|
||||
"builtins.lobe-agent-management.inspector.duplicateAgent.title": "Duplicating agent:",
|
||||
"builtins.lobe-agent-management.inspector.getAgentDetail.title": "Getting details:",
|
||||
"builtins.lobe-agent-management.inspector.installPlugin.title": "Installing Skill:",
|
||||
"builtins.lobe-agent-management.inspector.installPlugin.title": "Installing plugin:",
|
||||
"builtins.lobe-agent-management.inspector.searchAgent.all": "Search agents:",
|
||||
"builtins.lobe-agent-management.inspector.searchAgent.market": "Search market:",
|
||||
"builtins.lobe-agent-management.inspector.searchAgent.results": "{{count}} results",
|
||||
@@ -94,7 +94,7 @@
|
||||
"builtins.lobe-agent-management.render.duplicateAgent.newId": "New Agent ID",
|
||||
"builtins.lobe-agent-management.render.duplicateAgent.sourceId": "Source Agent ID",
|
||||
"builtins.lobe-agent-management.render.installPlugin.failed": "Installation failed",
|
||||
"builtins.lobe-agent-management.render.installPlugin.plugin": "Skill",
|
||||
"builtins.lobe-agent-management.render.installPlugin.plugin": "Plugin",
|
||||
"builtins.lobe-agent-management.render.installPlugin.success": "Installed successfully",
|
||||
"builtins.lobe-agent-management.title": "Agent Manager",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia": "Analyze visual media",
|
||||
|
||||
+84
-15
@@ -267,8 +267,8 @@
|
||||
"danger.clear.action": "Clear Now",
|
||||
"danger.clear.confirm": "Clear all chat data? This can't be undone.",
|
||||
"danger.clear.desc": "Delete all data, including agents, files, messages, and skills. Your account will NOT be deleted.",
|
||||
"danger.clear.success": "All conversation messages have been cleared",
|
||||
"danger.clear.title": "Clear Data",
|
||||
"danger.clear.success": "All session messages have been cleared",
|
||||
"danger.clear.title": "Wipe Data",
|
||||
"danger.reset.action": "Reset Now",
|
||||
"danger.reset.confirm": "Reset all settings?",
|
||||
"danger.reset.currentVersion": "Current Version",
|
||||
@@ -348,9 +348,9 @@
|
||||
"header.global": "Global Settings",
|
||||
"header.group": "Group Settings",
|
||||
"header.groupDesc": "Manage group and chat preferences",
|
||||
"header.session": "Agent Settings",
|
||||
"header.sessionDesc": "Agent Profile and chat preferences",
|
||||
"header.sessionWithName": "Agent Settings · {{name}}",
|
||||
"header.session": "Session Settings",
|
||||
"header.sessionDesc": "Agent Profile and session preferences",
|
||||
"header.sessionWithName": "Session Settings · {{name}}",
|
||||
"header.title": "Settings",
|
||||
"heterogeneousStatus.account.label": "Account",
|
||||
"heterogeneousStatus.auth.api": "API",
|
||||
@@ -432,7 +432,7 @@
|
||||
"llm.fetcher.latestTime": "Last Updated: {{time}}",
|
||||
"llm.fetcher.noLatestTime": "No list available yet",
|
||||
"llm.helpDoc": "Configuration Guide",
|
||||
"llm.modelList.desc": "Select the models to display in conversations. The selected models will be displayed in the model list.",
|
||||
"llm.modelList.desc": "Select the models to display in the session. The selected models will be displayed in the model list.",
|
||||
"llm.modelList.placeholder": "Please select a model from the list",
|
||||
"llm.modelList.title": "Model List",
|
||||
"llm.modelList.total": "{{count}} models available in total",
|
||||
@@ -440,7 +440,60 @@
|
||||
"llm.proxyUrl.title": "API proxy URL",
|
||||
"llm.waitingForMore": "More models are <1>planned to be added</1>, stay tuned",
|
||||
"llm.waitingForMoreLinkAriaLabel": "Open the Provider request form",
|
||||
"marketPublish.forkConfirm.by": "by {{author}}",
|
||||
"marketPublish.forkConfirm.confirm": "Confirm Publish",
|
||||
"marketPublish.forkConfirm.confirmGroup": "Confirm Publish",
|
||||
"marketPublish.forkConfirm.description": "You are about to publish a derivative version based on an existing agent from the community. Your new agent will be created as a separate entry in the marketplace.",
|
||||
"marketPublish.forkConfirm.descriptionGroup": "You are about to publish a derivative version based on an existing group from the community. Your new group will be created as a separate entry in the marketplace.",
|
||||
"marketPublish.forkConfirm.title": "Publish Derivative Agent",
|
||||
"marketPublish.forkConfirm.titleGroup": "Publish Derivative Group",
|
||||
"marketPublish.modal.changelog.extra": "Describe the key changes and improvements in this version",
|
||||
"marketPublish.modal.changelog.label": "Changelog",
|
||||
"marketPublish.modal.changelog.maxLengthError": "Changelog must not exceed 500 characters",
|
||||
"marketPublish.modal.changelog.placeholder": "Enter the changelog",
|
||||
"marketPublish.modal.changelog.required": "Please enter the changelog",
|
||||
"marketPublish.modal.comparison.local": "Current Local Version",
|
||||
"marketPublish.modal.comparison.remote": "Currently Published Version",
|
||||
"marketPublish.modal.identifier.extra": "This is the Agent’s unique identifier. Use lowercase letters, numbers, and hyphens.",
|
||||
"marketPublish.modal.identifier.label": "Agent Identifier",
|
||||
"marketPublish.modal.identifier.lengthError": "Identifier must be between 3 and 50 characters",
|
||||
"marketPublish.modal.identifier.patternError": "Identifier can only contain lowercase letters, numbers, and hyphens",
|
||||
"marketPublish.modal.identifier.placeholder": "Enter a unique identifier for the agent, e.g., web-development",
|
||||
"marketPublish.modal.identifier.required": "Please enter the agent identifier",
|
||||
"marketPublish.modal.loading.fetchingRemote": "Loading remote data...",
|
||||
"marketPublish.modal.loading.submit": "Submitting Agent...",
|
||||
"marketPublish.modal.loading.submitGroup": "Submitting Group...",
|
||||
"marketPublish.modal.loading.upload": "Publishing new version...",
|
||||
"marketPublish.modal.loading.uploadGroup": "Publishing new group version...",
|
||||
"marketPublish.modal.messages.createVersionFailed": "Failed to create version: {{message}}",
|
||||
"marketPublish.modal.messages.fetchRemoteFailed": "Failed to fetch remote agent data",
|
||||
"marketPublish.modal.messages.missingIdentifier": "This Agent doesn’t have a Community identifier yet.",
|
||||
"marketPublish.modal.messages.noGroup": "No group selected",
|
||||
"marketPublish.modal.messages.notAuthenticated": "Sign in to your Community account first.",
|
||||
"marketPublish.modal.messages.publishFailed": "Publish failed: {{message}}",
|
||||
"marketPublish.modal.submitButton": "Publish",
|
||||
"marketPublish.modal.title.submit": "Share to Agent Community",
|
||||
"marketPublish.modal.title.upload": "Publish New Version",
|
||||
"marketPublish.resultModal.message": "Your Agent has been submitted for review. Once approved, it will go live automatically.",
|
||||
"marketPublish.resultModal.messageGroup": "Your Group has been submitted for review. Once approved, it will go live automatically.",
|
||||
"marketPublish.resultModal.title": "Submission Successful",
|
||||
"marketPublish.resultModal.view": "View in Community",
|
||||
"marketPublish.status.underReview": "Under Review",
|
||||
"marketPublish.submit.button": "Share to Community",
|
||||
"marketPublish.submit.tooltip": "Share this Agent to the Community",
|
||||
"marketPublish.submitGroup.tooltip": "Share this Group to the Community",
|
||||
"marketPublish.upload.button": "Publish New Version",
|
||||
"marketPublish.upload.tooltip": "Publish a new version to Agent Community",
|
||||
"marketPublish.uploadGroup.tooltip": "Publish a new version to Group Community",
|
||||
"marketPublish.validation.communitySetupRequired.action": "Set Up Now",
|
||||
"marketPublish.validation.communitySetupRequired.desc": "This workspace hasn't set up its Community profile yet. Set it up before publishing to the Community.",
|
||||
"marketPublish.validation.communitySetupRequired.memberHint": "This workspace hasn't set up its Community profile yet. Ask a workspace owner to set it up before publishing to the Community.",
|
||||
"marketPublish.validation.communitySetupRequired.title": "Set Up Community Profile First",
|
||||
"marketPublish.validation.confirmPublish": "Publish to the Market?",
|
||||
"marketPublish.validation.confirmPublishDesc": "Once published, this content will be publicly visible in the market and available for anyone to discover and use.",
|
||||
"marketPublish.validation.emptyName": "Cannot publish: Name is required",
|
||||
"marketPublish.validation.emptySystemRole": "Cannot publish: System Role is required",
|
||||
"marketPublish.validation.underReview": "Your new version is currently under review. Please wait for approval before publishing a new version.",
|
||||
"memory.effort.desc": "Control how aggressively the AI retrieves and updates memory.",
|
||||
"memory.effort.high": "High — Proactive retrieval and updates",
|
||||
"memory.effort.level.high": "High",
|
||||
@@ -462,6 +515,14 @@
|
||||
"myAgents.actions.deprecateLoading": "Deprecating agent...",
|
||||
"myAgents.actions.deprecateSuccess": "Agent deprecated",
|
||||
"myAgents.actions.edit": "Edit Agent",
|
||||
"myAgents.actions.publish": "Publish Agent",
|
||||
"myAgents.actions.publishError": "Failed to publish agent",
|
||||
"myAgents.actions.publishLoading": "Publishing agent...",
|
||||
"myAgents.actions.publishSuccess": "Agent published",
|
||||
"myAgents.actions.unpublish": "Unpublish Agent",
|
||||
"myAgents.actions.unpublishError": "Failed to unpublish agent",
|
||||
"myAgents.actions.unpublishLoading": "Unpublishing agent...",
|
||||
"myAgents.actions.unpublishSuccess": "Agent unpublished",
|
||||
"myAgents.actions.viewDetail": "View Details",
|
||||
"myAgents.detail.category": "Category",
|
||||
"myAgents.detail.description": "Description",
|
||||
@@ -526,6 +587,7 @@
|
||||
"plugin.settings.title": "{{id}} Skill Configuration",
|
||||
"plugin.settings.tooltip": "Skill Configuration",
|
||||
"plugin.store": "Skill Store",
|
||||
"publishToCommunity": "Publish to Community",
|
||||
"serviceModel.contextLimit.placeholder": "Context limit",
|
||||
"serviceModel.memoryModels.title": "Memory Models",
|
||||
"serviceModel.modelAssignments.title": "Model Assignments",
|
||||
@@ -639,12 +701,12 @@
|
||||
"settingGroup.scene.options.productive": "Productive",
|
||||
"settingGroup.scene.title": "Group scenario",
|
||||
"settingGroup.submit": "Update Group",
|
||||
"settingGroup.systemPrompt.placeholder": "Please enter the Orchestrator system prompt",
|
||||
"settingGroup.systemPrompt.title": "Orchestrator System Prompt",
|
||||
"settingGroup.systemPrompt.placeholder": "Please enter the host system prompt",
|
||||
"settingGroup.systemPrompt.title": "Host System Prompt",
|
||||
"settingGroup.title": "Group Information",
|
||||
"settingGroupChat.allowDM.desc": "When turned off, you can still send direct messages to the agent",
|
||||
"settingGroupChat.allowDM.title": "Allow Direct Messages from Agent",
|
||||
"settingGroupChat.enableSupervisor.desc": "Enable the Orchestrator feature to manage Group conversations",
|
||||
"settingGroupChat.enableSupervisor.desc": "Enable the moderator feature to manage Group conversations",
|
||||
"settingGroupChat.enableSupervisor.title": "Enable Orchestrator",
|
||||
"settingGroupChat.maxResponseInRow.desc": "Select how many consecutive messages a member can reply with. Set to 0 to disable this limit.",
|
||||
"settingGroupChat.maxResponseInRow.title": "Consecutive Reply Count",
|
||||
@@ -665,9 +727,9 @@
|
||||
"settingGroupChat.revealDM.desc": "Make private messages sent to other members visible to you.",
|
||||
"settingGroupChat.revealDM.title": "Show Private Messages",
|
||||
"settingGroupChat.submit": "Update Settings",
|
||||
"settingGroupChat.systemPrompt.desc": "Custom system prompt for the group chat Orchestrator. This may affect the default Orchestrator behavior.",
|
||||
"settingGroupChat.systemPrompt.placeholder": "Please enter a custom Orchestrator system prompt...",
|
||||
"settingGroupChat.systemPrompt.title": "Orchestrator System Prompt",
|
||||
"settingGroupChat.systemPrompt.desc": "Custom system prompt for the group chat host. This may affect the default host behavior.",
|
||||
"settingGroupChat.systemPrompt.placeholder": "Please enter a custom host system prompt...",
|
||||
"settingGroupChat.systemPrompt.title": "Host System Prompt",
|
||||
"settingGroupChat.title": "Chat Settings",
|
||||
"settingGroupMembers.addToGroup": "Add to Group",
|
||||
"settingGroupMembers.availableAgents": "Available Agents",
|
||||
@@ -893,6 +955,13 @@
|
||||
"storageOverage.usage.estimatedCharge": "Est. Cycle Charge",
|
||||
"storageOverage.usage.incurredCharge": "Incurred This Cycle",
|
||||
"storageOverage.usage.overage": "Overage",
|
||||
"submitAgentModal.button": "Submit Agent",
|
||||
"submitAgentModal.identifier": "Agent Identifier",
|
||||
"submitAgentModal.metaMiss": "Please complete the agent information before submitting. It should include name, description, and tags",
|
||||
"submitAgentModal.placeholder": "Enter a unique identifier for the agent, e.g. web-development",
|
||||
"submitAgentModal.success": "Agent submitted successfully",
|
||||
"submitAgentModal.tooltips": "Share to Agent Community",
|
||||
"submitGroupModal.tooltips": "Share to Group Community",
|
||||
"sync.device.deviceName.hint": "Add a name for easy identification",
|
||||
"sync.device.deviceName.placeholder": "Enter device name",
|
||||
"sync.device.deviceName.title": "Device Name",
|
||||
@@ -1565,13 +1634,13 @@
|
||||
"workspace.general.delete.confirm.title": "Delete Workspace",
|
||||
"workspace.general.delete.confirm.warning.items.agents": "All agents, skills, and their configurations",
|
||||
"workspace.general.delete.confirm.warning.items.billing": "Subscription, budget settings, and auto top-up",
|
||||
"workspace.general.delete.confirm.warning.items.conversations": "All messages, topics, and tasks",
|
||||
"workspace.general.delete.confirm.warning.items.conversations": "All sessions, messages, topics, and tasks",
|
||||
"workspace.general.delete.confirm.warning.items.files": "Uploaded files, generations, and knowledge base data",
|
||||
"workspace.general.delete.confirm.warning.items.members": "Members, pending invitations, and audit logs",
|
||||
"workspace.general.delete.confirm.warning.lead": "The {{name}} workspace will be permanently deleted, along with:",
|
||||
"workspace.general.delete.confirm.warning.tail": "This cannot be undone. Spend and top-up history will be retained for audit only.",
|
||||
"workspace.general.delete.cta": "Delete Workspace",
|
||||
"workspace.general.delete.description": "Permanently delete this workspace and everything inside it — agents, messages, files, members, and invitations. This action cannot be reversed.",
|
||||
"workspace.general.delete.description": "Permanently delete this workspace and everything inside it — agents, sessions, messages, files, members, and invitations. This action cannot be reversed.",
|
||||
"workspace.general.delete.failed": "Failed to delete workspace",
|
||||
"workspace.general.delete.hint": "Cancel any active subscription before deletion. Billing history is kept for audit.",
|
||||
"workspace.general.delete.notOwner": "Only the workspace owner can delete this workspace.",
|
||||
@@ -1984,7 +2053,7 @@
|
||||
"workspaceSetting.group.subscription": "Plans",
|
||||
"workspaceSetting.storage.comingSoon": "Workspace-scoped data import & export is coming soon.",
|
||||
"workspaceSetting.storage.danger.clear.desc": "Delete all data in this workspace, including agents, files, messages, and skills. The workspace itself will NOT be deleted.",
|
||||
"workspaceSetting.storage.danger.clear.title": "Clear Workspace Data",
|
||||
"workspaceSetting.storage.danger.clear.title": "Wipe Workspace Data",
|
||||
"workspaceSetting.storage.danger.reset.desc": "Restore all workspace settings to defaults. Workspace data will not be deleted.",
|
||||
"workspaceSetting.storage.danger.reset.title": "Reset Workspace Settings",
|
||||
"workspaceSetting.storage.telemetry.desc": "Help us improve {{appName}} with anonymous workspace usage data",
|
||||
|
||||
@@ -7,16 +7,16 @@
|
||||
"actions.confirmRemoveUnstarred": "You are about to delete unstarred topics. This action cannot be undone.",
|
||||
"actions.copyLink": "Copy Link",
|
||||
"actions.copyLinkSuccess": "Link copied",
|
||||
"actions.copySessionId": "Copy Topic ID",
|
||||
"actions.copySessionIdSuccess": "Topic ID copied",
|
||||
"actions.copySessionId": "Copy Session ID",
|
||||
"actions.copySessionIdSuccess": "Session ID copied",
|
||||
"actions.copyWorkingDirectory": "Copy Working Directory",
|
||||
"actions.copyWorkingDirectorySuccess": "Working directory copied",
|
||||
"actions.duplicate": "Duplicate",
|
||||
"actions.export": "Export Topics",
|
||||
"actions.favorite": "Favorite",
|
||||
"actions.import": "Import Topics",
|
||||
"actions.import": "Import Conversation",
|
||||
"actions.markCompleted": "Mark as Completed",
|
||||
"actions.moveToAgent": "Move to another agent",
|
||||
"actions.moveToAgent": "Move to another assistant",
|
||||
"actions.openInNewTab": "Open in New Tab",
|
||||
"actions.openInNewWindow": "Open in a new window",
|
||||
"actions.removeAll": "Delete All Topics",
|
||||
@@ -56,7 +56,7 @@
|
||||
"guide.title": "Topic List",
|
||||
"importError": "Import Failed",
|
||||
"importInvalidFormat": "Invalid file format. Please ensure it is a valid JSON file.",
|
||||
"importLoading": "Importing topics...",
|
||||
"importLoading": "Importing conversation...",
|
||||
"importSuccess": "Successfully imported {{count}} messages",
|
||||
"inPopup.description": "This topic is currently open in a separate window. Continue the conversation there to keep messages in sync.",
|
||||
"inPopup.focus": "Focus Popup Window",
|
||||
@@ -81,9 +81,9 @@
|
||||
"management.bulk.deleteConfirm": "You are about to delete {{count}} topics. This action cannot be undone.",
|
||||
"management.bulk.deleteTitle": "Delete topics?",
|
||||
"management.bulk.favorite": "Favorite",
|
||||
"management.bulk.move": "Move to agent",
|
||||
"management.bulk.moveEmpty": "No other agents",
|
||||
"management.bulk.moveSearchPlaceholder": "Search agents…",
|
||||
"management.bulk.move": "Move to assistant",
|
||||
"management.bulk.moveEmpty": "No other assistants",
|
||||
"management.bulk.moveSearchPlaceholder": "Search assistants…",
|
||||
"management.bulk.selectedCount_one": "{{count}} selected",
|
||||
"management.bulk.selectedCount_other": "{{count}} selected",
|
||||
"management.card.noPreview": "No preview available",
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "Hola, soy **{{name}}**. Una frase es suficiente.\n\n¿Quieres que me adapte mejor a tu flujo de trabajo? Ve a [Configuración del Agente]({{url}}) y completa el Perfil del Agente (puedes editarlo en cualquier momento).",
|
||||
"agentDefaultMessageWithSystemRole": "Hola, soy **{{name}}**. Una frase es suficiente—tú tienes el control.",
|
||||
"agentDefaultMessageWithoutEdit": "Hola, soy **{{name}}**. Una frase es suficiente—tú tienes el control.",
|
||||
"agentDocument.backToChat": "Volver al chat",
|
||||
"agentDocument.linkCopied": "Enlace copiado",
|
||||
"agentDocument.openAsPage": "Abrir como página completa",
|
||||
"agentProfile.files_one": "{{count}} archivo",
|
||||
"agentProfile.files_other": "{{count}} archivos",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} base de conocimiento",
|
||||
@@ -168,9 +165,7 @@
|
||||
"extendParams.urlContext.title": "Extraer contenido de enlaces web",
|
||||
"followUpPlaceholder": "Seguimiento. Usa @ para asignar tareas a otros agentes.",
|
||||
"followUpPlaceholderHeterogeneous": "Continuar.",
|
||||
"gatewayMode.beta": "Beta",
|
||||
"gatewayMode.cardTitle": "Modo de Puerta de Enlace del Agente",
|
||||
"gatewayMode.desc": "Ejecuta agentes en la nube a través de la Puerta de Enlace de Agentes de LobeHub. Las tareas continúan ejecutándose incluso después de cerrar la página.",
|
||||
"gatewayMode.title": "Modo Gateway",
|
||||
"group.desc": "Avanza una tarea con múltiples Agentes en un espacio compartido.",
|
||||
"group.memberTooltip": "Hay {{count}} miembros en el grupo",
|
||||
"group.orchestratorThinking": "El Orquestador está pensando...",
|
||||
@@ -882,7 +877,6 @@
|
||||
"toolAuth.authorize": "Autorizar",
|
||||
"toolAuth.authorizing": "Autorizando...",
|
||||
"toolAuth.hint": "Sin autorización o configuración, las habilidades pueden no funcionar. Esto puede limitar al agente o causar errores.",
|
||||
"toolAuth.remove": "Eliminar",
|
||||
"toolAuth.signIn": "Iniciar sesión",
|
||||
"toolAuth.title": "Autorizar habilidades para este agente",
|
||||
"topic.checkOpenNewTopic": "¿Iniciar un nuevo tema?",
|
||||
@@ -1124,5 +1118,6 @@
|
||||
"workingPanel.skills.title": "Habilidades",
|
||||
"workingPanel.space": "Espacio",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "Tú"
|
||||
"you": "Tú",
|
||||
"zenMode": "Modo Zen"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,18 @@
|
||||
"fleet.allShown": "Se muestran todas las tareas en ejecución",
|
||||
"fleet.backToHome": "Volver a inicio",
|
||||
"fleet.closeColumn": "Cerrar columna",
|
||||
"fleet.closeIdleColumns": "Cerrar columnas inactivas",
|
||||
"fleet.closeIdleColumnsCount": "Cerrar {{count}} columnas inactivas",
|
||||
"fleet.collapseReply": "Colapsar",
|
||||
"fleet.createTask": "Crear tarea",
|
||||
"fleet.dragHint": "Arrastrar para reordenar",
|
||||
"fleet.empty": "No hay tareas abiertas",
|
||||
"fleet.emptyDesc": "Selecciona una tarea en ejecución a la izquierda o usa + para agregar una columna.",
|
||||
"fleet.noRunningTasks": "No hay tareas en ejecución",
|
||||
"fleet.openInChat": "Abrir en el chat",
|
||||
"fleet.pin": "Fijar columna",
|
||||
"fleet.reply": "Responder",
|
||||
"fleet.rows.one": "Una fila",
|
||||
"fleet.rows.two": "Dos filas",
|
||||
"fleet.runningBoard": "Tablero en ejecución",
|
||||
"fleet.runningTasks": "Tareas en ejecución",
|
||||
"fleet.status.idle": "Inactivo",
|
||||
"fleet.status.paused": "Pausado",
|
||||
"fleet.status.running": "En ejecución",
|
||||
"fleet.status.scheduled": "Programado",
|
||||
"fleet.tooltip": "Ver todos los agentes uno al lado del otro",
|
||||
"fleet.unpin": "Desfijar columna",
|
||||
"gateway.description": "Descripción",
|
||||
"gateway.descriptionPlaceholder": "Opcional",
|
||||
"gateway.deviceName": "Nombre del Dispositivo",
|
||||
@@ -38,7 +30,6 @@
|
||||
"navigation.discoverMcp": "Descubrir MCP",
|
||||
"navigation.discoverModels": "Descubrir Modelos",
|
||||
"navigation.discoverProviders": "Descubrir Proveedores",
|
||||
"navigation.document": "Documento",
|
||||
"navigation.group": "Grupo",
|
||||
"navigation.groupChat": "Chat de Grupo",
|
||||
"navigation.home": "Inicio",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "Expirados",
|
||||
"credits.packages.tabs.expiredCount": "Expirados ({{count}})",
|
||||
"credits.packages.title": "Mis Paquetes de Créditos",
|
||||
"credits.topUp.bestValue.cta": "Ver Ultimate anual",
|
||||
"credits.topUp.bestValue.savings": "Ahorra ${{savings}} en esta compra",
|
||||
"credits.topUp.bestValue.title": "{{plan}} anual desbloquea la tarifa de recarga más baja: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "Cancelar",
|
||||
"credits.topUp.custom": "Personalizado",
|
||||
"credits.topUp.freeFeeHint": "Las recargas del plan gratuito incluyen una tarifa de servicio de {{fee}} por cada 1M de créditos.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "Procesamiento de recompensas: Los créditos se distribuirán dentro de 1 hora después de que el invitado complete un pago y pase la verificación",
|
||||
"referral.rules.title": "Reglas del Programa",
|
||||
"referral.rules.validInvitation": "Invitación válida: El invitado se registra con tu código de referencia, realiza una acción válida y completa un pago (suscripción o recarga de créditos)",
|
||||
"referral.rules.validOperation": "Criterios de acción válida: enviar un mensaje en la página de chat o generar una imagen",
|
||||
"referral.stats.availableBalance": "Saldo Disponible",
|
||||
"referral.stats.description": "Consulta tus estadísticas de referidos",
|
||||
"referral.stats.title": "Resumen de Referidos",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"management.status.archived": "Archivado",
|
||||
"management.status.completed": "Completado",
|
||||
"management.status.failed": "Fallido",
|
||||
"management.status.idle": "Inactivo",
|
||||
"management.status.paused": "Pausado",
|
||||
"management.status.running": "En ejecución",
|
||||
"management.status.waitingForHuman": "Esperando entrada",
|
||||
@@ -155,8 +154,6 @@
|
||||
"projectStatus.failed_other": "{{count}} temas fallidos",
|
||||
"projectStatus.loading_one": "{{count}} tema cargando",
|
||||
"projectStatus.loading_other": "{{count}} temas cargando",
|
||||
"projectStatus.unread_one": "{{count}} tema con respuesta no leída",
|
||||
"projectStatus.unread_other": "{{count}} temas con respuestas no leídas",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} tema esperando entrada",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} temas esperando entrada",
|
||||
"renameModal.description": "Mantenlo breve y fácil de reconocer.",
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "سلام، من **{{name}}** هستم. یک جمله کافی است.\n\nمیخواهی بهتر با جریان کاریات هماهنگ شوم؟ به [تنظیمات عامل]({{url}}) برو و نمایه عامل را پر کن (هر زمان میتوانی ویرایشش کنی).",
|
||||
"agentDefaultMessageWithSystemRole": "سلام، من **{{name}}** هستم. یک جمله کافی است—کنترل با توست.",
|
||||
"agentDefaultMessageWithoutEdit": "سلام، من **{{name}}** هستم. یک جمله کافی است—کنترل با توست.",
|
||||
"agentDocument.backToChat": "بازگشت به چت",
|
||||
"agentDocument.linkCopied": "لینک کپی شد",
|
||||
"agentDocument.openAsPage": "باز کردن به صورت صفحه کامل",
|
||||
"agentProfile.files_one": "{{count}} فایل",
|
||||
"agentProfile.files_other": "{{count}} فایل",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} پایگاهدانش",
|
||||
@@ -168,9 +165,7 @@
|
||||
"extendParams.urlContext.title": "استخراج محتوای پیوند وب",
|
||||
"followUpPlaceholder": "پیگیری. برای واگذاری وظیفه به عاملهای دیگر از @ استفاده کنید.",
|
||||
"followUpPlaceholderHeterogeneous": "پیگیری.",
|
||||
"gatewayMode.beta": "بتا",
|
||||
"gatewayMode.cardTitle": "حالت دروازه نماینده",
|
||||
"gatewayMode.desc": "اجرای نمایندگان در فضای ابری از طریق دروازه نماینده LobeHub. وظایف حتی پس از بستن صفحه ادامه مییابند.",
|
||||
"gatewayMode.title": "حالت دروازه",
|
||||
"group.desc": "با چند عامل در یک فضای مشترک، یک وظیفه را پیش ببرید.",
|
||||
"group.memberTooltip": "{{count}} عضو در گروه وجود دارد",
|
||||
"group.orchestratorThinking": "هماهنگکننده در حال تفکر است...",
|
||||
@@ -882,7 +877,6 @@
|
||||
"toolAuth.authorize": "مجازسازی",
|
||||
"toolAuth.authorizing": "در حال مجازسازی...",
|
||||
"toolAuth.hint": "بدون مجازسازی یا پیکربندی، مهارتها ممکن است کار نکنند. این میتواند باعث محدودیت یا خطا شود.",
|
||||
"toolAuth.remove": "حذف",
|
||||
"toolAuth.signIn": "ورود",
|
||||
"toolAuth.title": "مجازسازی مهارتها برای این نماینده",
|
||||
"topic.checkOpenNewTopic": "موضوع جدیدی آغاز شود؟",
|
||||
@@ -1124,5 +1118,6 @@
|
||||
"workingPanel.skills.title": "مهارتها",
|
||||
"workingPanel.space": "فضا",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "شما"
|
||||
"you": "شما",
|
||||
"zenMode": "حالت تمرکز"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,18 @@
|
||||
"fleet.allShown": "تمام وظایف در حال اجرا نمایش داده شدهاند",
|
||||
"fleet.backToHome": "بازگشت به خانه",
|
||||
"fleet.closeColumn": "بستن ستون",
|
||||
"fleet.closeIdleColumns": "بستن ستونهای بیکار",
|
||||
"fleet.closeIdleColumnsCount": "بستن {{count}} ستون بیکار",
|
||||
"fleet.collapseReply": "جمع کردن",
|
||||
"fleet.createTask": "ایجاد وظیفه",
|
||||
"fleet.dragHint": "برای تغییر ترتیب بکشید",
|
||||
"fleet.empty": "هیچ وظیفهای باز نیست",
|
||||
"fleet.emptyDesc": "یک وظیفه در حال اجرا را از سمت چپ انتخاب کنید یا از + برای افزودن ستون استفاده کنید.",
|
||||
"fleet.noRunningTasks": "هیچ وظیفهای در حال اجرا نیست",
|
||||
"fleet.openInChat": "باز کردن در چت",
|
||||
"fleet.pin": "سنجاق کردن ستون",
|
||||
"fleet.reply": "پاسخ",
|
||||
"fleet.rows.one": "یک ردیف",
|
||||
"fleet.rows.two": "دو ردیف",
|
||||
"fleet.runningBoard": "تخته در حال اجرا",
|
||||
"fleet.runningTasks": "وظایف در حال اجرا",
|
||||
"fleet.status.idle": "بیکار",
|
||||
"fleet.status.paused": "متوقف شده",
|
||||
"fleet.status.running": "در حال اجرا",
|
||||
"fleet.status.scheduled": "برنامهریزی شده",
|
||||
"fleet.tooltip": "مشاهده تمام عوامل در کنار یکدیگر",
|
||||
"fleet.unpin": "برداشتن سنجاق ستون",
|
||||
"gateway.description": "توضیحات",
|
||||
"gateway.descriptionPlaceholder": "اختیاری",
|
||||
"gateway.deviceName": "نام دستگاه",
|
||||
@@ -38,7 +30,6 @@
|
||||
"navigation.discoverMcp": "کشف MCP",
|
||||
"navigation.discoverModels": "کشف مدلها",
|
||||
"navigation.discoverProviders": "کشف ارائهدهندگان",
|
||||
"navigation.document": "سند",
|
||||
"navigation.group": "گروه",
|
||||
"navigation.groupChat": "چت گروهی",
|
||||
"navigation.home": "خانه",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "منقضی شده",
|
||||
"credits.packages.tabs.expiredCount": "منقضی شده ({{count}})",
|
||||
"credits.packages.title": "بستههای اعتباری من",
|
||||
"credits.topUp.bestValue.cta": "مشاهده سالانه نهایی",
|
||||
"credits.topUp.bestValue.savings": "در این خرید ${{savings}} صرفهجویی کنید",
|
||||
"credits.topUp.bestValue.title": "{{plan}} سالانه کمترین نرخ شارژ را باز میکند: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "لغو",
|
||||
"credits.topUp.custom": "سفارشی",
|
||||
"credits.topUp.freeFeeHint": "شارژ طرح رایگان شامل هزینه خدمات {{fee}} به ازای هر ۱ میلیون اعتبار است.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "پردازش پاداش: اعتبارها ظرف 1 ساعت پس از تکمیل پرداخت و تأیید توسط دعوتشونده توزیع میشوند.",
|
||||
"referral.rules.title": "قوانین برنامه",
|
||||
"referral.rules.validInvitation": "دعوت معتبر: دعوتشونده با کد ارجاع شما ثبتنام میکند، یک اقدام معتبر انجام میدهد و پرداخت را تکمیل میکند (اشتراک یا شارژ اعتبار).",
|
||||
"referral.rules.validOperation": "معیار اقدام معتبر: ارسال یک پیام در صفحه چت یا تولید یک تصویر در صفحه تصویر",
|
||||
"referral.stats.availableBalance": "موجودی قابل استفاده",
|
||||
"referral.stats.description": "آمار دعوتهای خود را مشاهده کنید",
|
||||
"referral.stats.title": "نمای کلی دعوتها",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"management.status.archived": "بایگانیشده",
|
||||
"management.status.completed": "تکمیلشده",
|
||||
"management.status.failed": "ناموفق",
|
||||
"management.status.idle": "بیکار",
|
||||
"management.status.paused": "متوقفشده",
|
||||
"management.status.running": "در حال اجرا",
|
||||
"management.status.waitingForHuman": "در انتظار ورودی",
|
||||
@@ -155,8 +154,6 @@
|
||||
"projectStatus.failed_other": "{{count}} موضوعات ناموفق",
|
||||
"projectStatus.loading_one": "{{count}} موضوع در حال بارگذاری",
|
||||
"projectStatus.loading_other": "{{count}} موضوعات در حال بارگذاری",
|
||||
"projectStatus.unread_one": "{{count}} موضوع با پاسخ خوانده نشده",
|
||||
"projectStatus.unread_other": "{{count}} موضوع با پاسخهای خوانده نشده",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} موضوع منتظر ورودی",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} موضوعات منتظر ورودی",
|
||||
"renameModal.description": "کوتاه و قابل تشخیص نگه دارید.",
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "Bonjour, je suis **{{name}}**. Une phrase suffit.\n\nVous souhaitez que je m’adapte mieux à votre flux de travail ? Allez dans [Paramètres de l’agent]({{url}}) et complétez le profil de l’agent (modifiable à tout moment).",
|
||||
"agentDefaultMessageWithSystemRole": "Bonjour, je suis **{{name}}**. Une phrase suffit — vous avez le contrôle.",
|
||||
"agentDefaultMessageWithoutEdit": "Bonjour, je suis **{{name}}**. Une phrase suffit — vous avez le contrôle.",
|
||||
"agentDocument.backToChat": "Retour au chat",
|
||||
"agentDocument.linkCopied": "Lien copié",
|
||||
"agentDocument.openAsPage": "Ouvrir en pleine page",
|
||||
"agentProfile.files_one": "{{count}} fichier",
|
||||
"agentProfile.files_other": "{{count}} fichiers",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} base de connaissances",
|
||||
@@ -168,9 +165,7 @@
|
||||
"extendParams.urlContext.title": "Extraire le contenu des liens web",
|
||||
"followUpPlaceholder": "Donner suite. @ pour attribuer des tâches à d’autres agents.",
|
||||
"followUpPlaceholderHeterogeneous": "Poursuivre.",
|
||||
"gatewayMode.beta": "Bêta",
|
||||
"gatewayMode.cardTitle": "Mode Passerelle Agent",
|
||||
"gatewayMode.desc": "Exécutez des agents dans le cloud via la Passerelle Agent de LobeHub. Les tâches continuent de s'exécuter même après la fermeture de la page.",
|
||||
"gatewayMode.title": "Mode Passerelle",
|
||||
"group.desc": "Faites avancer une tâche avec plusieurs agents dans un espace partagé.",
|
||||
"group.memberTooltip": "Il y a {{count}} membres dans le groupe",
|
||||
"group.orchestratorThinking": "L’orchestrateur réfléchit...",
|
||||
@@ -882,7 +877,6 @@
|
||||
"toolAuth.authorize": "Autoriser",
|
||||
"toolAuth.authorizing": "Autorisation en cours...",
|
||||
"toolAuth.hint": "Sans autorisation ou configuration, les compétences peuvent ne pas fonctionner. Cela peut limiter l'agent ou provoquer des erreurs.",
|
||||
"toolAuth.remove": "Supprimer",
|
||||
"toolAuth.signIn": "Se connecter",
|
||||
"toolAuth.title": "Autoriser les compétences pour cet agent",
|
||||
"topic.checkOpenNewTopic": "Commencer un nouveau sujet ?",
|
||||
@@ -1124,5 +1118,6 @@
|
||||
"workingPanel.skills.title": "Compétences",
|
||||
"workingPanel.space": "Espace",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "Vous"
|
||||
"you": "Vous",
|
||||
"zenMode": "Mode Zen"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,18 @@
|
||||
"fleet.allShown": "Toutes les tâches en cours sont affichées",
|
||||
"fleet.backToHome": "Retour à l'accueil",
|
||||
"fleet.closeColumn": "Fermer la colonne",
|
||||
"fleet.closeIdleColumns": "Fermer les colonnes inactives",
|
||||
"fleet.closeIdleColumnsCount": "Fermer {{count}} colonnes inactives",
|
||||
"fleet.collapseReply": "Réduire",
|
||||
"fleet.createTask": "Créer une tâche",
|
||||
"fleet.dragHint": "Glisser pour réorganiser",
|
||||
"fleet.empty": "Aucune tâche ouverte",
|
||||
"fleet.emptyDesc": "Choisissez une tâche en cours à gauche, ou utilisez + pour ajouter une colonne.",
|
||||
"fleet.noRunningTasks": "Aucune tâche en cours",
|
||||
"fleet.openInChat": "Ouvrir dans le chat",
|
||||
"fleet.pin": "Épingler la colonne",
|
||||
"fleet.reply": "Répondre",
|
||||
"fleet.rows.one": "Une seule ligne",
|
||||
"fleet.rows.two": "Deux lignes",
|
||||
"fleet.runningBoard": "Tableau de bord en cours",
|
||||
"fleet.runningTasks": "Tâches en cours",
|
||||
"fleet.status.idle": "Inactif",
|
||||
"fleet.status.paused": "En pause",
|
||||
"fleet.status.running": "En cours",
|
||||
"fleet.status.scheduled": "Planifié",
|
||||
"fleet.tooltip": "Voir tous les agents côte à côte",
|
||||
"fleet.unpin": "Désépingler la colonne",
|
||||
"gateway.description": "Description",
|
||||
"gateway.descriptionPlaceholder": "Optionnel",
|
||||
"gateway.deviceName": "Nom de l'appareil",
|
||||
@@ -38,7 +30,6 @@
|
||||
"navigation.discoverMcp": "Découvrir MCP",
|
||||
"navigation.discoverModels": "Découvrir les Modèles",
|
||||
"navigation.discoverProviders": "Découvrir les Fournisseurs",
|
||||
"navigation.document": "Document",
|
||||
"navigation.group": "Groupe",
|
||||
"navigation.groupChat": "Chat de Groupe",
|
||||
"navigation.home": "Accueil",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "Expirés",
|
||||
"credits.packages.tabs.expiredCount": "Expirés ({{count}})",
|
||||
"credits.packages.title": "Mes forfaits de crédits",
|
||||
"credits.topUp.bestValue.cta": "Voir l'abonnement annuel Ultimate",
|
||||
"credits.topUp.bestValue.savings": "Économisez ${{savings}} sur cet achat",
|
||||
"credits.topUp.bestValue.title": "L'abonnement annuel {{plan}} offre le tarif de recharge le plus bas : ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "Annuler",
|
||||
"credits.topUp.custom": "Personnalisé",
|
||||
"credits.topUp.freeFeeHint": "Les rechargements du plan gratuit incluent des frais de service de {{fee}} par 1M de crédits.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "Traitement des récompenses : Les crédits seront distribués dans l'heure suivant le paiement et la vérification de l'invité.",
|
||||
"referral.rules.title": "Règles du programme",
|
||||
"referral.rules.validInvitation": "Invitation valide : L'invité s'inscrit avec votre code de parrainage, effectue une action valide et finalise un paiement (abonnement ou recharge de crédits).",
|
||||
"referral.rules.validOperation": "Critères d’action valide : envoyer un message ou générer une image",
|
||||
"referral.stats.availableBalance": "Solde disponible",
|
||||
"referral.stats.description": "Consultez vos statistiques de parrainage",
|
||||
"referral.stats.title": "Aperçu du parrainage",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"management.status.archived": "Archivé",
|
||||
"management.status.completed": "Terminé",
|
||||
"management.status.failed": "Échoué",
|
||||
"management.status.idle": "Inactif",
|
||||
"management.status.paused": "En pause",
|
||||
"management.status.running": "En cours",
|
||||
"management.status.waitingForHuman": "En attente d'une intervention",
|
||||
@@ -155,8 +154,6 @@
|
||||
"projectStatus.failed_other": "{{count}} sujets échoués",
|
||||
"projectStatus.loading_one": "{{count}} sujet en cours de chargement",
|
||||
"projectStatus.loading_other": "{{count}} sujets en cours de chargement",
|
||||
"projectStatus.unread_one": "{{count}} sujet avec une réponse non lue",
|
||||
"projectStatus.unread_other": "{{count}} sujets avec des réponses non lues",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} sujet en attente d'entrée",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} sujets en attente d'entrée",
|
||||
"renameModal.description": "Gardez-le court et facile à reconnaître.",
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "Ciao, sono **{{name}}**. Una frase è sufficiente.\n\nVuoi che mi adatti meglio al tuo flusso di lavoro? Vai su [Impostazioni Agente]({{url}}) e compila il Profilo Agente (puoi modificarlo in qualsiasi momento).",
|
||||
"agentDefaultMessageWithSystemRole": "Ciao, sono **{{name}}**. Una frase è sufficiente—sei tu al comando.",
|
||||
"agentDefaultMessageWithoutEdit": "Ciao, sono **{{name}}**. Una frase è sufficiente—sei tu al comando.",
|
||||
"agentDocument.backToChat": "Torna alla chat",
|
||||
"agentDocument.linkCopied": "Link copiato",
|
||||
"agentDocument.openAsPage": "Apri come pagina intera",
|
||||
"agentProfile.files_one": "{{count}} file",
|
||||
"agentProfile.files_other": "{{count}} file",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} base di conoscenza",
|
||||
@@ -168,9 +165,7 @@
|
||||
"extendParams.urlContext.title": "Estrai Contenuto da Link Web",
|
||||
"followUpPlaceholder": "Follow-up. Usa @ per assegnare attività ad altri agenti.",
|
||||
"followUpPlaceholderHeterogeneous": "Continua.",
|
||||
"gatewayMode.beta": "Beta",
|
||||
"gatewayMode.cardTitle": "Modalità Gateway Agente",
|
||||
"gatewayMode.desc": "Esegui agenti nel cloud tramite l'Agent Gateway di LobeHub. Le attività continuano a funzionare anche dopo aver chiuso la pagina.",
|
||||
"gatewayMode.title": "Modalità Gateway",
|
||||
"group.desc": "Fai avanzare un'attività con più Agenti in uno spazio condiviso.",
|
||||
"group.memberTooltip": "Ci sono {{count}} membri nel gruppo",
|
||||
"group.orchestratorThinking": "L'Orchestratore sta pensando...",
|
||||
@@ -882,7 +877,6 @@
|
||||
"toolAuth.authorize": "Autorizza",
|
||||
"toolAuth.authorizing": "Autorizzazione in corso...",
|
||||
"toolAuth.hint": "Senza autorizzazione o configurazione, le Skill potrebbero non funzionare. Questo può limitare l'agente o causare errori.",
|
||||
"toolAuth.remove": "Rimuovi",
|
||||
"toolAuth.signIn": "Accedi",
|
||||
"toolAuth.title": "Autorizza le Skill per questo agente",
|
||||
"topic.checkOpenNewTopic": "Iniziare un nuovo argomento?",
|
||||
@@ -1124,5 +1118,6 @@
|
||||
"workingPanel.skills.title": "Competenze",
|
||||
"workingPanel.space": "Spazio",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "Tu"
|
||||
"you": "Tu",
|
||||
"zenMode": "Modalità Zen"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,18 @@
|
||||
"fleet.allShown": "Tutti i compiti in esecuzione sono mostrati",
|
||||
"fleet.backToHome": "Torna alla home",
|
||||
"fleet.closeColumn": "Chiudi colonna",
|
||||
"fleet.closeIdleColumns": "Chiudi colonne inattive",
|
||||
"fleet.closeIdleColumnsCount": "Chiudi {{count}} colonne inattive",
|
||||
"fleet.collapseReply": "Comprimi",
|
||||
"fleet.createTask": "Crea compito",
|
||||
"fleet.dragHint": "Trascina per riordinare",
|
||||
"fleet.empty": "Nessun compito aperto",
|
||||
"fleet.emptyDesc": "Seleziona un compito in esecuzione a sinistra o usa + per aggiungere una colonna.",
|
||||
"fleet.noRunningTasks": "Nessun compito in esecuzione",
|
||||
"fleet.openInChat": "Apri nella chat",
|
||||
"fleet.pin": "Fissa colonna",
|
||||
"fleet.reply": "Rispondi",
|
||||
"fleet.rows.one": "Singola riga",
|
||||
"fleet.rows.two": "Due righe",
|
||||
"fleet.runningBoard": "Pannello in esecuzione",
|
||||
"fleet.runningTasks": "Compiti in esecuzione",
|
||||
"fleet.status.idle": "Inattivo",
|
||||
"fleet.status.paused": "In pausa",
|
||||
"fleet.status.running": "In esecuzione",
|
||||
"fleet.status.scheduled": "Programmato",
|
||||
"fleet.tooltip": "Visualizza tutti gli agenti fianco a fianco",
|
||||
"fleet.unpin": "Sblocca colonna",
|
||||
"gateway.description": "Descrizione",
|
||||
"gateway.descriptionPlaceholder": "Facoltativo",
|
||||
"gateway.deviceName": "Nome Dispositivo",
|
||||
@@ -38,7 +30,6 @@
|
||||
"navigation.discoverMcp": "Scopri MCP",
|
||||
"navigation.discoverModels": "Scopri Modelli",
|
||||
"navigation.discoverProviders": "Scopri Provider",
|
||||
"navigation.document": "Documento",
|
||||
"navigation.group": "Gruppo",
|
||||
"navigation.groupChat": "Chat di Gruppo",
|
||||
"navigation.home": "Home",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "Scaduti",
|
||||
"credits.packages.tabs.expiredCount": "Scaduti ({{count}})",
|
||||
"credits.packages.title": "I Miei Pacchetti Crediti",
|
||||
"credits.topUp.bestValue.cta": "Visualizza Ultimate annuale",
|
||||
"credits.topUp.bestValue.savings": "Risparmia ${{savings}} su questo acquisto",
|
||||
"credits.topUp.bestValue.title": "{{plan}} annuale sblocca la tariffa di ricarica più bassa: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "Annulla",
|
||||
"credits.topUp.custom": "Personalizzato",
|
||||
"credits.topUp.freeFeeHint": "Le ricariche del piano gratuito includono una commissione di servizio di {{fee}} per 1M di crediti.",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "Elaborazione delle ricompense: I crediti saranno distribuiti entro 1 ora dopo che l'invitato completa un pagamento e supera la verifica",
|
||||
"referral.rules.title": "Regole del Programma",
|
||||
"referral.rules.validInvitation": "Invito valido: L'invitato si registra con il tuo codice di riferimento, esegue un'azione valida e completa un pagamento (abbonamento o ricarica crediti)",
|
||||
"referral.rules.validOperation": "Criteri di azione valida: invia un messaggio nella pagina Chat o genera un'immagine nella pagina immagini",
|
||||
"referral.stats.availableBalance": "Saldo Disponibile",
|
||||
"referral.stats.description": "Visualizza le tue statistiche di invito",
|
||||
"referral.stats.title": "Panoramica Inviti",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"management.status.archived": "Archiviato",
|
||||
"management.status.completed": "Completato",
|
||||
"management.status.failed": "Fallito",
|
||||
"management.status.idle": "Inattivo",
|
||||
"management.status.paused": "In pausa",
|
||||
"management.status.running": "In esecuzione",
|
||||
"management.status.waitingForHuman": "In attesa di input",
|
||||
@@ -155,8 +154,6 @@
|
||||
"projectStatus.failed_other": "{{count}} argomenti falliti",
|
||||
"projectStatus.loading_one": "{{count}} argomento in caricamento",
|
||||
"projectStatus.loading_other": "{{count}} argomenti in caricamento",
|
||||
"projectStatus.unread_one": "{{count}} argomento con risposta non letta",
|
||||
"projectStatus.unread_other": "{{count}} argomenti con risposte non lette",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} argomento in attesa di input",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} argomenti in attesa di input",
|
||||
"renameModal.description": "Mantienilo breve e facile da riconoscere.",
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
"agentDefaultMessage": "こんにちは、私は **{{name}}** です。一文から始めましょう。\n\nよりあなたの働き方に合わせるには:[アシスタント設定]({{url}}) でアシスタントプロフィールを補完してください(いつでも変更可能)",
|
||||
"agentDefaultMessageWithSystemRole": "こんにちは、私は **{{name}}** です。一文から始めましょう—判断はあなたにあります",
|
||||
"agentDefaultMessageWithoutEdit": "こんにちは、私は **{{name}}** です。一文から始めましょう—判断はあなたにあります",
|
||||
"agentDocument.backToChat": "チャットに戻る",
|
||||
"agentDocument.linkCopied": "リンクがコピーされました",
|
||||
"agentDocument.openAsPage": "全画面で開く",
|
||||
"agentProfile.files_one": "{{count}} 件のファイル",
|
||||
"agentProfile.files_other": "{{count}} 件のファイル",
|
||||
"agentProfile.knowledgeBases_one": "{{count}} 件のナレッジベース",
|
||||
@@ -168,9 +165,7 @@
|
||||
"extendParams.urlContext.title": "ウェブリンクコンテンツの抽出",
|
||||
"followUpPlaceholder": "フォローアップ。@で他のエージェントにタスクを割り当てできます。",
|
||||
"followUpPlaceholderHeterogeneous": "フォローアップ。",
|
||||
"gatewayMode.beta": "ベータ版",
|
||||
"gatewayMode.cardTitle": "エージェントゲートウェイモード",
|
||||
"gatewayMode.desc": "LobeHubのエージェントゲートウェイを通じてクラウドでエージェントを実行します。ページを閉じた後もタスクは実行され続けます。",
|
||||
"gatewayMode.title": "ゲートウェイモード",
|
||||
"group.desc": "同一の対話空間で、複数のアシスタントが一緒にタスクを推進します",
|
||||
"group.memberTooltip": "グループに {{count}} 名のメンバーがいます",
|
||||
"group.orchestratorThinking": "ホストが思考中…",
|
||||
@@ -882,7 +877,6 @@
|
||||
"toolAuth.authorize": "承認",
|
||||
"toolAuth.authorizing": "承認中…",
|
||||
"toolAuth.hint": "未承認または未設定の場合、関連スキルは使用できません。これによりアシスタントの機能が制限されたりエラーが発生したりする可能性があります",
|
||||
"toolAuth.remove": "削除",
|
||||
"toolAuth.signIn": "ログイン",
|
||||
"toolAuth.title": "アシスタントのスキル承認を完了してください",
|
||||
"topic.checkOpenNewTopic": "新しいトピックを開きますか?",
|
||||
@@ -1124,5 +1118,6 @@
|
||||
"workingPanel.skills.title": "スキル",
|
||||
"workingPanel.space": "スペース",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"you": "あなた"
|
||||
"you": "あなた",
|
||||
"zenMode": "集中モード"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,18 @@
|
||||
"fleet.allShown": "すべての実行中のタスクが表示されています",
|
||||
"fleet.backToHome": "ホームに戻る",
|
||||
"fleet.closeColumn": "列を閉じる",
|
||||
"fleet.closeIdleColumns": "アイドル状態の列を閉じる",
|
||||
"fleet.closeIdleColumnsCount": "{{count}} 個のアイドル状態の列を閉じる",
|
||||
"fleet.collapseReply": "折りたたむ",
|
||||
"fleet.createTask": "タスクを作成",
|
||||
"fleet.dragHint": "ドラッグして並べ替え",
|
||||
"fleet.empty": "開いているタスクはありません",
|
||||
"fleet.emptyDesc": "左側の実行中のタスクを選択するか、+を使用して列を追加してください。",
|
||||
"fleet.noRunningTasks": "実行中のタスクはありません",
|
||||
"fleet.openInChat": "チャットで開く",
|
||||
"fleet.pin": "列を固定",
|
||||
"fleet.reply": "返信",
|
||||
"fleet.rows.one": "1行",
|
||||
"fleet.rows.two": "2行",
|
||||
"fleet.runningBoard": "ランニングボード",
|
||||
"fleet.runningTasks": "実行中のタスク",
|
||||
"fleet.status.idle": "待機中",
|
||||
"fleet.status.paused": "一時停止中",
|
||||
"fleet.status.running": "実行中",
|
||||
"fleet.status.scheduled": "スケジュール済み",
|
||||
"fleet.tooltip": "すべてのエージェントを並べて表示",
|
||||
"fleet.unpin": "列の固定を解除",
|
||||
"gateway.description": "説明",
|
||||
"gateway.descriptionPlaceholder": "任意",
|
||||
"gateway.deviceName": "デバイス名",
|
||||
@@ -38,7 +30,6 @@
|
||||
"navigation.discoverMcp": "MCP を発見",
|
||||
"navigation.discoverModels": "モデルを発見",
|
||||
"navigation.discoverProviders": "プロバイダーを発見",
|
||||
"navigation.document": "ドキュメント",
|
||||
"navigation.group": "グループ",
|
||||
"navigation.groupChat": "グループチャット",
|
||||
"navigation.home": "ホーム",
|
||||
|
||||
@@ -89,9 +89,6 @@
|
||||
"credits.packages.tabs.expired": "期限切れ",
|
||||
"credits.packages.tabs.expiredCount": "期限切れ({{count}})",
|
||||
"credits.packages.title": "マイクレジットパッケージ",
|
||||
"credits.topUp.bestValue.cta": "究極の年間プランを見る",
|
||||
"credits.topUp.bestValue.savings": "この購入で${{savings}}節約",
|
||||
"credits.topUp.bestValue.title": "{{plan}}年間プランで最安のチャージレートを解放: ${{price}} / 1M {{creditLabel}}",
|
||||
"credits.topUp.cancel": "キャンセル",
|
||||
"credits.topUp.custom": "カスタム",
|
||||
"credits.topUp.freeFeeHint": "無料プランのチャージには、1Mクレジットごとに{{fee}}のサービス料が含まれます。",
|
||||
@@ -420,6 +417,7 @@
|
||||
"referral.rules.rewardDelay": "報酬処理: 招待者が支払いを完了し、認証を通過した後、クレジットは1時間以内に配布されます。",
|
||||
"referral.rules.title": "プログラムルール",
|
||||
"referral.rules.validInvitation": "有効な招待: 招待者があなたの招待コードで登録し、有効なアクションを1つ実行し、支払い(サブスクリプションまたはクレジット追加)を完了すること。",
|
||||
"referral.rules.validOperation": "有効な操作の条件:チャットページで1回メッセージ送信、または画像ページで1枚生成",
|
||||
"referral.stats.availableBalance": "利用可能残高",
|
||||
"referral.stats.description": "紹介統計を確認",
|
||||
"referral.stats.title": "紹介概要",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user