mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 14:20:27 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65cbfc8491 | |||
| 2487407192 | |||
| 6de1e14a4d | |||
| db2a62d704 | |||
| e914e98369 | |||
| 825cfc2189 | |||
| 4386a42b92 | |||
| c316279606 | |||
| c17dc415ed | |||
| 6b87a141b6 | |||
| b569b3e53b | |||
| ef09457e63 | |||
| 44ebb77365 | |||
| a97e331727 | |||
| 63b52522d0 | |||
| ba5571cb4a | |||
| ce2e517be9 | |||
| 9e9ab1f05d | |||
| 6c62349339 | |||
| 3beafb20c6 | |||
| b2ae69ac11 | |||
| ec40f7e405 | |||
| 8b9fd761f6 | |||
| 5dea768397 | |||
| deb4bd6a3c | |||
| 415fdd02eb | |||
| 815901efa0 | |||
| a6f816f9bd | |||
| 4e96552102 | |||
| 80cce10dd5 | |||
| d28e976ac2 | |||
| f22185716a | |||
| 702e2aa15d | |||
| b917a81c77 | |||
| 32eaab9537 | |||
| a976cd52c9 | |||
| ce274593c2 |
@@ -38,7 +38,7 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
|
||||
|
||||
## Default Debug Order
|
||||
|
||||
1. Prove whether the raw CLI output is correct before touching UI code.
|
||||
1. Prove whether the raw CLI output is correct before touching UI code. The app records every real session — read the most recent one via `cat .heerogeneous-tracing/.last-live-trace` rather than hand-rolling a `claude -p` repro (see references/debug-workflow\.md §2).
|
||||
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
|
||||
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
|
||||
4. Turn the repro into a focused test before fixing.
|
||||
@@ -77,6 +77,10 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
|
||||
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
|
||||
- Subagent tools show up in the main bubble:
|
||||
check for subagent chunks reaching the main gateway handler.
|
||||
- Wrong terminal-error guide (e.g. "usage limit reached" shown for a network drop):
|
||||
a classifier is branching on a structured field whose mere presence isn't its meaning.
|
||||
Grep the field across all event states in a real trace before trusting it — see
|
||||
references/debug-workflow\.md §8 (CC `rate_limit_info` rides on `status: "allowed"` too).
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
## Contents
|
||||
|
||||
1. Pipeline map
|
||||
2. Capture raw CLI traces first
|
||||
2. Capture raw CLI traces first (incl. in-app live traces)
|
||||
3. Compare raw and adapted events
|
||||
4. Check step boundaries before persistence
|
||||
5. Check tool persistence invariants
|
||||
6. Focused tests
|
||||
7. Repro-to-fix workflow
|
||||
8. Verify a structured-field classifier against a real trace
|
||||
|
||||
## 1. Pipeline Map
|
||||
|
||||
@@ -27,6 +28,54 @@ Start at the leftmost broken layer. Do not jump straight to UI rendering unless
|
||||
|
||||
## 2. Capture Raw CLI Traces First
|
||||
|
||||
### In-app live traces (the faithful capture — prefer this)
|
||||
|
||||
The running app already records every CLI session it spawns. This is the most
|
||||
faithful trace you can get, because it captures the **exact** spawn args, env
|
||||
keys, cwd, `--resume`/`--mcp-config` flags, model, and stdin that the app used —
|
||||
things a hand-rolled `claude -p` / `codex exec` repro will not reproduce. Reach
|
||||
for this before reproducing manually. The recorder lives in
|
||||
`apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
|
||||
(`createCliTraceSession`, `shouldTraceCliOutput`, `resolveTraceRootDir`).
|
||||
|
||||
When it records:
|
||||
|
||||
- **Dev build** (`!app.isPackaged`): always.
|
||||
- **Packaged build**: only when the user flips the Help-menu developer toggle
|
||||
(`heteroTracingEnabled`). Off by default so normal runs aren't polluted.
|
||||
- Never under `NODE_ENV=test`.
|
||||
|
||||
Where it writes:
|
||||
|
||||
- Toggle **off** (plain dev run): `<cwd>/.heerogeneous-tracing/` — i.e. inside
|
||||
the repo you're running against. (Yes, the dir name is misspelled
|
||||
`heerogeneous`; it is the real path.)
|
||||
- Toggle **on**: `<appStoragePath>/heteroAgent/tracing/` — keeps traces out of
|
||||
the user's project. This is the only path packaged builds ever use.
|
||||
|
||||
Layout per session — `.../<agentType>/<YYYYMMDD-HHMMSS>-<sessionId>/`:
|
||||
|
||||
- `meta.json` — spawn `args`, `command`, `cwd`, `envKeys`, `model`,
|
||||
`resumeSessionId`/`agentSessionId`, attachment summaries. **Read this first**
|
||||
to know exactly how the CLI was invoked.
|
||||
- `stdin.txt` — the stream-json request fed to the CLI.
|
||||
- `stdout.jsonl` — the raw provider NDJSON (the trace you actually read).
|
||||
- `stderr.log` — CLI stderr.
|
||||
- `exit.json` — `{ code, signal, finishedAt }`.
|
||||
|
||||
`.heerogeneous-tracing/.last-live-trace` always points at the most recent
|
||||
session dir, so the fast path to "what just happened" is:
|
||||
|
||||
```bash
|
||||
dir=$(cat .heerogeneous-tracing/.last-live-trace)
|
||||
cat "$dir/meta.json" # how the CLI was spawned
|
||||
wc -l "$dir/stdout.jsonl" # raw event count
|
||||
```
|
||||
|
||||
Reproduce the same session yourself by reusing the recorded `meta.json` `args`
|
||||
together with `stdin.txt` (the args already include `--resume <sessionId>`),
|
||||
instead of guessing flags.
|
||||
|
||||
### Codex raw JSONL
|
||||
|
||||
Use a read-only prompt and save traces under the repo-local scratch directory `.heerogeneous-tracing/`.
|
||||
@@ -244,3 +293,55 @@ When the bug comes from a real trace, distill it into the closest existing test
|
||||
6. Only then do an Electron smoke test with the `agent-testing` skill if UI confirmation is still needed.
|
||||
|
||||
Do not start with a broad Electron repro if a raw trace or adapter test can prove the fault zone faster.
|
||||
|
||||
## 8. Verify A Structured-Field Classifier Against A Real Trace
|
||||
|
||||
Whenever the adapter **branches on a structured field** from the raw stream —
|
||||
`status`, `usage`, `rateLimitType`, `stop_reason`, `parent_tool_use_id`,
|
||||
`subtype`, etc. — do not trust your mental model of the wire format. The field
|
||||
you key on almost always also appears on **benign / non-target** events, and a
|
||||
classifier that ignores the surrounding state will misfire on those.
|
||||
|
||||
The procedure (recurring — run it every time):
|
||||
|
||||
1. Pull the most recent real session: `dir=$(cat .heerogeneous-tracing/.last-live-trace)`.
|
||||
|
||||
2. Grep the field across **every** event state, not just the failing one, and
|
||||
count by co-occurring state. Example:
|
||||
|
||||
```bash
|
||||
# Which event statuses carry a rate_limit_info block?
|
||||
grep -o '"status":"[a-z]*"' "$dir/stdout.jsonl" | sort | uniq -c
|
||||
grep -c 'rate_limit_info' "$dir/stdout.jsonl"
|
||||
```
|
||||
|
||||
3. If the field rides on states you did not account for, the classifier needs an
|
||||
extra gate. Add the trace as a fixture/assertion to the adapter test so the
|
||||
regression can't come back.
|
||||
|
||||
### Worked example: CC usage-limit vs. transient throttle (`fix/cc-rate-limit-quota-misclassify`)
|
||||
|
||||
- **Symptom:** an unrelated terminal failure (e.g. an `ECONNRESET` network drop)
|
||||
rendered a bogus "usage limit reached, resets at X" guide.
|
||||
- **What the trace showed:** Anthropic stamps a `rate_limit_info` block —
|
||||
carrying `resetsAt` and `rateLimitType` (e.g. `seven_day`) — onto events even
|
||||
when the request **goes through** (`status: "allowed"`). In real traces those
|
||||
reset-window fields appear on \~all `rate_limit_info` blocks, the vast majority
|
||||
of which are `allowed`, not `rejected`. So the window is rolling-window
|
||||
_metadata for an allowed call_, NOT evidence the limit was hit.
|
||||
- **The bug:** `isUserQuotaRateLimit` keyed only on the presence of a reset
|
||||
window (`info.resetsAt != null || info.rateLimitType != null`). A later
|
||||
terminal error inherited the last allowed event's window → false positive.
|
||||
- **The fix:** require `status === 'rejected'` **and** a concrete reset window.
|
||||
A bare `rejected` with no window is the transient server throttle → leave it
|
||||
to the overloaded (retry) classifier. Status codes (429 / 529) and message
|
||||
text are deliberately not consulted — only this structured signal decides the
|
||||
guide.
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` →
|
||||
`isUserQuotaRateLimit`
|
||||
- regression assertions in
|
||||
`packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
|
||||
|
||||
The general lesson: a field's **presence** is not its **meaning**. Confirm which
|
||||
event states a discriminator field co-occurs with in a real recorded trace
|
||||
before branching on it.
|
||||
|
||||
@@ -18,8 +18,8 @@ Periodic review of the project-local skill set under `.agents/skills/`. The goal
|
||||
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
|
||||
|
||||
```bash
|
||||
find .agents/skills -name SKILL.md | wc -l # total count
|
||||
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
|
||||
find -L .agents/skills -name SKILL.md | wc -l # total count, including symlinked skills
|
||||
find -L .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length, including symlinked skills
|
||||
```
|
||||
|
||||
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.31",
|
||||
"version": "0.0.32",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -37,6 +37,7 @@
|
||||
"@lobechat/tool-runtime": "workspace:*",
|
||||
"@trpc/client": "^11.8.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"commander": "^13.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
@@ -45,6 +46,7 @@
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.5",
|
||||
"picocolors": "^1.1.1",
|
||||
"semver": "^7.7.3",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -440,6 +440,25 @@ describe('connect command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect (alias for connect stop)', () => {
|
||||
it('should stop running daemon', async () => {
|
||||
mockRunningPid = 12345;
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'disconnect']);
|
||||
|
||||
expect(stopDaemon).toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
|
||||
});
|
||||
|
||||
it('should warn if no daemon is running', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'disconnect']);
|
||||
|
||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('connect status', () => {
|
||||
it('should show no daemon running', async () => {
|
||||
const program = createProgram();
|
||||
|
||||
@@ -74,17 +74,7 @@ export function registerConnectCommand(program: Command) {
|
||||
});
|
||||
|
||||
// Subcommands
|
||||
connectCmd
|
||||
.command('stop')
|
||||
.description('Stop the background daemon process')
|
||||
.action(() => {
|
||||
const stopped = stopDaemon();
|
||||
if (stopped) {
|
||||
log.info('Daemon stopped.');
|
||||
} else {
|
||||
log.warn('No daemon is running.');
|
||||
}
|
||||
});
|
||||
connectCmd.command('stop').description('Stop the background daemon process').action(handleStop);
|
||||
|
||||
connectCmd
|
||||
.command('status')
|
||||
@@ -148,10 +138,27 @@ export function registerConnectCommand(program: Command) {
|
||||
}
|
||||
handleDaemonStart({ ...options, daemon: true });
|
||||
});
|
||||
|
||||
// Top-level alias for `connect stop`. Users who run `lh connect` naturally
|
||||
// reach for `lh disconnect` to undo it; the nested `connect stop` is not
|
||||
// discoverable enough on its own.
|
||||
program
|
||||
.command('disconnect')
|
||||
.description('Disconnect from the device gateway (alias for `connect stop`)')
|
||||
.action(handleStop);
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
function handleStop() {
|
||||
const stopped = stopDaemon();
|
||||
if (stopped) {
|
||||
log.info('Daemon stopped.');
|
||||
} else {
|
||||
log.warn('No daemon is running.');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDaemonStart(options: ConnectOptions) {
|
||||
const existingPid = getRunningDaemonPid();
|
||||
if (existingPid !== null) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearCredentials } from '../auth/credentials';
|
||||
import { stopDaemon } from '../daemon/manager';
|
||||
import { log } from '../utils/logger';
|
||||
import { registerLogoutCommand } from './logout';
|
||||
|
||||
@@ -9,6 +10,10 @@ vi.mock('../auth/credentials', () => ({
|
||||
clearCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../daemon/manager', () => ({
|
||||
stopDaemon: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
@@ -19,6 +24,11 @@ vi.mock('../utils/logger', () => ({
|
||||
}));
|
||||
|
||||
describe('logout command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(stopDaemon).mockReturnValue(false);
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
@@ -44,4 +54,24 @@ describe('logout command', () => {
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
|
||||
});
|
||||
|
||||
it('should stop the connect daemon before clearing credentials', async () => {
|
||||
vi.mocked(stopDaemon).mockReturnValue(true);
|
||||
vi.mocked(clearCredentials).mockReturnValue(true);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'logout']);
|
||||
|
||||
expect(stopDaemon).toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Disconnected device daemon'));
|
||||
});
|
||||
|
||||
it('should still attempt daemon teardown when no credentials exist', async () => {
|
||||
vi.mocked(clearCredentials).mockReturnValue(false);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'logout']);
|
||||
|
||||
expect(stopDaemon).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { clearCredentials } from '../auth/credentials';
|
||||
import { stopDaemon } from '../daemon/manager';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerLogoutCommand(program: Command) {
|
||||
@@ -8,6 +9,14 @@ export function registerLogoutCommand(program: Command) {
|
||||
.command('logout')
|
||||
.description('Log out and remove stored credentials')
|
||||
.action(() => {
|
||||
// Tear down the connect daemon first — otherwise it keeps the device
|
||||
// online on the gateway with the cached token even after credentials are
|
||||
// gone, leaving the machine remotely driveable past "logout".
|
||||
const stopped = stopDaemon();
|
||||
if (stopped) {
|
||||
log.info('Disconnected device daemon.');
|
||||
}
|
||||
|
||||
const removed = clearCredentials();
|
||||
if (removed) {
|
||||
log.info('Logged out. Credentials removed.');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildInstallCommand, isNewerVersion } from './update';
|
||||
|
||||
describe('isNewerVersion', () => {
|
||||
it('compares core versions', () => {
|
||||
expect(isNewerVersion('1.2.3', '1.2.2')).toBe(true);
|
||||
expect(isNewerVersion('1.2.2', '1.2.3')).toBe(false);
|
||||
expect(isNewerVersion('1.2.3', '1.2.3')).toBe(false);
|
||||
expect(isNewerVersion('2.0.0', '1.9.9')).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates a leading v and missing segments', () => {
|
||||
expect(isNewerVersion('v1.2.0', '1.2.0')).toBe(false);
|
||||
expect(isNewerVersion('1.2', '1.2.0')).toBe(false);
|
||||
expect(isNewerVersion('1.3', '1.2.9')).toBe(true);
|
||||
});
|
||||
|
||||
it('ranks a stable release above a prerelease of the same core', () => {
|
||||
expect(isNewerVersion('1.2.3', '1.2.3-beta.1')).toBe(true);
|
||||
expect(isNewerVersion('1.2.3-beta.1', '1.2.3')).toBe(false);
|
||||
expect(isNewerVersion('1.2.3-beta.2', '1.2.3-beta.1')).toBe(true);
|
||||
expect(isNewerVersion('1.2.3-beta.1', '1.2.3-beta.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('orders numeric prerelease identifiers numerically, not lexicographically', () => {
|
||||
// The bug a raw string compare gets wrong: beta.10 must outrank beta.9.
|
||||
expect(isNewerVersion('1.0.0-beta.10', '1.0.0-beta.9')).toBe(true);
|
||||
expect(isNewerVersion('1.0.0-beta.9', '1.0.0-beta.10')).toBe(false);
|
||||
expect(isNewerVersion('1.0.0-beta.2', '1.0.0-beta.10')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unparseable latest version', () => {
|
||||
expect(isNewerVersion('not-a-version', '1.0.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInstallCommand', () => {
|
||||
it('builds the global install command per package manager', () => {
|
||||
expect(buildInstallCommand('npm', '@lobehub/cli@1.0.0')).toEqual({
|
||||
args: ['install', '-g', '@lobehub/cli@1.0.0'],
|
||||
command: 'npm',
|
||||
});
|
||||
expect(buildInstallCommand('pnpm', '@lobehub/cli@1.0.0')).toEqual({
|
||||
args: ['add', '-g', '@lobehub/cli@1.0.0'],
|
||||
command: 'pnpm',
|
||||
});
|
||||
expect(buildInstallCommand('bun', '@lobehub/cli@1.0.0')).toEqual({
|
||||
args: ['add', '-g', '@lobehub/cli@1.0.0'],
|
||||
command: 'bun',
|
||||
});
|
||||
expect(buildInstallCommand('yarn', '@lobehub/cli@1.0.0')).toEqual({
|
||||
args: ['global', 'add', '@lobehub/cli@1.0.0'],
|
||||
command: 'yarn',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { realpathSync } from 'node:fs';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
import semver from 'semver';
|
||||
|
||||
// Pull package metadata from the shared `src/pkg.ts` module (resolved at the
|
||||
// bundled entry's depth) rather than a local `require('../../package.json')`,
|
||||
// which would point outside the package once bundled into dist/index.js.
|
||||
import { cliPackageName, cliVersion } from '../pkg';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
||||
|
||||
const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
|
||||
|
||||
interface UpdateOptions {
|
||||
check?: boolean;
|
||||
packageManager?: PackageManager;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which package manager installed the CLI so we run the matching global
|
||||
* upgrade command. We first trust an explicit `npm_config_user_agent` (set when
|
||||
* invoked through a package-manager script) and otherwise infer from the path of
|
||||
* the running binary. Falls back to npm.
|
||||
*/
|
||||
export function detectPackageManager(): PackageManager {
|
||||
const ua = process.env.npm_config_user_agent;
|
||||
if (ua) {
|
||||
if (ua.startsWith('pnpm')) return 'pnpm';
|
||||
if (ua.startsWith('yarn')) return 'yarn';
|
||||
if (ua.startsWith('bun')) return 'bun';
|
||||
if (ua.startsWith('npm')) return 'npm';
|
||||
}
|
||||
|
||||
try {
|
||||
const binPath = realpathSync(process.argv[1] ?? '').replaceAll('\\', '/');
|
||||
if (binPath.includes('/pnpm/')) return 'pnpm';
|
||||
if (binPath.includes('/.bun/') || binPath.includes('/bun/')) return 'bun';
|
||||
if (binPath.includes('/yarn/') || binPath.includes('/.yarn/')) return 'yarn';
|
||||
} catch {
|
||||
// ignore – fall back to npm
|
||||
}
|
||||
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
/** Build the global-install command for the detected package manager. */
|
||||
export function buildInstallCommand(
|
||||
pm: PackageManager,
|
||||
spec: string,
|
||||
): { args: string[]; command: string } {
|
||||
switch (pm) {
|
||||
case 'pnpm': {
|
||||
return { args: ['add', '-g', spec], command: 'pnpm' };
|
||||
}
|
||||
case 'yarn': {
|
||||
return { args: ['global', 'add', spec], command: 'yarn' };
|
||||
}
|
||||
case 'bun': {
|
||||
return { args: ['add', '-g', spec], command: 'bun' };
|
||||
}
|
||||
default: {
|
||||
return { args: ['install', '-g', spec], command: 'npm' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `latest` is a newer version than `current`. Delegates to `semver` so
|
||||
* prerelease identifiers order correctly (e.g. `1.0.0-beta.10` > `1.0.0-beta.9`,
|
||||
* which a lexicographic compare gets wrong). Tolerates a leading `v` and missing
|
||||
* segments via coercion; an unparseable `latest` is treated as "not newer".
|
||||
*/
|
||||
export function isNewerVersion(latest: string, current: string): boolean {
|
||||
const latestParsed = semver.coerce(latest, { includePrerelease: true }) ?? semver.parse(latest);
|
||||
const currentParsed =
|
||||
semver.coerce(current, { includePrerelease: true }) ?? semver.parse(current);
|
||||
if (!latestParsed || !currentParsed) return false;
|
||||
return semver.gt(latestParsed, currentParsed);
|
||||
}
|
||||
|
||||
async function fetchLatestVersion(name: string, tag: string): Promise<string> {
|
||||
const url = `https://registry.npmjs.org/${name}/${encodeURIComponent(tag)}`;
|
||||
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`npm registry returned status ${res.status} for tag "${tag}"`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { version?: string };
|
||||
if (!data.version) {
|
||||
throw new Error('npm registry response is missing the "version" field');
|
||||
}
|
||||
|
||||
return data.version;
|
||||
}
|
||||
|
||||
function runInstall(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
shell: process.platform === 'win32',
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited with code ${code ?? 'null'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerUpdateCommand(program: Command) {
|
||||
program
|
||||
.command('update')
|
||||
.description('Update the LobeHub CLI to the latest published version')
|
||||
.option('--check', 'Only check for a newer version without installing')
|
||||
.option('--tag <tag>', 'npm dist-tag to update to', 'latest')
|
||||
.option(
|
||||
'--package-manager <pm>',
|
||||
`Force a package manager (${PACKAGE_MANAGERS.join(', ')}) instead of auto-detecting`,
|
||||
)
|
||||
.action(async (options: UpdateOptions) => {
|
||||
if (options.packageManager && !PACKAGE_MANAGERS.includes(options.packageManager)) {
|
||||
log.error(
|
||||
`Unsupported package manager "${options.packageManager}". Use one of: ${PACKAGE_MANAGERS.join(', ')}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = cliVersion;
|
||||
const tag = options.tag || 'latest';
|
||||
|
||||
log.info(`Current version: ${pc.bold(current)}`);
|
||||
|
||||
let latest: string;
|
||||
try {
|
||||
latest = await fetchLatestVersion(cliPackageName, tag);
|
||||
} catch (error) {
|
||||
log.error(`Unable to check for updates: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Latest version: ${pc.bold(latest)} ${pc.dim(`(${tag})`)}`);
|
||||
|
||||
if (!isNewerVersion(latest, current)) {
|
||||
log.info(pc.green('Already on the latest version.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.check) {
|
||||
log.info(
|
||||
`Update available: ${current} → ${pc.green(latest)}. Run ${pc.cyan('lh update')} to upgrade.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pm = options.packageManager || detectPackageManager();
|
||||
const spec = `${cliPackageName}@${latest}`;
|
||||
const { args, command } = buildInstallCommand(pm, spec);
|
||||
|
||||
log.info(`Upgrading via ${pc.bold(pm)}: ${pc.dim([command, ...args].join(' '))}`);
|
||||
|
||||
try {
|
||||
await runInstall(command, args);
|
||||
log.info(pc.green(`Successfully updated to ${latest}. Restart any running sessions.`));
|
||||
} catch (error) {
|
||||
log.error(`Update failed: ${(error as Error).message}`);
|
||||
log.error(`You can upgrade manually: ${[command, ...args].join(' ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -19,11 +19,22 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock only `execFileSync` (used by isDaemonProcess to read a process command
|
||||
// line); keep the real `spawn` so nothing else changes.
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, any>>();
|
||||
return { ...actual, execFileSync: vi.fn() };
|
||||
});
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import {
|
||||
appendLog,
|
||||
getLogPath,
|
||||
getRunningDaemonPid,
|
||||
isDaemonProcess,
|
||||
isProcessAlive,
|
||||
readPid,
|
||||
readStatus,
|
||||
@@ -35,9 +46,15 @@ import {
|
||||
writeStatus,
|
||||
} from './manager';
|
||||
|
||||
// A command line that matches the daemon signature (`connect … --daemon-child`).
|
||||
const DAEMON_COMMAND = '/usr/local/bin/node /path/to/cli.js connect --daemon-child';
|
||||
|
||||
describe('daemon manager', () => {
|
||||
beforeEach(async () => {
|
||||
await mkdir(mockDir, { recursive: true });
|
||||
// Default: any inspected PID looks like our daemon. Tests that need a
|
||||
// reused / unrelated PID override this per-case.
|
||||
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -80,6 +97,36 @@ describe('daemon manager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDaemonProcess', () => {
|
||||
it('should return true when the command line matches the daemon signature', () => {
|
||||
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
|
||||
expect(isDaemonProcess(12345)).toBe(true);
|
||||
expect(execFileSync).toHaveBeenCalledWith(
|
||||
'ps',
|
||||
['-ww', '-p', '12345', '-o', 'command='],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for an unrelated process command line', () => {
|
||||
vi.mocked(execFileSync).mockReturnValue('/usr/bin/vim notes.txt' as any);
|
||||
expect(isDaemonProcess(12345)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when the signature is only partially present', () => {
|
||||
// `connect` without the internal `--daemon-child` flag is not our daemon.
|
||||
vi.mocked(execFileSync).mockReturnValue('/usr/bin/node /path/cli connect' as any);
|
||||
expect(isDaemonProcess(12345)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when ps is unavailable / throws', () => {
|
||||
vi.mocked(execFileSync).mockImplementation(() => {
|
||||
throw new Error('ps: command not found');
|
||||
});
|
||||
expect(isDaemonProcess(12345)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningDaemonPid', () => {
|
||||
it('should return null when no PID file', () => {
|
||||
expect(getRunningDaemonPid()).toBeNull();
|
||||
@@ -110,6 +157,23 @@ describe('daemon manager', () => {
|
||||
|
||||
expect(readStatus()).toBeNull();
|
||||
});
|
||||
|
||||
it('should treat a live but reused (non-daemon) PID as stale and clean up', () => {
|
||||
// process.pid is alive, but the inspected command line is not our daemon —
|
||||
// simulates the OS reusing a dead daemon's PID for an unrelated process.
|
||||
writePid(process.pid);
|
||||
writeStatus({
|
||||
connectionStatus: 'connected',
|
||||
gatewayUrl: 'https://test.com',
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
|
||||
|
||||
expect(getRunningDaemonPid()).toBeNull();
|
||||
expect(readPid()).toBeNull();
|
||||
expect(readStatus()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status file', () => {
|
||||
@@ -232,5 +296,23 @@ describe('daemon manager', () => {
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should NOT SIGTERM a live PID that is not our daemon', () => {
|
||||
// Stale daemon.pid whose PID was reused by an unrelated, living process.
|
||||
writePid(process.pid);
|
||||
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
|
||||
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
const result = stopDaemon();
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Only the liveness probe (signal 0) is allowed — never a real SIGTERM.
|
||||
expect(killSpy).not.toHaveBeenCalledWith(process.pid, 'SIGTERM');
|
||||
// Stale metadata is cleaned up so we don't keep re-checking it.
|
||||
expect(readPid()).toBeNull();
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -70,6 +70,34 @@ export function isProcessAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a live PID actually belongs to a LobeHub connect daemon.
|
||||
*
|
||||
* A bare `isProcessAlive` check is not enough: if a daemon dies without cleaning
|
||||
* up `daemon.pid` (crash, `kill -9`, reboot), the OS can later reuse that PID
|
||||
* for an unrelated process. Acting on the stale PID would let `lh logout` /
|
||||
* `connect stop` SIGTERM a stranger. The daemon is always spawned as
|
||||
* `<node> … connect … --daemon-child`, so we confirm that signature in the
|
||||
* process command line before trusting the PID.
|
||||
*
|
||||
* Best-effort and deliberately conservative: if the command line can't be read
|
||||
* (e.g. `ps` is unavailable), we return `false` so callers never kill a process
|
||||
* we can't positively identify.
|
||||
*/
|
||||
export function isDaemonProcess(pid: number): boolean {
|
||||
try {
|
||||
// `-ww` disables column truncation so the trailing `--daemon-child` flag is
|
||||
// never cut off; stderr is silenced so a dead PID just yields an empty match.
|
||||
const command = execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
return command.includes('--daemon-child') && command.includes('connect');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PID of a running daemon, cleaning up stale PID files.
|
||||
* Returns null if no daemon is running.
|
||||
@@ -78,9 +106,11 @@ export function getRunningDaemonPid(): number | null {
|
||||
const pid = readPid();
|
||||
if (pid === null) return null;
|
||||
|
||||
if (isProcessAlive(pid)) return pid;
|
||||
// Require both liveness AND identity — a live-but-reused PID is treated as
|
||||
// stale so we never act on a process that isn't ours.
|
||||
if (isProcessAlive(pid) && isDaemonProcess(pid)) return pid;
|
||||
|
||||
// Stale PID file — process is dead
|
||||
// Stale PID file — process is dead or the PID now belongs to someone else.
|
||||
removePid();
|
||||
removeStatus();
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
/**
|
||||
* Single source of truth for this package's own metadata.
|
||||
*
|
||||
* Must live directly under `src/` (depth 1), the same depth as the bundled
|
||||
* entry `dist/index.js`, so `../package.json` resolves to `@lobehub/cli`'s own
|
||||
* package.json both when running from source (`bun src/index.ts`) and from the
|
||||
* tsdown bundle (`dist/index.js`). A module one directory deeper would resolve
|
||||
* the path outside the package once everything is bundled into a single file.
|
||||
*/
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('../package.json') as { name: string; version: string };
|
||||
|
||||
export const cliPackageName = pkg.name;
|
||||
export const cliVersion = pkg.version;
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { registerAgentCommand } from './commands/agent';
|
||||
@@ -33,11 +31,10 @@ import { registerStatusCommand } from './commands/status';
|
||||
import { registerTaskCommand } from './commands/task';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUpdateCommand } from './commands/update';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
import { registerVerifyCommand } from './commands/verify';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json');
|
||||
import { cliVersion } from './pkg';
|
||||
|
||||
export function createProgram() {
|
||||
const program = new Command();
|
||||
@@ -45,7 +42,7 @@ export function createProgram() {
|
||||
program
|
||||
.name('lh')
|
||||
.description('LobeHub CLI - manage and connect to LobeHub services')
|
||||
.version(version);
|
||||
.version(cliVersion);
|
||||
|
||||
registerLoginCommand(program);
|
||||
registerLogoutCommand(program);
|
||||
@@ -80,8 +77,9 @@ export function createProgram() {
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
registerMigrateCommand(program);
|
||||
registerUpdateCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export { version as cliVersion };
|
||||
export { cliPackageName, cliVersion } from './pkg';
|
||||
|
||||
@@ -127,8 +127,8 @@
|
||||
],
|
||||
"overrides": {
|
||||
"node-gyp": "^12.4.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"vitest": "3.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,14 +366,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
|
||||
async readFiles({ paths, cwd }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
|
||||
logger.debug('Starting batch file reading:', { count: paths.length });
|
||||
|
||||
const results: LocalReadFileResult[] = [];
|
||||
|
||||
for (const filePath of paths) {
|
||||
logger.debug('Reading single file:', { filePath });
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
const result = await readLocalFile({ cwd, path: filePath });
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
@@ -400,9 +400,9 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
|
||||
async handleMoveFiles({ items, cwd }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
|
||||
logger.debug('Starting batch file move:', { itemsCount: items?.length });
|
||||
return moveLocalFiles({ items });
|
||||
return moveLocalFiles({ cwd, items });
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -418,9 +418,9 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
|
||||
async handleWriteFile({ path: filePath, content, cwd }: WriteLocalFileParams) {
|
||||
logger.debug(`Writing file ${filePath}`, { contentLength: content?.length });
|
||||
return writeLocalFile({ content, path: filePath });
|
||||
return writeLocalFile({ content, cwd, path: filePath });
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
|
||||
@@ -3,6 +3,11 @@ import './pre-app-init';
|
||||
import fixPath from 'fix-path';
|
||||
|
||||
import { App } from './core/App';
|
||||
import { installProcessErrorHandlers } from './process-error-handlers';
|
||||
|
||||
// Guard the main process against transient network blips (Wi-Fi/VPN switch,
|
||||
// system sleep) emitted by Electron's net stack as uncaught exceptions.
|
||||
installProcessErrorHandlers();
|
||||
|
||||
const app = new App();
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
const logger = createLogger('main:process-error-handlers');
|
||||
|
||||
/**
|
||||
* Transient Chromium network errors emitted by Electron's `net` stack
|
||||
* (`SimpleURLLoaderWrapper`). These happen during normal operation — switching
|
||||
* Wi-Fi / VPN, the machine sleeping, the network interface dropping — and are
|
||||
* NOT application bugs. Electron emits them as an `error` event on the internal
|
||||
* loader; when nothing is listening they bubble up as an `uncaughtException`
|
||||
* and pop the "A JavaScript error occurred in the main process" dialog, even
|
||||
* though the request layer already handles the failure via promise rejection.
|
||||
*
|
||||
* We swallow these specific cases so transient connectivity blips never crash
|
||||
* the main process. Everything else is re-thrown to preserve normal crash
|
||||
* visibility.
|
||||
*
|
||||
* @see https://github.com/electron/electron/issues/24948
|
||||
*/
|
||||
const TRANSIENT_NET_ERROR_CODES = new Set([
|
||||
'ERR_NETWORK_CHANGED',
|
||||
'ERR_NETWORK_IO_SUSPENDED',
|
||||
'ERR_INTERNET_DISCONNECTED',
|
||||
'ERR_NETWORK_ACCESS_DENIED',
|
||||
'ERR_CONNECTION_RESET',
|
||||
'ERR_CONNECTION_ABORTED',
|
||||
'ERR_CONNECTION_CLOSED',
|
||||
'ERR_NAME_NOT_RESOLVED',
|
||||
'ERR_TIMED_OUT',
|
||||
]);
|
||||
|
||||
const isTransientNetError = (error: unknown): boolean => {
|
||||
if (!error) return false;
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Electron net errors are formatted as `net::ERR_XXX`.
|
||||
const match = message.match(/net::(ERR_[A-Z_]+)/);
|
||||
if (match && TRANSIENT_NET_ERROR_CODES.has(match[1])) return true;
|
||||
|
||||
// Belt-and-suspenders: these only ever originate from the net loader.
|
||||
const stack = error instanceof Error ? (error.stack ?? '') : '';
|
||||
return /net::ERR_/.test(message) && stack.includes('SimpleURLLoaderWrapper');
|
||||
};
|
||||
|
||||
/**
|
||||
* Install global guards for the Electron main process. Must be called as early
|
||||
* as possible (before the rest of the app boots) so it catches errors from any
|
||||
* module's top-level / async work.
|
||||
*/
|
||||
export const installProcessErrorHandlers = () => {
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (isTransientNetError(error)) {
|
||||
logger.warn('Ignoring transient network error in main process:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-throw so genuine bugs still surface as a crash instead of being
|
||||
// silently swallowed by this handler.
|
||||
logger.error('Uncaught exception in main process:', error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
if (isTransientNetError(reason)) {
|
||||
logger.warn(
|
||||
'Ignoring transient network rejection in main process:',
|
||||
reason instanceof Error ? reason.message : String(reason),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error('Unhandled rejection in main process:', reason);
|
||||
});
|
||||
|
||||
logger.info('Process error handlers installed');
|
||||
};
|
||||
@@ -72,7 +72,7 @@ import {
|
||||
} from '@lobechat/types';
|
||||
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
|
||||
import debug from 'debug';
|
||||
import type { ExtendParamsType } from 'model-bank';
|
||||
import { type ExtendParamsType, ModelProvider } from 'model-bank';
|
||||
|
||||
import { composioEnv } from '@/config/composio';
|
||||
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
|
||||
@@ -911,6 +911,12 @@ export const createRuntimeExecutors = (
|
||||
item.providerId === provider &&
|
||||
(item.id === model || item.config?.deploymentName === model),
|
||||
);
|
||||
const canonicalModelCard = builtinModels.find(
|
||||
(item) => item.id === model || item.config?.deploymentName === model,
|
||||
);
|
||||
const modelKnowledgeCutoff =
|
||||
modelCard?.knowledgeCutoff ??
|
||||
(provider === ModelProvider.LobeHub ? canonicalModelCard?.knowledgeCutoff : undefined);
|
||||
|
||||
let modelExtendParams = readExtendParams(modelCard);
|
||||
|
||||
@@ -920,10 +926,7 @@ export const createRuntimeExecutors = (
|
||||
// `thinkingLevel` still reach the model. Mirrors the client-side
|
||||
// `transformToAiModelList` re-namespacing behavior.
|
||||
if (!modelExtendParams || modelExtendParams.length === 0) {
|
||||
const canonicalCard = builtinModels.find(
|
||||
(item) => item.id === model || item.config?.deploymentName === model,
|
||||
);
|
||||
modelExtendParams = readExtendParams(canonicalCard);
|
||||
modelExtendParams = readExtendParams(canonicalModelCard);
|
||||
}
|
||||
|
||||
const modelSupportsPreserveThinkingFromCard =
|
||||
@@ -1300,6 +1303,7 @@ export const createRuntimeExecutors = (
|
||||
},
|
||||
messages: messagesForContext,
|
||||
model,
|
||||
modelKnowledgeCutoff,
|
||||
provider,
|
||||
systemRole: agentConfig.systemRole ?? undefined,
|
||||
toolDiscoveryConfig,
|
||||
@@ -2700,6 +2704,10 @@ export const createRuntimeExecutors = (
|
||||
toolResultMaxLength,
|
||||
topicId: ctx.topicId,
|
||||
userId: ctx.userId,
|
||||
// Device-bound cwd folded into deviceSystemInfo at operation
|
||||
// creation; resume-safe via computeDeviceContext (recovers it
|
||||
// from the prior tool message's pluginState.metadata).
|
||||
workingDirectory: state.metadata?.deviceSystemInfo?.workingDirectory,
|
||||
workspaceId: state.metadata?.workspaceId ?? ctx.workspaceId,
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ const mockBuiltinModels = vi.hoisted(() => [
|
||||
{
|
||||
abilities: { functionCall: true, video: false, vision: true },
|
||||
id: 'gpt-4',
|
||||
knowledgeCutoff: '2024-06',
|
||||
providerId: 'openai',
|
||||
},
|
||||
{
|
||||
@@ -77,6 +78,9 @@ vi.mock('@/business/client/model-bank/loadModels', () => ({
|
||||
// model-bank is a TypeScript source file that cannot be dynamically imported in vitest
|
||||
vi.mock('model-bank', () => ({
|
||||
LOBE_DEFAULT_MODEL_LIST: mockBuiltinModels,
|
||||
ModelProvider: {
|
||||
LobeHub: 'lobehub',
|
||||
},
|
||||
}));
|
||||
|
||||
// composioEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
|
||||
@@ -1575,6 +1579,87 @@ describe('RuntimeExecutors', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass model knowledge cutoff into serverMessagesEngine', async () => {
|
||||
const ctxWithConfig: RuntimeExecutorContext = {
|
||||
...ctx,
|
||||
agentConfig: {
|
||||
plugins: [],
|
||||
systemRole: 'You are a helpful assistant',
|
||||
},
|
||||
};
|
||||
const executors = createRuntimeExecutors(ctxWithConfig);
|
||||
const state = createMockState();
|
||||
|
||||
const instruction = {
|
||||
payload: {
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
},
|
||||
type: 'call_llm' as const,
|
||||
};
|
||||
|
||||
await executors.call_llm!(instruction, state);
|
||||
|
||||
expect(engineSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve LobeHub routed model knowledge cutoff by model id fallback', async () => {
|
||||
const ctxWithConfig: RuntimeExecutorContext = {
|
||||
...ctx,
|
||||
agentConfig: {
|
||||
plugins: [],
|
||||
systemRole: 'You are a helpful assistant',
|
||||
},
|
||||
};
|
||||
const executors = createRuntimeExecutors(ctxWithConfig);
|
||||
const state = createMockState();
|
||||
|
||||
await executors.call_llm!(
|
||||
{
|
||||
payload: {
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'gpt-4',
|
||||
provider: 'lobehub',
|
||||
},
|
||||
type: 'call_llm' as const,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(engineSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should omit model knowledge cutoff for unknown non-LobeHub providers', async () => {
|
||||
const ctxWithConfig: RuntimeExecutorContext = {
|
||||
...ctx,
|
||||
agentConfig: {
|
||||
plugins: [],
|
||||
systemRole: 'You are a helpful assistant',
|
||||
},
|
||||
};
|
||||
const executors = createRuntimeExecutors(ctxWithConfig);
|
||||
const state = createMockState();
|
||||
|
||||
await executors.call_llm!(
|
||||
{
|
||||
payload: {
|
||||
messages: [{ content: 'Hello', role: 'user' }],
|
||||
model: 'gpt-4',
|
||||
provider: 'custom-openai',
|
||||
},
|
||||
type: 'call_llm' as const,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(engineSpy.mock.calls[0][0]).toHaveProperty('modelKnowledgeCutoff', undefined);
|
||||
});
|
||||
|
||||
it('should keep current turn when agent historyCount is 0', async () => {
|
||||
const ctxWithConfig: RuntimeExecutorContext = {
|
||||
...ctx,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentRuntimeErrorType } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatErrorEventData } from '../formatErrorEventData';
|
||||
@@ -62,6 +63,75 @@ describe('formatErrorEventData', () => {
|
||||
});
|
||||
|
||||
describe('business-typed errors (must not be overridden)', () => {
|
||||
it('preserves traceable runtime payload body for gateway error events', () => {
|
||||
const out = formatErrorEventData(
|
||||
{
|
||||
error: { message: 'Upstream failed', traceId: 'trace-123' },
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
provider: 'openai',
|
||||
},
|
||||
'llm_execution',
|
||||
);
|
||||
|
||||
expect(out).toMatchObject({
|
||||
body: {
|
||||
message: 'Upstream failed',
|
||||
provider: 'openai',
|
||||
traceId: 'trace-123',
|
||||
},
|
||||
error: 'Upstream failed',
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
phase: 'llm_execution',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the normalized runtime type for gateway error events', () => {
|
||||
const out = formatErrorEventData(
|
||||
{
|
||||
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
provider: 'lobehub',
|
||||
},
|
||||
'llm_execution',
|
||||
);
|
||||
|
||||
expect(out).toMatchObject({
|
||||
body: {
|
||||
message: 'Payment required',
|
||||
provider: 'lobehub',
|
||||
status: 402,
|
||||
traceId: 'trace-402',
|
||||
},
|
||||
error: 'Payment required',
|
||||
errorType: AgentRuntimeErrorType.InsufficientQuota,
|
||||
phase: 'llm_execution',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the normalized runtime message when the payload message is a placeholder', () => {
|
||||
const out = formatErrorEventData(
|
||||
{
|
||||
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
message: 'error',
|
||||
provider: 'lobehub',
|
||||
},
|
||||
'llm_execution',
|
||||
);
|
||||
|
||||
expect(out).toMatchObject({
|
||||
body: {
|
||||
message: 'Payment required',
|
||||
provider: 'lobehub',
|
||||
status: 402,
|
||||
traceId: 'trace-402',
|
||||
},
|
||||
error: 'Payment required',
|
||||
errorType: AgentRuntimeErrorType.InsufficientQuota,
|
||||
phase: 'llm_execution',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves ConversationParentMissing errorType and message even when .cause has PG info', () => {
|
||||
// Mirrors createConversationParentMissingError from messagePersistErrors.ts:
|
||||
// the user-facing errorType lives on the error object directly, and the
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { pickNonEmptyString, toRecord } from '@lobechat/utils/object';
|
||||
|
||||
import { formatErrorForState } from './formatErrorForState';
|
||||
import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
|
||||
|
||||
const isErrorType = (value: unknown): value is string | number =>
|
||||
typeof value === 'string' || typeof value === 'number';
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary thrown value into the shape the runtime stream-event
|
||||
* protocol expects. Extracts a human-readable `error` string and a best-effort
|
||||
@@ -23,55 +29,38 @@ import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
|
||||
* DB failures by SQLSTATE.
|
||||
*/
|
||||
export const formatErrorEventData = (error: unknown, phase: string) => {
|
||||
let errorMessage = 'Unknown error';
|
||||
let errorType: string | undefined;
|
||||
// True when `errorType` came from a business-typed field on the error
|
||||
// payload (step 1 above). Driver class names assigned via `error.name`
|
||||
// do NOT set this flag, so raw `PostgresError` / `DatabaseError` instances
|
||||
// still fall through to the PG unwrap step.
|
||||
let hasBusinessErrorType = false;
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
const payload = error as { error?: unknown; errorType?: unknown; message?: unknown };
|
||||
|
||||
if (typeof payload.errorType === 'string') {
|
||||
errorType = payload.errorType;
|
||||
hasBusinessErrorType = true;
|
||||
}
|
||||
|
||||
if (typeof payload.message === 'string' && payload.message.length > 0) {
|
||||
errorMessage = payload.message;
|
||||
} else if (typeof payload.error === 'string' && payload.error.length > 0) {
|
||||
errorMessage = payload.error;
|
||||
} else if (
|
||||
payload.error &&
|
||||
typeof payload.error === 'object' &&
|
||||
'message' in payload.error &&
|
||||
typeof payload.error.message === 'string'
|
||||
) {
|
||||
errorMessage = payload.error.message;
|
||||
} else if (error instanceof Error && error.message.length > 0) {
|
||||
errorMessage = error.message;
|
||||
} else if (errorType) {
|
||||
errorMessage = errorType;
|
||||
}
|
||||
} else if (error instanceof Error && error.message.length > 0) {
|
||||
errorMessage = error.message;
|
||||
errorType = error.name;
|
||||
} else if (typeof error === 'string' && error.length > 0) {
|
||||
errorMessage = error;
|
||||
}
|
||||
const payload = toRecord(error);
|
||||
const rawPayloadErrorType = payload?.errorType ?? payload?.type;
|
||||
const payloadErrorType = isErrorType(rawPayloadErrorType) ? rawPayloadErrorType : undefined;
|
||||
const structuredError =
|
||||
error instanceof Error || payloadErrorType === undefined
|
||||
? undefined
|
||||
: formatErrorForState(payload);
|
||||
const body = structuredError?.body;
|
||||
const hasPayloadErrorType = payloadErrorType !== undefined;
|
||||
let errorType = hasPayloadErrorType
|
||||
? String(structuredError?.type ?? payloadErrorType)
|
||||
: undefined;
|
||||
const payloadError = payload?.error;
|
||||
let errorMessage =
|
||||
pickNonEmptyString(structuredError?.message) ??
|
||||
pickNonEmptyString(payload?.message) ??
|
||||
pickNonEmptyString(payloadError) ??
|
||||
pickNonEmptyString(toRecord(payloadError)?.message) ??
|
||||
(error instanceof Error ? pickNonEmptyString(error.message) : pickNonEmptyString(error)) ??
|
||||
errorType ??
|
||||
'Unknown error';
|
||||
|
||||
if (!errorType && error instanceof Error && error.name) {
|
||||
errorType = error.name;
|
||||
}
|
||||
|
||||
// Enrichment: run PG unwrap whenever no *business-typed* errorType was
|
||||
// Enrichment: run PG unwrap whenever no payload errorType was
|
||||
// declared. This covers both Drizzle-wrapped errors (PG info under .cause)
|
||||
// AND raw top-level driver errors like `PostgresError` / `DatabaseError`
|
||||
// which carry a specific `name` but are still real PG errors deserving
|
||||
// `pg_<sqlstate>` classification on the dashboard.
|
||||
if (!hasBusinessErrorType) {
|
||||
if (!hasPayloadErrorType) {
|
||||
const pg = unwrapPgError(error);
|
||||
if (pg) {
|
||||
errorMessage = formatPgError(pg);
|
||||
@@ -80,6 +69,7 @@ export const formatErrorEventData = (error: unknown, phase: string) => {
|
||||
}
|
||||
|
||||
return {
|
||||
...(body === undefined ? {} : { body }),
|
||||
error: errorMessage,
|
||||
errorType,
|
||||
phase,
|
||||
|
||||
@@ -16,7 +16,35 @@ describe('formatErrorForState', () => {
|
||||
|
||||
expect(result.type).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
|
||||
expect(result.message).toBe('Invalid API key');
|
||||
expect(result.body).toEqual({ detail: 'Unauthorized' });
|
||||
expect(result.body).toEqual({
|
||||
detail: 'Unauthorized',
|
||||
message: 'Invalid API key',
|
||||
provider: 'openai',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves top-level context from ChatCompletionErrorPayload', () => {
|
||||
const budget = { required: 12 };
|
||||
|
||||
const result = formatErrorForState({
|
||||
budget,
|
||||
error: { message: 'Budget exceeded' },
|
||||
errorType: ChatErrorType.FreePlanLimit,
|
||||
provider: 'lobehub',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
attribution: 'user',
|
||||
body: {
|
||||
budget,
|
||||
message: 'Budget exceeded',
|
||||
provider: 'lobehub',
|
||||
},
|
||||
category: 'quota',
|
||||
httpStatus: 402,
|
||||
message: 'Budget exceeded',
|
||||
type: ChatErrorType.FreePlanLimit,
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps standard Error as InternalServerError', () => {
|
||||
@@ -180,6 +208,43 @@ describe('formatErrorForState', () => {
|
||||
expect(result.category).toBe('quota');
|
||||
});
|
||||
|
||||
it('keeps payload.error available when _responseBody is present', () => {
|
||||
const result = formatErrorForState({
|
||||
_responseBody: { provider: 'lobehub' },
|
||||
error: { status: 402 },
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
message: 'opaque upstream message',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
body: {
|
||||
error: { status: 402 },
|
||||
message: 'opaque upstream message',
|
||||
provider: 'lobehub',
|
||||
},
|
||||
category: 'quota',
|
||||
type: AgentRuntimeErrorType.InsufficientQuota,
|
||||
});
|
||||
});
|
||||
|
||||
it('merges payload status into an existing _responseBody error object', () => {
|
||||
const result = formatErrorForState({
|
||||
_responseBody: { error: { message: 'Payment required' }, provider: 'lobehub' },
|
||||
error: { status: 402 },
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
message: 'opaque upstream message',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
body: {
|
||||
error: { message: 'Payment required', status: 402 },
|
||||
provider: 'lobehub',
|
||||
},
|
||||
category: 'quota',
|
||||
type: AgentRuntimeErrorType.InsufficientQuota,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a genuine residual as ProviderBizError (E8002)', () => {
|
||||
const result = formatErrorForState({
|
||||
errorType: AgentRuntimeErrorType.ProviderBizError,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getErrorCodeSpec, refineErrorCode } from '@lobechat/model-runtime';
|
||||
import { AgentRuntimeErrorType, ChatErrorType, type ChatMessageError } from '@lobechat/types';
|
||||
import { isRecord } from '@lobechat/utils';
|
||||
|
||||
/** Pull a usable HTTP status out of the nested upstream error object. */
|
||||
const extractHttpStatus = (body: unknown): number | undefined => {
|
||||
@@ -19,6 +20,80 @@ const extractProvider = (body: unknown): string | undefined => {
|
||||
return typeof p === 'string' ? p : undefined;
|
||||
};
|
||||
|
||||
const extractMessage = (value: unknown): string | undefined => {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const message = value.message;
|
||||
if (typeof message === 'string' && message) return message;
|
||||
|
||||
const nestedError = value.error;
|
||||
if (isRecord(nestedError)) {
|
||||
const nestedMessage = nestedError.message;
|
||||
if (typeof nestedMessage === 'string' && nestedMessage) return nestedMessage;
|
||||
}
|
||||
};
|
||||
|
||||
interface ChatCompletionErrorPayloadLike {
|
||||
_responseBody?: unknown;
|
||||
budget?: unknown;
|
||||
error?: unknown;
|
||||
errorType: ChatMessageError['type'];
|
||||
message?: string;
|
||||
provider?: unknown;
|
||||
}
|
||||
|
||||
const mergePayloadError = (
|
||||
sourceBody: Record<string, unknown>,
|
||||
payload: ChatCompletionErrorPayloadLike,
|
||||
): unknown | undefined => {
|
||||
if (payload._responseBody === undefined || payload.error === undefined) return undefined;
|
||||
if (!('error' in sourceBody)) return payload.error;
|
||||
if (isRecord(sourceBody.error) && isRecord(payload.error)) {
|
||||
return { ...payload.error, ...sourceBody.error };
|
||||
}
|
||||
};
|
||||
|
||||
const buildPayloadBody = (
|
||||
payload: ChatCompletionErrorPayloadLike,
|
||||
originalError: unknown,
|
||||
message: string,
|
||||
): unknown => {
|
||||
// Runtime payloads often keep UI context (for example quota hints) next to
|
||||
// `error`, while `error` itself only carries the display message. Merge both
|
||||
// layers so normalizing `{ errorType, error }` does not drop the fields the
|
||||
// chat error renderer needs later.
|
||||
const sourceBody = payload._responseBody ?? payload.error ?? originalError;
|
||||
const context: Record<string, unknown> = {};
|
||||
|
||||
if (payload.budget !== undefined) context.budget = payload.budget;
|
||||
if (typeof payload.provider === 'string') context.provider = payload.provider;
|
||||
|
||||
if (isRecord(sourceBody)) {
|
||||
const payloadError = mergePayloadError(sourceBody, payload);
|
||||
|
||||
return {
|
||||
...sourceBody,
|
||||
// `_responseBody` is the display-facing body, but gateway/model-runtime
|
||||
// still carries status/provider details in `error` for some failures:
|
||||
// `{ _responseBody: { error: { message } }, error: { status: 402 } }`.
|
||||
...(payloadError === undefined ? {} : { error: payloadError }),
|
||||
...(payload.budget !== undefined && !('budget' in sourceBody)
|
||||
? { budget: payload.budget }
|
||||
: {}),
|
||||
...(typeof payload.provider === 'string' && !('provider' in sourceBody)
|
||||
? { provider: payload.provider }
|
||||
: {}),
|
||||
...('message' in sourceBody ? {} : { message }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...context,
|
||||
...(sourceBody === undefined ? {} : { error: sourceBody }),
|
||||
message,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge classification metadata from `ERROR_CODE_SPECS` onto a normalized
|
||||
* `ChatMessageError`. Codes that aren't in the spec table (fallbacks like
|
||||
@@ -79,14 +154,16 @@ const enrichWithSpec = (formatted: ChatMessageError): ChatMessageError => {
|
||||
*/
|
||||
export const formatErrorForState = (error: unknown): ChatMessageError => {
|
||||
if (error && typeof error === 'object' && 'errorType' in error) {
|
||||
const payload = error as {
|
||||
error?: unknown;
|
||||
errorType: ChatMessageError['type'];
|
||||
message?: string;
|
||||
};
|
||||
const payload = error as ChatCompletionErrorPayloadLike;
|
||||
const message =
|
||||
(payload.message && payload.message !== 'error' ? payload.message : undefined) ??
|
||||
extractMessage(payload._responseBody) ??
|
||||
extractMessage(payload.error) ??
|
||||
String(payload.errorType);
|
||||
|
||||
return enrichWithSpec({
|
||||
body: payload.error || error,
|
||||
message: payload.message || String(payload.errorType),
|
||||
body: buildPayloadBody(payload, error, message),
|
||||
message,
|
||||
type: payload.errorType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export const createServerAgentToolsEngine = (
|
||||
const executionTarget =
|
||||
executionPlan?.target ??
|
||||
resolveExecutionTarget(agentConfig.agencyConfig, {
|
||||
isDesktop: platform === 'desktop',
|
||||
clientExecutionAvailable: platform === 'desktop',
|
||||
});
|
||||
const runtimeMode: RuntimeEnvMode = executionTargetToRuntimeMode(executionTarget);
|
||||
// Device tools (local-system, remote-device proxy) only exist for
|
||||
|
||||
+19
@@ -70,6 +70,25 @@ describe('serverMessagesEngine', () => {
|
||||
expect(result[0].content).toBe(systemRole + '\n\n' + getCurrentDateContent());
|
||||
});
|
||||
|
||||
it('should inject model knowledge cutoff when provided', async () => {
|
||||
const messages = createBasicMessages();
|
||||
|
||||
const result = await serverMessagesEngine({
|
||||
messages,
|
||||
model: 'gpt-4',
|
||||
modelKnowledgeCutoff: '2024-06',
|
||||
provider: 'openai',
|
||||
systemRole: 'You are a helpful assistant',
|
||||
});
|
||||
|
||||
expect(result[0].role).toBe('system');
|
||||
expect(result[0].content).toBe(
|
||||
'You are a helpful assistant\n\n' +
|
||||
getCurrentDateContent() +
|
||||
'\n\nModel knowledge cutoff: 2024-06',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty messages', async () => {
|
||||
const result = await serverMessagesEngine({
|
||||
messages: [],
|
||||
|
||||
@@ -51,6 +51,7 @@ const createServerVariableGenerators = (params: {
|
||||
export const serverMessagesEngine = async ({
|
||||
messages = [],
|
||||
model,
|
||||
modelKnowledgeCutoff,
|
||||
provider,
|
||||
systemRole,
|
||||
inputTemplate,
|
||||
@@ -121,6 +122,7 @@ export const serverMessagesEngine = async ({
|
||||
|
||||
// Model info
|
||||
model,
|
||||
modelKnowledgeCutoff,
|
||||
|
||||
provider,
|
||||
systemRole,
|
||||
|
||||
@@ -132,6 +132,8 @@ export interface ServerMessagesEngineParams {
|
||||
|
||||
/** Model ID */
|
||||
model: string;
|
||||
/** Model knowledge cutoff date, e.g. `2024-06`. Omit when unknown. */
|
||||
modelKnowledgeCutoff?: string;
|
||||
|
||||
/** Page content context (optional, for document editing) */
|
||||
pageContentContext?: PageContentContext;
|
||||
|
||||
@@ -20,8 +20,8 @@ import { GenerationModel } from '@/database/models/generation';
|
||||
import { asyncAuthedProcedure, asyncRouter as router } from '@/libs/trpc/async';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { VideoGenerationService } from '@/server/services/generation/video';
|
||||
import { buildVideoGenerationFilePayload } from '@/server/services/generation/videoFile';
|
||||
import { FileSource } from '@/types/files';
|
||||
import { sanitizeFileName } from '@/utils/sanitizeFileName';
|
||||
|
||||
const log = debug('lobe-video:async');
|
||||
|
||||
@@ -196,13 +196,11 @@ export const videoRouter = router({
|
||||
url: processResult.videoKey,
|
||||
width: processResult.width,
|
||||
},
|
||||
{
|
||||
fileHash: processResult.fileHash,
|
||||
fileType: processResult.mimeType,
|
||||
name: `${sanitizeFileName(batch?.prompt ?? '', generationId)}.mp4`,
|
||||
size: processResult.fileSize,
|
||||
url: processResult.videoKey,
|
||||
},
|
||||
buildVideoGenerationFilePayload({
|
||||
generationId,
|
||||
processResult,
|
||||
prompt: batch?.prompt,
|
||||
}),
|
||||
FileSource.VideoGeneration,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
chatTopicStatusSchema,
|
||||
type RecentTopic,
|
||||
type RecentTopicGroup,
|
||||
type RecentTopicGroupMember,
|
||||
@@ -614,18 +615,7 @@ export const topicRouter = router({
|
||||
})
|
||||
.optional(),
|
||||
sessionId: z.string().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
'active',
|
||||
'running',
|
||||
'paused',
|
||||
'waitingForHuman',
|
||||
'failed',
|
||||
'completed',
|
||||
'archived',
|
||||
])
|
||||
.nullable()
|
||||
.optional(),
|
||||
status: chatTopicStatusSchema.nullable().optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/database/models/agentOperation';
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
import { type LobeChatDatabase } from '@/database/type';
|
||||
import { formatErrorForState } from '@/server/modules/AgentRuntime/formatErrorForState';
|
||||
import { buildFinalSnapshotKey } from '@/server/modules/AgentTracing';
|
||||
import { emitAgentSignalSourceEvent } from '@/server/services/agentSignal';
|
||||
import { toAgentSignalTraceEvents } from '@/server/services/agentSignal/observability/traceEvents';
|
||||
@@ -361,13 +362,20 @@ export class CompletionLifecycle {
|
||||
|
||||
const assistantMessageId = metadata?.assistantMessageId;
|
||||
if (assistantMessageId && state?.error) {
|
||||
const errorMessage = this.extractErrorMessage(state.error) || String(state.error);
|
||||
// Preserve the semantic error type written by the runtime. Rebuilding
|
||||
// this as a generic AgentRuntimeError would lose UI routing data such
|
||||
// as quota context and force the client into the fallback card.
|
||||
const messageError = formatErrorForState(state.error);
|
||||
const errorMessage =
|
||||
this.extractErrorMessage(messageError) ||
|
||||
this.extractErrorMessage(state.error) ||
|
||||
String(state.error);
|
||||
try {
|
||||
await this.messageModel.update(assistantMessageId, {
|
||||
error: {
|
||||
body: { message: errorMessage },
|
||||
...messageError,
|
||||
body: messageError.body ?? { message: errorMessage },
|
||||
message: errorMessage,
|
||||
type: 'AgentRuntimeError',
|
||||
},
|
||||
});
|
||||
} catch (updateError) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { ChatErrorType } from '@lobechat/types';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CompletionLifecycle } from '../CompletionLifecycle';
|
||||
@@ -197,6 +198,50 @@ describe('CompletionLifecycle.buildLifecycleEvent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompletionLifecycle.dispatchHooks — error persistence', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('persists budget errors without downgrading them to AgentRuntimeError', async () => {
|
||||
const lifecycle = buildLifecycle();
|
||||
const updateMessage = vi.fn().mockResolvedValue({ success: true });
|
||||
const budget = { required: 12 };
|
||||
|
||||
(lifecycle as any).messageModel = { update: updateMessage };
|
||||
vi.spyOn(lifecycle as any, 'persistCompletion').mockResolvedValue(undefined);
|
||||
vi.spyOn(hookDispatcher, 'dispatch').mockResolvedValue(undefined as any);
|
||||
vi.spyOn(hookDispatcher, 'unregister').mockImplementation(() => {});
|
||||
|
||||
await lifecycle.dispatchHooks(
|
||||
'op-1',
|
||||
{
|
||||
error: {
|
||||
budget,
|
||||
error: { message: 'Budget exceeded' },
|
||||
errorType: ChatErrorType.FreePlanLimit,
|
||||
provider: 'lobehub',
|
||||
},
|
||||
metadata: { _hooks: [], assistantMessageId: 'msg-1' },
|
||||
status: 'error',
|
||||
},
|
||||
'error',
|
||||
);
|
||||
|
||||
expect(updateMessage).toHaveBeenCalledWith('msg-1', {
|
||||
error: expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
budget,
|
||||
message: 'Budget exceeded',
|
||||
provider: 'lobehub',
|
||||
}),
|
||||
message: 'Budget exceeded',
|
||||
type: ChatErrorType.FreePlanLimit,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompletionLifecycle.dispatchHooks — async-tool park', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -180,10 +180,35 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
platform: 'darwin' as const,
|
||||
};
|
||||
|
||||
// Override the agent's agencyConfig and rebuild the service. Auto-activation
|
||||
// is now exclusive to `executionTarget: 'auto'` — the default (`local`) never
|
||||
// grabs a device — so the auto-activation specs opt in explicitly.
|
||||
const useAgencyConfig = async (agencyConfig: Record<string, unknown>) => {
|
||||
const { AgentService } = await import('@/server/services/agent');
|
||||
vi.mocked(AgentService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
getAgentConfig: vi.fn().mockResolvedValue({
|
||||
agencyConfig,
|
||||
chatConfig: {},
|
||||
files: [],
|
||||
id: 'agent-1',
|
||||
knowledgeBases: [],
|
||||
model: 'gpt-4',
|
||||
plugins: [],
|
||||
provider: 'openai',
|
||||
systemRole: 'You are a helpful assistant',
|
||||
}),
|
||||
}) as any,
|
||||
);
|
||||
service = new AiAgentService(mockDb, userId);
|
||||
};
|
||||
|
||||
describe('IM/Bot scenario with botContext', () => {
|
||||
it('should auto-activate when exactly one device is online', async () => {
|
||||
it('should auto-activate when exactly one device is online (executionTarget: auto)', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -202,9 +227,10 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
expect(createOpArgs.activeDeviceId).toBe('device-001');
|
||||
});
|
||||
|
||||
it('should NOT auto-activate when multiple devices are online', async () => {
|
||||
it('should NOT auto-activate when multiple devices are online (executionTarget: auto)', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice, onlineDevice2]);
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -223,9 +249,10 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
expect(createOpArgs.activeDeviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT auto-activate when no devices are online', async () => {
|
||||
it('should NOT auto-activate when no devices are online (executionTarget: auto)', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([]);
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -243,12 +270,35 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
const createOpArgs = mockCreateOperation.mock.calls[0][0];
|
||||
expect(createOpArgs.activeDeviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT auto-activate the single online device by default (executionTarget unset → local)', async () => {
|
||||
// The default mode never grabs a device — only explicit `auto` does.
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
|
||||
await useAgencyConfig({}); // unset executionTarget → default `local`
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
botContext: {
|
||||
applicationId: 'app-1',
|
||||
isOwner: true,
|
||||
platform: 'discord',
|
||||
platformThreadId: 'discord:guild-1:channel-1',
|
||||
senderExternalUserId: 'owner-id',
|
||||
} as any,
|
||||
prompt: 'List my files',
|
||||
});
|
||||
|
||||
const createOpArgs = mockCreateOperation.mock.calls[0][0];
|
||||
expect(createOpArgs.activeDeviceId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IM/Bot scenario with discordContext', () => {
|
||||
it('should auto-activate when exactly one device is online', async () => {
|
||||
it('should auto-activate when exactly one device is online (executionTarget: auto)', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -263,16 +313,15 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
});
|
||||
|
||||
describe('Web UI scenario (no botContext/discordContext)', () => {
|
||||
// regular chat used to leave activeDeviceId undefined when no
|
||||
// device was bound, which caused the local-system system prompt's
|
||||
// {{workingDirectory}} / {{hostname}} placeholders to reach the LLM as
|
||||
// literals. The model would then waste the first N steps groping for cwd.
|
||||
// Now we auto-activate when exactly one device is online — multi-device
|
||||
// users still need to bind explicitly, since picking one by recency
|
||||
// would be a guess that could route tool calls to the wrong machine.
|
||||
it('should auto-activate the only online device', async () => {
|
||||
// In `auto` mode a single online device is activated up-front, so the
|
||||
// local-system system prompt's {{workingDirectory}} / {{hostname}}
|
||||
// placeholders resolve instead of reaching the LLM as literals. Multi-device
|
||||
// users still pick explicitly (the model selects via the remote-device
|
||||
// tool). The default mode never auto-activates.
|
||||
it('should auto-activate the only online device (executionTarget: auto)', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -284,9 +333,10 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
expect(createOpArgs.activeDeviceId).toBe('device-001');
|
||||
});
|
||||
|
||||
it('should NOT auto-activate when multiple devices are online', async () => {
|
||||
it('should NOT auto-activate when multiple devices are online (executionTarget: auto)', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice, onlineDevice2]);
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -297,9 +347,24 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
expect(createOpArgs.activeDeviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT auto-activate when no devices are online', async () => {
|
||||
it('should NOT auto-activate when no devices are online (executionTarget: auto)', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([]);
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
prompt: 'List my files',
|
||||
});
|
||||
|
||||
const createOpArgs = mockCreateOperation.mock.calls[0][0];
|
||||
expect(createOpArgs.activeDeviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT auto-activate the single online device by default (unset → local)', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
|
||||
await useAgencyConfig({}); // unset executionTarget → default `local`
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -482,33 +547,16 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
});
|
||||
|
||||
// Verifies topic-stored metadata.boundDeviceId is NOT silently reused as
|
||||
// the runtime bound device. Setup: topic.metadata says device-002, but the
|
||||
// only online device is device-001. If the topic metadata were reused as
|
||||
// boundDeviceId, activeDeviceId would be undefined (device-002 is offline).
|
||||
// After auto-activate, we instead pick the most-recent online
|
||||
// the runtime bound device. Setup: `auto` mode, topic.metadata says
|
||||
// device-002, but the only online device is device-001. If the topic
|
||||
// metadata were reused as boundDeviceId, activeDeviceId would be undefined
|
||||
// (device-002 is offline). Auto-activation instead picks the single online
|
||||
// device (device-001) — proving the topic's stale metadata wasn't honored.
|
||||
it('should not reuse topic boundDeviceId when no explicit deviceId is provided', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
|
||||
topicMock.findById.mockResolvedValue({ metadata: { boundDeviceId: 'device-002' } });
|
||||
const { AgentService } = await import('@/server/services/agent');
|
||||
vi.mocked(AgentService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
getAgentConfig: vi.fn().mockResolvedValue({
|
||||
chatConfig: {},
|
||||
files: [],
|
||||
id: 'agent-1',
|
||||
knowledgeBases: [],
|
||||
model: 'gpt-4',
|
||||
plugins: [],
|
||||
provider: 'openai',
|
||||
systemRole: 'You are a helpful assistant',
|
||||
}),
|
||||
}) as any,
|
||||
);
|
||||
|
||||
service = new AiAgentService(mockDb, userId);
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -585,11 +633,11 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
});
|
||||
|
||||
// Mirrors the "should not reuse topic boundDeviceId" test above with a
|
||||
// different mock shape. Topic metadata stores device-002, but only
|
||||
// device-001 is online; if topic metadata leaked into boundDeviceId,
|
||||
// different mock shape. `auto` mode, topic metadata stores device-002, but
|
||||
// only device-001 is online; if topic metadata leaked into boundDeviceId,
|
||||
// activeDeviceId would be undefined (since device-002 is offline). The
|
||||
// post-auto-activate picks device-001 instead, confirming the
|
||||
// stale topic.metadata.boundDeviceId path is dead.
|
||||
// auto-activation picks device-001 instead, confirming the stale
|
||||
// topic.metadata.boundDeviceId path is dead.
|
||||
it('should not reuse topic metadata bound device when no deviceId is supplied', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
|
||||
@@ -597,6 +645,7 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
id: 'topic-1',
|
||||
metadata: { boundDeviceId: 'device-002' },
|
||||
});
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -635,27 +684,10 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
});
|
||||
|
||||
describe('Remote Device tool injection when device is auto-activated', () => {
|
||||
it('should mark autoActivated when single device is auto-activated (IM/Bot)', async () => {
|
||||
it('should mark autoActivated when single device is auto-activated (IM/Bot, executionTarget: auto)', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
|
||||
|
||||
const { AgentService } = await import('@/server/services/agent');
|
||||
vi.mocked(AgentService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
getAgentConfig: vi.fn().mockResolvedValue({
|
||||
chatConfig: {},
|
||||
files: [],
|
||||
id: 'agent-1',
|
||||
knowledgeBases: [],
|
||||
model: 'gpt-4',
|
||||
plugins: [],
|
||||
provider: 'openai',
|
||||
systemRole: 'You are a helpful assistant',
|
||||
}),
|
||||
}) as any,
|
||||
);
|
||||
service = new AiAgentService(mockDb, userId);
|
||||
await useAgencyConfig({ executionTarget: 'auto' });
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
|
||||
@@ -215,7 +215,9 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
|
||||
]);
|
||||
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
|
||||
mockGetAgentConfig.mockResolvedValue(
|
||||
createBaseAgentConfig({ agencyConfig: { executionTarget: 'auto' } }),
|
||||
);
|
||||
|
||||
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
|
||||
|
||||
|
||||
@@ -277,6 +277,22 @@ interface InternalExecAgentParams extends ExecAgentParams {
|
||||
userInterventionConfig?: UserInterventionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@link AiAgentService.resolveWorkspaceInit}: the cacheable scan
|
||||
* (`workspace`) plus the per-run resolved bound directory (`boundCwd`).
|
||||
*
|
||||
* `boundCwd` is deliberately kept OUT of {@link WorkspaceInitResult}: that type
|
||||
* is persisted into `devices.workingDirs[].workspace` and read by the web UI,
|
||||
* and its scanned root is always the enclosing `WorkingDirEntry.path` — not a
|
||||
* field on the scan. Surfacing it here lets the caller fill the system prompt's
|
||||
* `{{workingDirectory}}` (and the tool cwd/scope downstream) without re-loading
|
||||
* the device + topic the scan already read.
|
||||
*/
|
||||
interface ResolvedWorkspaceInit {
|
||||
boundCwd?: string;
|
||||
workspace: WorkspaceInitResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Agent Service
|
||||
*
|
||||
@@ -370,18 +386,22 @@ export class AiAgentService {
|
||||
activeDeviceId: string | undefined;
|
||||
agencyConfig?: LobeAgentAgencyConfig;
|
||||
topicId: string;
|
||||
}): Promise<WorkspaceInitResult> {
|
||||
}): Promise<ResolvedWorkspaceInit> {
|
||||
const empty: WorkspaceInitResult = { instructions: [], skills: [] };
|
||||
const { activeDeviceId, agencyConfig, topicId } = params;
|
||||
if (!activeDeviceId) return empty;
|
||||
if (!activeDeviceId) return { workspace: empty };
|
||||
|
||||
try {
|
||||
const deviceModel = new DeviceModel(this.db, this.userId);
|
||||
const device = await deviceModel.findByDeviceId(activeDeviceId);
|
||||
if (!device) return empty;
|
||||
if (!device) return { workspace: empty };
|
||||
|
||||
// The bound project root we scan — resolved via the shared precedence
|
||||
// helper so it cannot drift from hetero dispatch / topic backfill.
|
||||
// helper so it cannot drift from hetero dispatch / topic backfill. Read
|
||||
// from the persisted `device.defaultCwd` (not a live device query, which
|
||||
// only reports the daemon's process.cwd = `/`); also returned to the
|
||||
// caller so the system prompt's {{workingDirectory}} reflects the same
|
||||
// bound directory the workspace scan used.
|
||||
const topic = await this.topicModel.findById(topicId);
|
||||
const boundCwd = resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: device.defaultCwd,
|
||||
@@ -389,14 +409,14 @@ export class AiAgentService {
|
||||
topicWorkingDirectory: topic?.metadata?.workingDirectory,
|
||||
workingDirByDevice: agencyConfig?.workingDirByDevice,
|
||||
});
|
||||
if (!boundCwd) return empty;
|
||||
if (!boundCwd) return { workspace: empty };
|
||||
|
||||
const workingDirs = device.workingDirs ?? [];
|
||||
const cached = workingDirs.find((dir) => dir.path === boundCwd);
|
||||
|
||||
if (isWorkspaceCacheFresh(cached, Date.now()) && cached?.workspace) {
|
||||
log('execAgent: reusing cached workspace init for %s', boundCwd);
|
||||
return cached.workspace;
|
||||
return { boundCwd, workspace: cached.workspace };
|
||||
}
|
||||
|
||||
const scanned = await deviceGateway.initWorkspace({
|
||||
@@ -409,9 +429,9 @@ export class AiAgentService {
|
||||
// cache rather than dropping the project's skills + instructions.
|
||||
if (cached?.workspace) {
|
||||
log('execAgent: workspace init scan failed, using stale cache for %s', boundCwd);
|
||||
return cached.workspace;
|
||||
return { boundCwd, workspace: cached.workspace };
|
||||
}
|
||||
return empty;
|
||||
return { boundCwd, workspace: empty };
|
||||
}
|
||||
|
||||
// Persist the fresh scan back onto `workingDirs` (update in place or prepend
|
||||
@@ -420,10 +440,10 @@ export class AiAgentService {
|
||||
await deviceModel.update(activeDeviceId, { workingDirs: updated });
|
||||
log('execAgent: scanned and cached workspace init for %s', boundCwd);
|
||||
|
||||
return scanned;
|
||||
return { boundCwd, workspace: scanned };
|
||||
} catch (error) {
|
||||
log('execAgent: resolveWorkspaceInit failed: %O', error);
|
||||
return empty;
|
||||
return { workspace: empty };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1450,9 +1470,10 @@ export class AiAgentService {
|
||||
const heteroPlan = resolveExecutionPlan({
|
||||
agencyConfig: agentConfig.agencyConfig,
|
||||
canUseDevice,
|
||||
isDesktop: false,
|
||||
isHetero: true,
|
||||
clientExecutionAvailable: false,
|
||||
requestedDeviceId,
|
||||
trigger: requestTriggerMetadata?.trigger,
|
||||
});
|
||||
|
||||
if (heteroPlan.kind !== 'sandbox') {
|
||||
@@ -1913,9 +1934,9 @@ export class AiAgentService {
|
||||
// engine's enabledToolIds exclusion — resolving the plan here closes
|
||||
// that bypass at the source.
|
||||
//
|
||||
// `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.
|
||||
// `clientExecutionAvailable` is `gatewayConfigured` here: a server with a
|
||||
// device gateway can tunnel a `local` target to the user's device, 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
|
||||
@@ -1927,9 +1948,10 @@ export class AiAgentService {
|
||||
agencyConfig: agentConfig.agencyConfig,
|
||||
canUseDevice,
|
||||
chatConfig: agentConfig.chatConfig ?? undefined,
|
||||
isDesktop: gatewayConfigured,
|
||||
clientExecutionAvailable: gatewayConfigured,
|
||||
onlineDeviceIds: onlineDevices.map((device) => device.deviceId),
|
||||
requestedDeviceId,
|
||||
trigger: requestTriggerMetadata?.trigger,
|
||||
});
|
||||
// Device tools (local-system / remote-device proxy) only exist in a
|
||||
// device-capable session — `none` and `sandbox` sessions must never see
|
||||
@@ -2233,7 +2255,11 @@ export class AiAgentService {
|
||||
platform: device?.platform ?? 'unknown',
|
||||
userDataPath: systemInfo.userDataPath,
|
||||
videosPath: systemInfo.videosPath,
|
||||
workingDirectory: systemInfo.workingDirectory,
|
||||
// `workingDirectory` is intentionally NOT taken from the live device
|
||||
// query — it only reports the daemon's process.cwd() (= `/` for a
|
||||
// Finder/Dock-launched app). The bound directory is resolved from the
|
||||
// persisted device row in resolveWorkspaceInit and written onto
|
||||
// deviceSystemInfo.workingDirectory at the call site below.
|
||||
};
|
||||
} catch (error) {
|
||||
log('execAgent: failed to fetch device system info: %O', error);
|
||||
@@ -2655,7 +2681,17 @@ export class AiAgentService {
|
||||
topicId,
|
||||
});
|
||||
|
||||
const projectMetas = workspaceInit.skills.map((s) => ({
|
||||
// Feed the bound directory (resolved from the persisted device row) into
|
||||
// the local-system tool's {{workingDirectory}} placeholder — the channel
|
||||
// the model uses to know where it is and reach for absolute paths — and,
|
||||
// downstream, the runCommand cwd / search scope (RuntimeExecutors reads
|
||||
// state.metadata.deviceSystemInfo.workingDirectory). Resume-safe via the
|
||||
// existing deviceSystemInfo plumbing (computeDeviceContext).
|
||||
if (workspaceInit.boundCwd) {
|
||||
deviceSystemInfo.workingDirectory = workspaceInit.boundCwd;
|
||||
}
|
||||
|
||||
const projectMetas = workspaceInit.workspace.skills.map((s) => ({
|
||||
description: s.description ?? '',
|
||||
identifier: `project:${s.name}`,
|
||||
location: s.path,
|
||||
@@ -2675,8 +2711,8 @@ export class AiAgentService {
|
||||
// trailing blocks on the system role — after the agent's persona and any
|
||||
// page/task/additional instructions. `agentConfig` is read by
|
||||
// `createOperation` below, so appending here still reaches the LLM.
|
||||
if (workspaceInit.instructions.length) {
|
||||
const block = workspaceInit.instructions
|
||||
if (workspaceInit.workspace.instructions.length) {
|
||||
const block = workspaceInit.workspace.instructions
|
||||
.map(
|
||||
({ content, source }) =>
|
||||
`<project_instructions source="${source}">\n${content}\n</project_instructions>`,
|
||||
@@ -2687,8 +2723,8 @@ export class AiAgentService {
|
||||
: block;
|
||||
log(
|
||||
'execAgent: injected %d project instruction file(s): %s',
|
||||
workspaceInit.instructions.length,
|
||||
workspaceInit.instructions.map((i) => i.source).join(', '),
|
||||
workspaceInit.workspace.instructions.length,
|
||||
workspaceInit.workspace.instructions.map((i) => i.source).join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,8 +129,14 @@ export async function ingestAttachment(
|
||||
throw new Error('AttachmentSource must have either buffer or url');
|
||||
}
|
||||
|
||||
// 2. MIME correction from filename
|
||||
if (mimeType === 'application/octet-stream' && source.name) {
|
||||
// 2. MIME correction from filename.
|
||||
// Recover whenever we don't have a usable MIME type — both the generic
|
||||
// `application/octet-stream` and bogus non-MIME values some platforms send
|
||||
// (e.g. QQ labels c2c file attachments as `"file"`). Without the `includes('/')`
|
||||
// guard, a value like `"file"` slips through and an `.m4a` never gets
|
||||
// classified as audio, so it's parsed as a document instead of passed to
|
||||
// audio-capable models.
|
||||
if ((mimeType === 'application/octet-stream' || !mimeType.includes('/')) && source.name) {
|
||||
const inferred = mime.getType(source.name);
|
||||
if (inferred) {
|
||||
log('ingestAttachment: inferred mimeType from filename: %s -> %s', source.name, inferred);
|
||||
|
||||
@@ -301,6 +301,34 @@ describe('FileService', () => {
|
||||
expect(result).toBe(expectedResult);
|
||||
});
|
||||
|
||||
describe('uploadBase64', () => {
|
||||
beforeEach(() => {
|
||||
mockFileModel.checkHash = vi.fn().mockResolvedValue({ isExist: false });
|
||||
mockFileModel.create = vi.fn().mockResolvedValue({ id: 'new-file-id' });
|
||||
vi.mocked(service['impl'].uploadMedia).mockResolvedValue({
|
||||
key: 'assets/generations/2026-06-19/generated.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('should write metadata compatible with UI upload path', async () => {
|
||||
await service.uploadBase64(
|
||||
Buffer.from('test content').toString('base64'),
|
||||
'assets/generations/2026-06-19/generated.png',
|
||||
);
|
||||
|
||||
expect(mockFileModel.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
dirname: 'assets/generations/2026-06-19',
|
||||
filename: 'generated.png',
|
||||
path: 'assets/generations/2026-06-19/generated.png',
|
||||
}),
|
||||
}),
|
||||
expect.any(Boolean),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadFromBuffer', () => {
|
||||
beforeEach(() => {
|
||||
mockFileModel.checkHash = vi.fn().mockResolvedValue({ isExist: false });
|
||||
|
||||
@@ -329,6 +329,7 @@ export class FileService {
|
||||
|
||||
// Extract filename from pathname
|
||||
const name = pathname.split('/').pop() || 'unknown';
|
||||
const dirname = pathname.split('/').slice(0, -1).join('/');
|
||||
|
||||
// Calculate file metadata
|
||||
const size = buffer.length;
|
||||
@@ -343,6 +344,13 @@ export class FileService {
|
||||
fileHash: hash,
|
||||
fileType,
|
||||
id: fileId, // Use UUID instead of auto-generated ID
|
||||
// Keep generated/base64 uploads compatible with UI hash-dedup, which reads metadata.path.
|
||||
metadata: {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
dirname,
|
||||
filename: name,
|
||||
path: key,
|
||||
},
|
||||
name,
|
||||
size,
|
||||
url: key, // Store original key (S3 key or desktop://)
|
||||
|
||||
@@ -123,6 +123,15 @@ describe('videoBackgroundPolling', () => {
|
||||
expect.objectContaining({
|
||||
fileHash: 'hash-abc',
|
||||
fileType: 'video/mp4',
|
||||
metadata: expect.objectContaining({
|
||||
dirname: '',
|
||||
duration: 10,
|
||||
filename: 'test-prompt-gen-456.mp4',
|
||||
generationId: 'gen-456',
|
||||
height: 1080,
|
||||
path: 'video-key-789',
|
||||
width: 1920,
|
||||
}),
|
||||
name: 'test-prompt-gen-456.mp4',
|
||||
size: 1024,
|
||||
url: 'video-key-789',
|
||||
|
||||
@@ -8,10 +8,10 @@ import { GenerationModel } from '@/database/models/generation';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { VideoGenerationService } from '@/server/services/generation/video';
|
||||
import { buildVideoGenerationFilePayload } from '@/server/services/generation/videoFile';
|
||||
import { AsyncTaskError, AsyncTaskErrorType, AsyncTaskStatus } from '@/types/asyncTask';
|
||||
import { FileSource } from '@/types/files';
|
||||
import type { VideoGenerationAsset } from '@/types/generation';
|
||||
import { sanitizeFileName } from '@/utils/sanitizeFileName';
|
||||
|
||||
const log = debug('lobe-video:background-polling');
|
||||
|
||||
@@ -88,13 +88,11 @@ export async function processBackgroundVideoPolling(
|
||||
await generationModel.createAssetAndFile(
|
||||
generationId,
|
||||
asset,
|
||||
{
|
||||
fileHash: processResult.fileHash,
|
||||
fileType: processResult.mimeType,
|
||||
name: `${sanitizeFileName(batch?.prompt ?? '', generationId)}.mp4`,
|
||||
size: processResult.fileSize,
|
||||
url: processResult.videoKey,
|
||||
},
|
||||
buildVideoGenerationFilePayload({
|
||||
generationId,
|
||||
processResult,
|
||||
prompt: batch?.prompt,
|
||||
}),
|
||||
FileSource.VideoGeneration,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildVideoGenerationFilePayload } from './videoFile';
|
||||
|
||||
describe('buildVideoGenerationFilePayload', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should include upload metadata for generated video hash dedup', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-06-19T10:20:30Z'));
|
||||
|
||||
const payload = buildVideoGenerationFilePayload({
|
||||
generationId: 'gen-456',
|
||||
processResult: {
|
||||
coverKey: 'generations/videos/video_cover.webp',
|
||||
duration: 12,
|
||||
fileHash: 'hash-abc',
|
||||
fileSize: 1024,
|
||||
height: 1080,
|
||||
mimeType: 'video/mp4',
|
||||
thumbnailKey: 'generations/videos/video_thumb.webp',
|
||||
videoKey: 'generations/videos/video_raw.mp4',
|
||||
width: 1920,
|
||||
},
|
||||
prompt: 'test prompt',
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
fileHash: 'hash-abc',
|
||||
fileType: 'video/mp4',
|
||||
metadata: {
|
||||
date: '2026-06-19',
|
||||
dirname: 'generations/videos',
|
||||
duration: 12,
|
||||
filename: 'test prompt.mp4',
|
||||
generationId: 'gen-456',
|
||||
height: 1080,
|
||||
path: 'generations/videos/video_raw.mp4',
|
||||
width: 1920,
|
||||
},
|
||||
name: 'test prompt.mp4',
|
||||
size: 1024,
|
||||
url: 'generations/videos/video_raw.mp4',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { VideoProcessResult } from '@/server/services/generation/video';
|
||||
import { sanitizeFileName } from '@/utils/sanitizeFileName';
|
||||
|
||||
interface BuildVideoGenerationFilePayloadParams {
|
||||
generationId: string;
|
||||
processResult: VideoProcessResult;
|
||||
prompt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps generated video files compatible with UI hash dedup, which reads metadata.path.
|
||||
*/
|
||||
export const buildVideoGenerationFilePayload = ({
|
||||
generationId,
|
||||
processResult,
|
||||
prompt,
|
||||
}: BuildVideoGenerationFilePayloadParams) => {
|
||||
const name = `${sanitizeFileName(prompt ?? '', generationId)}.mp4`;
|
||||
const dirname = processResult.videoKey.split('/').slice(0, -1).join('/');
|
||||
|
||||
return {
|
||||
fileHash: processResult.fileHash,
|
||||
fileType: processResult.mimeType,
|
||||
metadata: {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
dirname,
|
||||
duration: processResult.duration,
|
||||
filename: name,
|
||||
generationId,
|
||||
height: processResult.height,
|
||||
path: processResult.videoKey,
|
||||
width: processResult.width,
|
||||
},
|
||||
name,
|
||||
size: processResult.fileSize,
|
||||
url: processResult.videoKey,
|
||||
};
|
||||
};
|
||||
@@ -529,7 +529,7 @@ export class HeterogeneousPersistenceHandler {
|
||||
if (snapshot.model) state.main.turnModel = snapshot.model;
|
||||
if (snapshot.provider) state.main.turnProvider = snapshot.provider;
|
||||
|
||||
// Recover the chain spine from the DB (LOBE-10445 phase 2). The next normal
|
||||
// Recover the chain spine from the DB. The next normal
|
||||
// turn parents off the run's latest NON-tool / NON-signal main-thread
|
||||
// message; reading it straight from the DB (independent of
|
||||
// `currentAssistantId`, which can regress to the seed placeholder on a cold
|
||||
@@ -649,7 +649,7 @@ export class HeterogeneousPersistenceHandler {
|
||||
if (!currentAssistant) return undefined;
|
||||
|
||||
const toolRows = messages.filter((m) => m.role === 'tool' && m.tool_call_id);
|
||||
// Chain rule (LOBE-10445 phase 2): the next turn's assistant parents off the
|
||||
// Chain rule: the next turn's assistant parents off the
|
||||
// prior assistant (the spine), not its last child tool — recover the anchor
|
||||
// as the current assistant itself (matches the subagent reducer, and is
|
||||
// fork-resistant since it reads the thread's real latest assistant from DB).
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import {
|
||||
* chain parent were derived from that in-memory pointer, every later `newStep`
|
||||
* would open off the seed → orphan sibling forks.
|
||||
*
|
||||
* LOBE-10445 phase 2 anchors the chain to the run's latest NON-tool / NON-signal
|
||||
* The phase 2 rewrite anchors the chain to the run's latest NON-tool / NON-signal
|
||||
* main-thread message (`getLastMainThreadSpineMessageId`), read straight from the
|
||||
* DB and ordered by createdAt — independent of `currentAssistantId`. So step 2
|
||||
* chains off step 1's assistant even though the in-memory pointer regressed.
|
||||
|
||||
+78
-1
@@ -1,4 +1,8 @@
|
||||
import { LocalSystemIdentifier, LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
|
||||
import {
|
||||
LocalSystemApiName,
|
||||
LocalSystemIdentifier,
|
||||
LocalSystemManifest,
|
||||
} from '@lobechat/builtin-tool-local-system';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type ToolExecutionContext } from '../../types';
|
||||
@@ -114,4 +118,77 @@ describe('localSystemRuntime', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('working directory injection', () => {
|
||||
const parseArgs = () => JSON.parse(mockExecuteToolCall.mock.calls[0][1].arguments);
|
||||
|
||||
const buildProxy = (workingDirectory?: string) => {
|
||||
mockExecuteToolCall.mockResolvedValue({ content: '', success: true });
|
||||
return localSystemRuntime.factory({
|
||||
activeDeviceId: 'device-1',
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
workingDirectory,
|
||||
});
|
||||
};
|
||||
|
||||
it('injects cwd into runCommand when the model omits it', async () => {
|
||||
const proxy = buildProxy('/Users/me/repo');
|
||||
await proxy[LocalSystemApiName.runCommand]({ command: 'git status' });
|
||||
|
||||
expect(parseArgs()).toEqual({ command: 'git status', cwd: '/Users/me/repo' });
|
||||
});
|
||||
|
||||
it('injects scope into search ops that honor it', async () => {
|
||||
const proxy = buildProxy('/Users/me/repo');
|
||||
await proxy[LocalSystemApiName.grepContent]({ pattern: 'TODO' });
|
||||
|
||||
expect(parseArgs()).toEqual({ pattern: 'TODO', scope: '/Users/me/repo' });
|
||||
});
|
||||
|
||||
it('does not override an explicit cwd/scope supplied by the model', async () => {
|
||||
const proxy = buildProxy('/Users/me/repo');
|
||||
await proxy[LocalSystemApiName.runCommand]({ command: 'ls', cwd: '/explicit' });
|
||||
|
||||
expect(parseArgs()).toEqual({ command: 'ls', cwd: '/explicit' });
|
||||
});
|
||||
|
||||
it('injects cwd into file ops so the daemon can resolve a relative path', async () => {
|
||||
const proxy = buildProxy('/Users/me/repo');
|
||||
await proxy[LocalSystemApiName.readFile]({ path: 'src/index.ts' });
|
||||
|
||||
// The daemon's resolveAgainstCwd anchors the relative path to cwd; an
|
||||
// absolute path the model supplies passes through unchanged there.
|
||||
expect(parseArgs()).toEqual({ cwd: '/Users/me/repo', path: 'src/index.ts' });
|
||||
});
|
||||
|
||||
it('injects cwd into writeFile / editFile / moveFiles', async () => {
|
||||
for (const api of [
|
||||
LocalSystemApiName.writeFile,
|
||||
LocalSystemApiName.editFile,
|
||||
LocalSystemApiName.moveFiles,
|
||||
]) {
|
||||
mockExecuteToolCall.mockClear();
|
||||
const proxy = buildProxy('/Users/me/repo');
|
||||
await proxy[api]({ path: 'x' });
|
||||
expect(JSON.parse(mockExecuteToolCall.mock.calls[0][1].arguments).cwd).toBe(
|
||||
'/Users/me/repo',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not inject for command-id ops (getCommandOutput / killCommand)', async () => {
|
||||
const proxy = buildProxy('/Users/me/repo');
|
||||
await proxy[LocalSystemApiName.getCommandOutput]({ shell_id: 'cmd-1' });
|
||||
|
||||
expect(parseArgs()).toEqual({ shell_id: 'cmd-1' });
|
||||
});
|
||||
|
||||
it('leaves args untouched when no working directory is bound', async () => {
|
||||
const proxy = buildProxy(undefined);
|
||||
await proxy[LocalSystemApiName.runCommand]({ command: 'pwd' });
|
||||
|
||||
expect(parseArgs()).toEqual({ command: 'pwd' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
import { LocalSystemIdentifier, LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
|
||||
import {
|
||||
LocalSystemApiName,
|
||||
LocalSystemIdentifier,
|
||||
LocalSystemManifest,
|
||||
} from '@lobechat/builtin-tool-local-system';
|
||||
|
||||
import { deviceGateway } from '@/server/services/deviceGateway';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
/**
|
||||
* Which arg carries the working directory for the APIs that consume one. The
|
||||
* model never picks the working directory — the system prompt's
|
||||
* `{{workingDirectory}}` tells it where it is — so the runtime injects it as the
|
||||
* tool call's cwd/scope. `executeToolCall` only forwards `arguments`, so it must
|
||||
* ride in the args; the daemon otherwise falls back to `process.cwd()` (= `/`
|
||||
* for a Finder/Dock-launched app):
|
||||
*
|
||||
* - `runCommand → cwd`: the manifest deliberately hides `cwd`, but the daemon
|
||||
* spawns in `params.cwd`.
|
||||
* - file ops (`readFile`/`writeFile`/`editFile`/`moveFiles`) → `cwd`:
|
||||
* the daemon resolves a relative `path`/`file_path`/move item against
|
||||
* `params.cwd` (see `resolveAgainstCwd`), so a model-supplied relative path
|
||||
* lands in the bound directory instead of `/`. Absolute paths ignore it.
|
||||
* - search ops (`searchFiles`/`globFiles`/`grepContent`) → `scope`: their
|
||||
* manifest claims `scope` "defaults to the working directory", but the daemon
|
||||
* falls back to `process.cwd()`. Inject `scope` so that promise holds.
|
||||
*
|
||||
* APIs that act on a command id (getCommandOutput / killCommand) take neither.
|
||||
*/
|
||||
const WORKING_DIR_ARG: Partial<Record<string, 'cwd' | 'scope'>> = {
|
||||
[LocalSystemApiName.editFile]: 'cwd',
|
||||
[LocalSystemApiName.globFiles]: 'scope',
|
||||
[LocalSystemApiName.grepContent]: 'scope',
|
||||
[LocalSystemApiName.moveFiles]: 'cwd',
|
||||
[LocalSystemApiName.readFile]: 'cwd',
|
||||
[LocalSystemApiName.runCommand]: 'cwd',
|
||||
[LocalSystemApiName.searchFiles]: 'scope',
|
||||
[LocalSystemApiName.writeFile]: 'cwd',
|
||||
};
|
||||
|
||||
export const localSystemRuntime: ServerRuntimeRegistration = {
|
||||
factory: (context) => {
|
||||
if (!context.userId) {
|
||||
@@ -16,7 +51,15 @@ export const localSystemRuntime: ServerRuntimeRegistration = {
|
||||
const proxy: Record<string, (args: any) => Promise<any>> = {};
|
||||
|
||||
for (const api of LocalSystemManifest.api) {
|
||||
const workingDirArg = WORKING_DIR_ARG[api.name];
|
||||
proxy[api.name] = async (args: any) => {
|
||||
// Inject the device-bound cwd/scope when the model didn't supply one.
|
||||
// `??=` leaves an explicit per-call override possible for the future.
|
||||
const finalArgs =
|
||||
workingDirArg && context.workingDirectory && args?.[workingDirArg] == null
|
||||
? { ...args, [workingDirArg]: context.workingDirectory }
|
||||
: args;
|
||||
|
||||
return deviceGateway.executeToolCall(
|
||||
{
|
||||
deviceId: context.activeDeviceId!,
|
||||
@@ -25,7 +68,7 @@ export const localSystemRuntime: ServerRuntimeRegistration = {
|
||||
},
|
||||
{
|
||||
apiName: api.name,
|
||||
arguments: JSON.stringify(args),
|
||||
arguments: JSON.stringify(finalArgs),
|
||||
identifier: LocalSystemIdentifier,
|
||||
},
|
||||
context.executionTimeoutMs,
|
||||
|
||||
@@ -185,6 +185,17 @@ export interface ToolExecutionContext {
|
||||
/** Topic ID for sandbox session management */
|
||||
topicId?: string;
|
||||
userId?: string;
|
||||
/**
|
||||
* Device-bound working directory resolved when the operation was created
|
||||
* (`resolveDeviceWorkingDirectory`: topic override > workingDirByDevice >
|
||||
* device default). Injected by device-proxy runtimes as the tool call's
|
||||
* cwd/scope so commands and file ops land in the bound directory instead of
|
||||
* the daemon's `process.cwd()` (= `/` for a Finder/Dock-launched app).
|
||||
*
|
||||
* NOT the conversation `scope` above — that is the operation's thread/group
|
||||
* scope and is unrelated to the filesystem working directory.
|
||||
*/
|
||||
workingDirectory?: string;
|
||||
/**
|
||||
* Workspace ID that scopes ownership for any model/service the runtime
|
||||
* instantiates. When unset the runtime falls back to personal mode
|
||||
|
||||
@@ -132,7 +132,6 @@ table agent_cron_jobs {
|
||||
enabled [name: 'agent_cron_jobs_enabled_idx']
|
||||
remaining_executions [name: 'agent_cron_jobs_remaining_executions_idx']
|
||||
last_executed_at [name: 'agent_cron_jobs_last_executed_at_idx']
|
||||
workspace_id [name: 'agent_cron_jobs_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +389,6 @@ table agent_operations {
|
||||
status [name: 'agent_operations_status_idx']
|
||||
(user_id, created_at) [name: 'agent_operations_user_id_created_at_idx']
|
||||
metadata [name: 'agent_operations_metadata_idx']
|
||||
workspace_id [name: 'agent_operations_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +438,7 @@ table agent_skills {
|
||||
|
||||
table ai_models {
|
||||
id varchar(150) [not null]
|
||||
"_id" uuid [default: `gen_random_uuid()`]
|
||||
"_id" uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
display_name varchar(200)
|
||||
description text
|
||||
organization varchar(100)
|
||||
@@ -473,7 +471,7 @@ table ai_models {
|
||||
table ai_providers {
|
||||
id varchar(64) [not null]
|
||||
name text
|
||||
"_id" uuid [default: `gen_random_uuid()`]
|
||||
"_id" uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
sort integer
|
||||
enabled boolean
|
||||
@@ -765,6 +763,7 @@ table devices {
|
||||
|
||||
indexes {
|
||||
(user_id, device_id) [name: 'devices_user_id_device_id_unique', unique]
|
||||
(workspace_id, device_id) [name: 'devices_workspace_id_device_id_unique', unique]
|
||||
user_id [name: 'devices_user_id_idx']
|
||||
workspace_id [name: 'devices_workspace_id_idx']
|
||||
}
|
||||
@@ -784,7 +783,6 @@ table document_histories {
|
||||
user_id [name: 'document_histories_user_id_idx']
|
||||
workspace_id [name: 'document_histories_workspace_id_idx']
|
||||
saved_at [name: 'document_histories_saved_at_idx']
|
||||
workspace_id [name: 'document_histories_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,7 +1038,6 @@ table llm_generation_tracing {
|
||||
validation_failed [name: 'llm_generation_tracing_validation_failed_idx']
|
||||
feedback_signal [name: 'llm_generation_tracing_feedback_signal_idx']
|
||||
created_at [name: 'llm_generation_tracing_created_at_idx']
|
||||
workspace_id [name: 'llm_generation_tracing_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1802,7 +1799,6 @@ table file_chunks {
|
||||
workspace_id [name: 'file_chunks_workspace_id_idx']
|
||||
file_id [name: 'file_chunks_file_id_idx']
|
||||
chunk_id [name: 'file_chunks_chunk_id_idx']
|
||||
workspace_id [name: 'file_chunks_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1818,7 +1814,6 @@ table files_to_sessions {
|
||||
workspace_id [name: 'files_to_sessions_workspace_id_idx']
|
||||
file_id [name: 'files_to_sessions_file_id_idx']
|
||||
session_id [name: 'files_to_sessions_session_id_idx']
|
||||
workspace_id [name: 'files_to_sessions_workspace_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2609,6 +2604,9 @@ table workspaces {
|
||||
avatar text
|
||||
primary_owner_id text [not null]
|
||||
settings jsonb [default: `{}`]
|
||||
frozen boolean [default: false]
|
||||
frozen_reason text
|
||||
frozen_at "timestamp with time zone"
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
|
||||
+13
-7
@@ -41,6 +41,8 @@
|
||||
"artifact.thinking": "Thinking",
|
||||
"artifact.thought": "Thought process",
|
||||
"artifact.unknownTitle": "Untitled Work",
|
||||
"audioPlayer.pause": "Pause audio",
|
||||
"audioPlayer.play": "Play audio",
|
||||
"availableAgents": "Available Agents",
|
||||
"backToBottom": "Jump to latest",
|
||||
"beforeUnload.confirmLeave": "A request is still running. Leave anyway?",
|
||||
@@ -122,15 +124,15 @@
|
||||
"createModal.placeholder": "Describe what this Agent should do...",
|
||||
"createModal.skillSuggestion.actions.createAnyway": "Create Agent Anyway",
|
||||
"createModal.skillSuggestion.actions.createAnywayHint": "Skill not a fit?",
|
||||
"createModal.skillSuggestion.actions.install": "Add Skill",
|
||||
"createModal.skillSuggestion.actions.installing": "Adding…",
|
||||
"createModal.skillSuggestion.actions.install": "Install Skill",
|
||||
"createModal.skillSuggestion.actions.installing": "Installing…",
|
||||
"createModal.skillSuggestion.actions.openSkills": "View in Skills",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "Use in LobeAI",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "Use in {{name}}",
|
||||
"createModal.skillSuggestion.description": "This looks like a reusable workflow. Install the Skill once, then use it across Agents.",
|
||||
"createModal.skillSuggestion.installError": "Skill wasn't added. Retry, or create an Agent anyway.",
|
||||
"createModal.skillSuggestion.installed.description": "You can use this Skill in LobeAI or add it to any Agent.",
|
||||
"createModal.skillSuggestion.installed.ready": "Ready in LobeAI",
|
||||
"createModal.skillSuggestion.installed.title": "Skill added",
|
||||
"createModal.skillSuggestion.installError": "Skill wasn't installed. Retry, or create an Agent anyway.",
|
||||
"createModal.skillSuggestion.installed.description": "You can use this Skill in {{name}}, or enable it for any Agent.",
|
||||
"createModal.skillSuggestion.installed.ready": "Ready in {{name}}",
|
||||
"createModal.skillSuggestion.installed.title": "Skill installed",
|
||||
"createModal.skillSuggestion.title": "A Skill may fit better",
|
||||
"createModal.title": "What should this Agent do?",
|
||||
"createTask.assignee": "Assignee",
|
||||
@@ -233,9 +235,13 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
|
||||
"heteroAgent.cloudRepo.notSet": "No repo selected",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
|
||||
"heteroAgent.executionTarget.auto": "Auto",
|
||||
"heteroAgent.executionTarget.autoDesc": "Use an online device automatically, picking one when several are available",
|
||||
"heteroAgent.executionTarget.downloadDesktop": "Get Desktop App",
|
||||
"heteroAgent.executionTarget.downloadDesktopDesc": "Run agents with access to your computer",
|
||||
"heteroAgent.executionTarget.downloadDesktopTitle": "Get the desktop app",
|
||||
"heteroAgent.executionTarget.gateway": "Gateway",
|
||||
"heteroAgent.executionTarget.gatewayDesc": "Run through the device gateway so other clients can follow progress",
|
||||
"heteroAgent.executionTarget.infoTooltip": "Pick a device and the agent uses it as its runtime environment — reading and writing files and operating the computer. Cloud sandbox is provided by LobeHub Marketplace.",
|
||||
"heteroAgent.executionTarget.loading": "Loading devices…",
|
||||
"heteroAgent.executionTarget.local": "This device",
|
||||
|
||||
@@ -444,6 +444,23 @@
|
||||
"tab.setting": "Settings",
|
||||
"tab.tasks": "Tasks",
|
||||
"tab.video": "Video",
|
||||
"taskTemplate.action.connect.button": "Connect {{provider}}",
|
||||
"taskTemplate.action.connect.error": "Connection failed, please try again.",
|
||||
"taskTemplate.action.connect.popupBlocked": "Connection popup blocked. Allow popups in your browser to continue.",
|
||||
"taskTemplate.action.connect.short": "Connect",
|
||||
"taskTemplate.action.connecting": "Waiting for authorization…",
|
||||
"taskTemplate.action.create.error": "Failed to create task. Please try again.",
|
||||
"taskTemplate.action.create.success": "Scheduled task added. Find it in Lobe AI.",
|
||||
"taskTemplate.action.createButton": "Add task",
|
||||
"taskTemplate.action.creating": "Creating...",
|
||||
"taskTemplate.action.dismiss.error": "Failed to dismiss. Please try again.",
|
||||
"taskTemplate.action.dismiss.tooltip": "Not interested",
|
||||
"taskTemplate.action.refresh.button": "Refresh",
|
||||
"taskTemplate.card.templateTag": "Template",
|
||||
"taskTemplate.schedule.daily": "Every day at {{time}}",
|
||||
"taskTemplate.schedule.editableAfterCreateTooltip": "You can adjust the schedule after creating the task.",
|
||||
"taskTemplate.schedule.weekly": "Every {{weekday}} at {{time}}",
|
||||
"taskTemplate.section.title": "Try these scheduled tasks",
|
||||
"telemetry.allow": "Allow",
|
||||
"telemetry.deny": "Deny",
|
||||
"telemetry.desc": "We would like to anonymously collect usage information to help us improve {{appName}} and provide you with a better product experience. You can disable this at any time in Settings - About.",
|
||||
@@ -474,6 +491,7 @@
|
||||
"userPanel.email": "Email Support",
|
||||
"userPanel.feedback": "Contact Us",
|
||||
"userPanel.help": "Help Center",
|
||||
"userPanel.inviteFriend": "Invite a friend",
|
||||
"userPanel.moveGuide": "The settings button has been moved here",
|
||||
"userPanel.plans": "Subscription Plans",
|
||||
"userPanel.profile": "Account",
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"authorize.footer.agreement": "By continuing, you confirm that you have read and agree to the <terms>Terms and Conditions</terms> and <privacy>Privacy Policy</privacy>.",
|
||||
"authorize.footer.privacy": "Privacy Policy",
|
||||
"authorize.footer.terms": "Terms of Service",
|
||||
"authorize.scenes.connector.confirm": "Continue to Market",
|
||||
"authorize.scenes.connector.description": "Market is only used to start this service authorization. Your {{appName}} account stays separate.",
|
||||
"authorize.scenes.connector.subtitle": "Sign in to Market to connect and authorize this community service.",
|
||||
"authorize.scenes.connector.title": "Connect Community 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.sandbox.subtitle": "Create a community profile to run this tool in the community sandbox.",
|
||||
|
||||
@@ -1017,6 +1017,7 @@
|
||||
"tools.activation.auto": "Auto",
|
||||
"tools.activation.auto.desc": "Smart",
|
||||
"tools.activation.fixed.hint": "Always on — managed by the app and can’t be turned off",
|
||||
"tools.activation.pin": "Pin",
|
||||
"tools.activation.pinned": "Pinned",
|
||||
"tools.activation.pinned.desc": "Always On",
|
||||
"tools.add": "Add Skill",
|
||||
|
||||
@@ -342,6 +342,14 @@
|
||||
"plans.workspace.noSharedCredits": "No shared credits",
|
||||
"plans.workspace.sharedCredits": "~{{count}} Credits / mo",
|
||||
"plans.workspace.solo": "Solo (1 member)",
|
||||
"plansModal.creditLimit.desc": "Upgrade your plan to unlock more monthly credits and keep working without interruption.",
|
||||
"plansModal.creditLimit.title": "You’re out of credits",
|
||||
"plansModal.default.desc": "Unlock more capacity and advanced features.",
|
||||
"plansModal.default.title": "Upgrade your plan",
|
||||
"plansModal.fileStorageLimit.desc": "Your file storage is full. Upgrade to keep uploading and managing files.",
|
||||
"plansModal.fileStorageLimit.title": "Storage limit reached",
|
||||
"plansModal.modelAccess.desc": "This model is available on paid plans. Upgrade to use the full model lineup.",
|
||||
"plansModal.modelAccess.title": "Unlock all models",
|
||||
"promoBanner.fableYearly": "Annual subscribers get {{percent}}% usage off for a limited time",
|
||||
"qa.desc": "If your question is not answered, check <1>Product Documentation</1> for more FAQs, or contact us.",
|
||||
"qa.detail": "View Details",
|
||||
@@ -398,8 +406,10 @@
|
||||
"referral.errors.invalidFormat": "Invalid referral code format, please enter 2-8 letters, numbers or underscores",
|
||||
"referral.errors.selfReferral": "You cannot use your own invite code",
|
||||
"referral.errors.updateFailed": "Update failed, please try again later",
|
||||
"referral.hero.description": "Share your referral link below. After your friend makes their first payment, you each earn {{reward}}M credits.",
|
||||
"referral.hero.title": "Invite friends, you both earn <0>{{reward}}M credits</0>",
|
||||
"referral.inviteCode.description": "Share your exclusive referral code to invite friends to register",
|
||||
"referral.inviteCode.title": "My Referral Code",
|
||||
"referral.inviteCode.title": "My Exclusive Referral Code",
|
||||
"referral.inviteLink.description": "Copy the link and share with friends. Both of you earn credits after your friend makes a payment",
|
||||
"referral.inviteLink.title": "Referral Link",
|
||||
"referral.rules.antiAbuse": "If fraudulent activity is detected (e.g., mass registration of disposable email accounts), the associated accounts will be permanently banned",
|
||||
|
||||
+13
-7
@@ -41,6 +41,8 @@
|
||||
"artifact.thinking": "思考中",
|
||||
"artifact.thought": "思考过程",
|
||||
"artifact.unknownTitle": "未命名作品",
|
||||
"audioPlayer.pause": "暂停音频",
|
||||
"audioPlayer.play": "播放音频",
|
||||
"availableAgents": "可用助理",
|
||||
"backToBottom": "跳转到最新",
|
||||
"beforeUnload.confirmLeave": "你有正在生成中的请求,确定要离开吗?",
|
||||
@@ -122,15 +124,15 @@
|
||||
"createModal.placeholder": "描述这个助理要完成什么…",
|
||||
"createModal.skillSuggestion.actions.createAnyway": "仍然创建助理",
|
||||
"createModal.skillSuggestion.actions.createAnywayHint": "技能不合适?",
|
||||
"createModal.skillSuggestion.actions.install": "添加技能",
|
||||
"createModal.skillSuggestion.actions.installing": "添加中…",
|
||||
"createModal.skillSuggestion.actions.install": "安装技能",
|
||||
"createModal.skillSuggestion.actions.installing": "安装中…",
|
||||
"createModal.skillSuggestion.actions.openSkills": "查看技能",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "在 LobeAI 中使用",
|
||||
"createModal.skillSuggestion.actions.tryInLobeAI": "在 {{name}} 中使用",
|
||||
"createModal.skillSuggestion.description": "这更像一个可复用的工作流。安装一次技能后,可在多个助理中使用。",
|
||||
"createModal.skillSuggestion.installError": "技能未添加成功。你可以重试,或仍然创建助理。",
|
||||
"createModal.skillSuggestion.installed.description": "你可以在 LobeAI 中使用这个技能,也可以把它添加到任意助理。",
|
||||
"createModal.skillSuggestion.installed.ready": "已可在 LobeAI 中使用",
|
||||
"createModal.skillSuggestion.installed.title": "技能已添加",
|
||||
"createModal.skillSuggestion.installError": "技能未安装成功。你可以重试,或仍然创建助理。",
|
||||
"createModal.skillSuggestion.installed.description": "你可以在 {{name}} 中使用这个技能,也可以为任意助理启用它。",
|
||||
"createModal.skillSuggestion.installed.ready": "已可在 {{name}} 中使用",
|
||||
"createModal.skillSuggestion.installed.title": "技能已安装",
|
||||
"createModal.skillSuggestion.title": "这个需求适合用技能",
|
||||
"createModal.title": "这个助理要做什么?",
|
||||
"createTask.assignee": "负责人",
|
||||
@@ -233,9 +235,13 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
|
||||
"heteroAgent.cloudRepo.notSet": "未选择仓库",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "代码仓库",
|
||||
"heteroAgent.executionTarget.auto": "自动",
|
||||
"heteroAgent.executionTarget.autoDesc": "自动使用在线设备,有多台时择一使用",
|
||||
"heteroAgent.executionTarget.downloadDesktop": "下载桌面端",
|
||||
"heteroAgent.executionTarget.downloadDesktopDesc": "让 Agent 直接连接你的电脑",
|
||||
"heteroAgent.executionTarget.downloadDesktopTitle": "下载桌面端",
|
||||
"heteroAgent.executionTarget.gateway": "网关",
|
||||
"heteroAgent.executionTarget.gatewayDesc": "经由设备网关运行,其他客户端可跟踪进度",
|
||||
"heteroAgent.executionTarget.infoTooltip": "选择某台设备后,Agent 将以该设备为运行环境,读写文件、操作电脑。云端沙箱由 LobeHub Marketplace 提供服务",
|
||||
"heteroAgent.executionTarget.loading": "正在加载设备…",
|
||||
"heteroAgent.executionTarget.local": "本机",
|
||||
|
||||
@@ -444,6 +444,23 @@
|
||||
"tab.setting": "设置",
|
||||
"tab.tasks": "任务",
|
||||
"tab.video": "视频",
|
||||
"taskTemplate.action.connect.button": "连接 {{provider}}",
|
||||
"taskTemplate.action.connect.error": "连接失败,请重试。",
|
||||
"taskTemplate.action.connect.popupBlocked": "连接弹出窗口被阻止。请在浏览器中允许弹出窗口以继续。",
|
||||
"taskTemplate.action.connect.short": "授权",
|
||||
"taskTemplate.action.connecting": "等待授权…",
|
||||
"taskTemplate.action.create.error": "创建任务失败,请稍后再试",
|
||||
"taskTemplate.action.create.success": "定时任务已创建,可在 Lobe AI 中查看",
|
||||
"taskTemplate.action.createButton": "添加任务",
|
||||
"taskTemplate.action.creating": "创建中…",
|
||||
"taskTemplate.action.dismiss.error": "操作失败,请稍后再试",
|
||||
"taskTemplate.action.dismiss.tooltip": "不感兴趣",
|
||||
"taskTemplate.action.refresh.button": "换一批",
|
||||
"taskTemplate.card.templateTag": "模板",
|
||||
"taskTemplate.schedule.daily": "每天 {{time}}",
|
||||
"taskTemplate.schedule.editableAfterCreateTooltip": "创建后可调整执行时间。",
|
||||
"taskTemplate.schedule.weekly": "每{{weekday}} {{time}}",
|
||||
"taskTemplate.section.title": "试试这些定时任务",
|
||||
"telemetry.allow": "允许",
|
||||
"telemetry.deny": "拒绝",
|
||||
"telemetry.desc": "我们希望匿名获取你的使用信息,帮助我们改进 {{appName}},为你提供更好的产品体验。你可在「设置」-「关于」中随时关闭",
|
||||
@@ -474,6 +491,7 @@
|
||||
"userPanel.email": "邮件支持",
|
||||
"userPanel.feedback": "联系我们",
|
||||
"userPanel.help": "帮助中心",
|
||||
"userPanel.inviteFriend": "邀请好友",
|
||||
"userPanel.moveGuide": "设置按钮搬到这里啦",
|
||||
"userPanel.plans": "订阅方案",
|
||||
"userPanel.profile": "账户管理",
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"authorize.footer.agreement": "继续操作即表示你确认已理解并同意<terms>条款和条件</terms>和<privacy>隐私政策</privacy>",
|
||||
"authorize.footer.privacy": "隐私政策",
|
||||
"authorize.footer.terms": "服务条款",
|
||||
"authorize.scenes.connector.confirm": "前往 Market",
|
||||
"authorize.scenes.connector.description": "Market 仅用于发起该服务授权,你的 {{appName}} 帐号仍保持独立。",
|
||||
"authorize.scenes.connector.subtitle": "登录 Market 后即可连接并授权该社区服务。",
|
||||
"authorize.scenes.connector.title": "连接社区服务",
|
||||
"authorize.scenes.mcp.subtitle": "创建社区个人档案,即可安装并运行该社区技能。",
|
||||
"authorize.scenes.mcp.title": "安装社区技能",
|
||||
"authorize.scenes.sandbox.subtitle": "创建社区个人档案,即可在社区沙箱中运行该工具。",
|
||||
|
||||
@@ -1017,6 +1017,7 @@
|
||||
"tools.activation.auto": "自动",
|
||||
"tools.activation.auto.desc": "智能调用",
|
||||
"tools.activation.fixed.hint": "由应用强制开启,始终可用,无法关闭",
|
||||
"tools.activation.pin": "固定启用",
|
||||
"tools.activation.pinned": "固定启用",
|
||||
"tools.activation.pinned.desc": "始终注入",
|
||||
"tools.add": "集成技能",
|
||||
|
||||
@@ -342,6 +342,14 @@
|
||||
"plans.workspace.noSharedCredits": "无共享积分",
|
||||
"plans.workspace.sharedCredits": "~{{count}} 积分 / 月",
|
||||
"plans.workspace.solo": "单人工作区 (1 名成员)",
|
||||
"plansModal.creditLimit.desc": "升级方案以解锁更多每月积分,畅用不中断。",
|
||||
"plansModal.creditLimit.title": "积分已用尽",
|
||||
"plansModal.default.desc": "解锁更多容量与高级功能。",
|
||||
"plansModal.default.title": "升级方案",
|
||||
"plansModal.fileStorageLimit.desc": "文件存储已达上限,升级以继续上传和管理文件。",
|
||||
"plansModal.fileStorageLimit.title": "存储空间不足",
|
||||
"plansModal.modelAccess.desc": "该模型为付费方案专享,升级即可使用全部模型。",
|
||||
"plansModal.modelAccess.title": "解锁全部模型",
|
||||
"promoBanner.fableYearly": "年付订阅用户限时享 {{percent}}% 用量优惠",
|
||||
"qa.desc": "如果您的问题未被解答,请查看 <1>产品文档</1> 获取更多常见问题,或联系我们。",
|
||||
"qa.detail": "查看详情",
|
||||
@@ -398,8 +406,10 @@
|
||||
"referral.errors.invalidFormat": "推荐码格式无效,请输入 2-8 位字母、数字或下划线",
|
||||
"referral.errors.selfReferral": "不能使用自己的邀请码",
|
||||
"referral.errors.updateFailed": "更新失败,请稍后重试",
|
||||
"referral.hero.description": "把下方的推荐链接分享给好友,好友完成首次付费后,你和好友将各自获得 {{reward}}M 积分。",
|
||||
"referral.hero.title": "邀请好友,双方各得 <0>{{reward}}M 积分</0>",
|
||||
"referral.inviteCode.description": "分享您的专属推荐码,邀请好友注册",
|
||||
"referral.inviteCode.title": "我的推荐码",
|
||||
"referral.inviteCode.title": "我的专属推荐码",
|
||||
"referral.inviteLink.description": "复制链接并分享给好友,好友付费后双方均可获得奖励",
|
||||
"referral.inviteLink.title": "推荐链接",
|
||||
"referral.rules.antiAbuse": "如检测到通过不正当手段获取积分(如批量注册临时邮箱账号),相关账号将被永久封禁",
|
||||
|
||||
+5
-5
@@ -409,9 +409,9 @@
|
||||
"query-string": "^9.3.1",
|
||||
"random-words": "^2.0.1",
|
||||
"rc-util": "^5.44.4",
|
||||
"react": "19.2.5",
|
||||
"react": "19.2.7",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-dom": "19.2.5",
|
||||
"react-dom": "19.2.7",
|
||||
"react-fast-marquee": "^1.6.5",
|
||||
"react-hotkeys-hook": "^5.2.3",
|
||||
"react-i18next": "^16.5.3",
|
||||
@@ -420,7 +420,7 @@
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-router": "^8.0.0",
|
||||
"react-scan": "^0.5.3",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"react-wrap-balancer": "^1.1.1",
|
||||
@@ -578,8 +578,8 @@
|
||||
"lexical": "0.42.0",
|
||||
"node-gyp": "^12.4.0",
|
||||
"pdfjs-dist": "5.4.530",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"stylelint-config-clean-order": "7.0.0",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "3.2.6"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@lobechat/agent-manager-runtime": "workspace:*",
|
||||
"@lobechat/const": "workspace:*",
|
||||
"lucide-react": "*",
|
||||
"react-router-dom": "*"
|
||||
"react-router": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Avatar, Block, Flexbox, Markdown, Tag } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import type { CreateAgentParams, CreateAgentState } from '../../../types';
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"chat": "^4.23.0"
|
||||
"chat": "^4.23.0",
|
||||
"mime": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
|
||||
@@ -295,6 +295,27 @@ describe('QQAdapter', () => {
|
||||
expect(message?.attachments[0].type).toBe('file');
|
||||
});
|
||||
|
||||
it('should infer mime type from filename when content_type is the bare "file" label', async () => {
|
||||
// QQ delivers c2c file attachments with content_type === 'file' (a coarse
|
||||
// category, not a real MIME type). It must be recovered from the filename
|
||||
// so an .m4a is classified as audio instead of an unreadable document.
|
||||
const attachment = makeAttachment({
|
||||
content_type: 'file',
|
||||
filename: 'Broadstone Amelia 5.m4a',
|
||||
});
|
||||
const payload = makeWebhookPayload(QQ_EVENT_TYPES.GROUP_AT_MESSAGE_CREATE, {
|
||||
attachments: [attachment],
|
||||
content: 'audio file',
|
||||
});
|
||||
await adapter.handleWebhook(makeRequest(payload));
|
||||
|
||||
const factory = vi.mocked(mockChat.processMessage).mock.calls[0]?.[2];
|
||||
const message = await factory?.();
|
||||
|
||||
expect(message?.attachments[0].mimeType).toBe('audio/mp4');
|
||||
expect(message?.attachments[0].type).toBe('audio');
|
||||
});
|
||||
|
||||
it('should map multiple attachments', async () => {
|
||||
const attachments = [
|
||||
makeAttachment({ content_type: 'image/png', filename: 'a.png' }),
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
WebhookOptions,
|
||||
} from 'chat';
|
||||
import { Message, parseMarkdown } from 'chat';
|
||||
import mime from 'mime';
|
||||
|
||||
import { QQApiClient } from './api';
|
||||
import { signWebhookResponse } from './crypto';
|
||||
@@ -395,20 +396,35 @@ export class QQAdapter implements Adapter<QQThreadId, QQRawMessage> {
|
||||
if (!qqAttachments || qqAttachments.length === 0) return [];
|
||||
|
||||
return qqAttachments.map((a) => {
|
||||
const type = this.resolveAttachmentType(a.content_type);
|
||||
// QQ's `content_type` is not always a real MIME type — for c2c file
|
||||
// attachments it comes back as the coarse category label `"file"`. Trusting
|
||||
// it verbatim mislabels e.g. an `.m4a` as `"file"` instead of `audio/mp4`,
|
||||
// which then defeats the filename-based MIME recovery in ingestAttachment
|
||||
// (that only re-infers for `application/octet-stream`). Fall back to the
|
||||
// filename when content_type isn't a usable MIME type.
|
||||
const mimeType = this.resolveMimeType(a.content_type, a.filename);
|
||||
return {
|
||||
fetchData: () => this.fetchAttachmentData(a.url),
|
||||
height: a.height,
|
||||
mimeType: a.content_type,
|
||||
mimeType,
|
||||
name: a.filename,
|
||||
size: a.size,
|
||||
type,
|
||||
type: this.resolveAttachmentType(mimeType),
|
||||
url: a.url,
|
||||
width: a.width,
|
||||
} as Attachment;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a usable MIME type from QQ's `content_type`, falling back to
|
||||
* filename-based inference when QQ sends a non-MIME value (e.g. `"file"`).
|
||||
*/
|
||||
private resolveMimeType(contentType: string | undefined, filename?: string): string {
|
||||
if (contentType && contentType.includes('/')) return contentType;
|
||||
return (filename && mime.getType(filename)) || 'application/octet-stream';
|
||||
}
|
||||
|
||||
private resolveAttachmentType(contentType: string): 'image' | 'video' | 'audio' | 'file' {
|
||||
if (contentType.startsWith('image/')) return 'image';
|
||||
if (contentType.startsWith('video/')) return 'video';
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
HistorySummaryProvider,
|
||||
KnowledgeInjector,
|
||||
LocalSystemToolSnapshotInjector,
|
||||
ModelKnowledgeCutoffProvider,
|
||||
OnboardingActionHintInjector,
|
||||
OnboardingContextInjector,
|
||||
OnboardingSyntheticStateInjector,
|
||||
@@ -136,6 +137,7 @@ export class MessagesEngine {
|
||||
private buildProcessors(): ContextProcessor[] {
|
||||
const {
|
||||
model,
|
||||
modelKnowledgeCutoff,
|
||||
provider,
|
||||
systemRole,
|
||||
inputTemplate,
|
||||
@@ -244,6 +246,8 @@ export class MessagesEngine {
|
||||
}),
|
||||
// System date
|
||||
new SystemDateProvider({ enabled: isSystemDateEnabled, timezone }),
|
||||
// Model knowledge cutoff
|
||||
new ModelKnowledgeCutoffProvider({ knowledgeCutoff: modelKnowledgeCutoff }),
|
||||
// Skill context (available skills list + activated skill content).
|
||||
// Disabled in chat mode — pairs with the tools-engine gate so the LLM
|
||||
// sees neither the manifests nor the discovery prompt.
|
||||
|
||||
@@ -114,6 +114,35 @@ describe('MessagesEngine', () => {
|
||||
expect(result.messages[0].content).toBe(systemRole);
|
||||
});
|
||||
|
||||
it('should inject model knowledge cutoff when provided', async () => {
|
||||
const params = createBasicParams({
|
||||
modelKnowledgeCutoff: '2024-06',
|
||||
systemRole: 'You are a helpful assistant',
|
||||
});
|
||||
const engine = new MessagesEngine(params);
|
||||
|
||||
const result = await engine.process();
|
||||
|
||||
expect(result.messages[0]).toEqual({
|
||||
content: 'You are a helpful assistant\n\nModel knowledge cutoff: 2024-06',
|
||||
role: 'system',
|
||||
});
|
||||
expect(result.metadata.modelKnowledgeCutoffInjected).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip model knowledge cutoff injection when unknown', async () => {
|
||||
const params = createBasicParams({ systemRole: 'You are a helpful assistant' });
|
||||
const engine = new MessagesEngine(params);
|
||||
|
||||
const result = await engine.process();
|
||||
|
||||
expect(result.messages[0]).toEqual({
|
||||
content: 'You are a helpful assistant',
|
||||
role: 'system',
|
||||
});
|
||||
expect(result.metadata.modelKnowledgeCutoffInjected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should inject history summary when provided', async () => {
|
||||
const historySummary = 'We discussed AI and machine learning';
|
||||
const params = createBasicParams({ historySummary });
|
||||
|
||||
@@ -215,6 +215,8 @@ export interface MessagesEngineParams {
|
||||
messages: UIChatMessage[];
|
||||
/** Model ID */
|
||||
model: string;
|
||||
/** Model knowledge cutoff date, e.g. `2024-06`. Omit when unknown. */
|
||||
modelKnowledgeCutoff?: string;
|
||||
/** Provider ID */
|
||||
provider: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
|
||||
import type { PipelineContext, ProcessorOptions } from '../types';
|
||||
|
||||
declare module '../types' {
|
||||
interface PipelineContextMetadataOverrides {
|
||||
modelKnowledgeCutoffInjected?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const log = debug('context-engine:provider:ModelKnowledgeCutoffProvider');
|
||||
|
||||
export interface ModelKnowledgeCutoffProviderConfig {
|
||||
enabled?: boolean;
|
||||
knowledgeCutoff?: string;
|
||||
}
|
||||
|
||||
export class ModelKnowledgeCutoffProvider extends BaseSystemRoleProvider {
|
||||
readonly name = 'ModelKnowledgeCutoffProvider';
|
||||
|
||||
constructor(
|
||||
private config: ModelKnowledgeCutoffProviderConfig = {},
|
||||
options: ProcessorOptions = {},
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
protected buildSystemRoleContent(_context: PipelineContext): string | null {
|
||||
if (this.config.enabled === false) {
|
||||
log('Model knowledge cutoff injection disabled, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
const knowledgeCutoff = this.config.knowledgeCutoff?.trim();
|
||||
|
||||
if (!knowledgeCutoff) {
|
||||
log('No model knowledge cutoff configured, skipping injection');
|
||||
return null;
|
||||
}
|
||||
|
||||
return `Model knowledge cutoff: ${knowledgeCutoff}`;
|
||||
}
|
||||
|
||||
protected onInjected(context: PipelineContext): void {
|
||||
context.metadata.modelKnowledgeCutoffInjected = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ModelKnowledgeCutoffProvider } from '../ModelKnowledgeCutoffProvider';
|
||||
|
||||
const createContext = (messages: any[] = []) => ({
|
||||
initialState: {
|
||||
messages: [],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
systemRole: '',
|
||||
tools: [],
|
||||
},
|
||||
isAborted: false,
|
||||
messages,
|
||||
metadata: {
|
||||
maxTokens: 4096,
|
||||
model: 'gpt-4',
|
||||
},
|
||||
});
|
||||
|
||||
describe('ModelKnowledgeCutoffProvider', () => {
|
||||
it('should inject model knowledge cutoff', async () => {
|
||||
const provider = new ModelKnowledgeCutoffProvider({ knowledgeCutoff: '2024-06' });
|
||||
const context = createContext([
|
||||
{ content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() },
|
||||
]);
|
||||
|
||||
const result = await provider.process(context);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].role).toBe('system');
|
||||
expect(result.messages[0].content).toBe('Model knowledge cutoff: 2024-06');
|
||||
expect(result.metadata.modelKnowledgeCutoffInjected).toBe(true);
|
||||
});
|
||||
|
||||
it('should append cutoff to existing system message', async () => {
|
||||
const provider = new ModelKnowledgeCutoffProvider({ knowledgeCutoff: '2024-06' });
|
||||
const context = createContext([
|
||||
{
|
||||
content: 'You are a helpful assistant.',
|
||||
createdAt: Date.now(),
|
||||
id: 'sys',
|
||||
role: 'system',
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
{ content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() },
|
||||
]);
|
||||
|
||||
const result = await provider.process(context);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].content).toBe(
|
||||
'You are a helpful assistant.\n\nModel knowledge cutoff: 2024-06',
|
||||
);
|
||||
expect(result.metadata.modelKnowledgeCutoffInjected).toBe(true);
|
||||
});
|
||||
|
||||
it('should trim cutoff before injection', async () => {
|
||||
const provider = new ModelKnowledgeCutoffProvider({ knowledgeCutoff: ' 2024-06 ' });
|
||||
const context = createContext([]);
|
||||
|
||||
const result = await provider.process(context);
|
||||
|
||||
expect(result.messages[0].content).toBe('Model knowledge cutoff: 2024-06');
|
||||
});
|
||||
|
||||
it('should skip injection when cutoff is missing', async () => {
|
||||
const provider = new ModelKnowledgeCutoffProvider({});
|
||||
const context = createContext([
|
||||
{ content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() },
|
||||
]);
|
||||
|
||||
const result = await provider.process(context);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.metadata.modelKnowledgeCutoffInjected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip injection when disabled', async () => {
|
||||
const provider = new ModelKnowledgeCutoffProvider({
|
||||
enabled: false,
|
||||
knowledgeCutoff: '2024-06',
|
||||
});
|
||||
const context = createContext([
|
||||
{ content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() },
|
||||
]);
|
||||
|
||||
const result = await provider.process(context);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.metadata.modelKnowledgeCutoffInjected).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export { GroupContextInjector } from './GroupContextInjector';
|
||||
export { HistorySummaryProvider } from './HistorySummary';
|
||||
export { KnowledgeInjector } from './KnowledgeInjector';
|
||||
export { LocalSystemToolSnapshotInjector } from './LocalSystemToolSnapshotInjector';
|
||||
export { ModelKnowledgeCutoffProvider } from './ModelKnowledgeCutoffProvider';
|
||||
export { OnboardingActionHintInjector } from './OnboardingActionHintInjector';
|
||||
export { OnboardingContextInjector } from './OnboardingContextInjector';
|
||||
export { OnboardingSyntheticStateInjector } from './OnboardingSyntheticStateInjector';
|
||||
@@ -90,6 +91,7 @@ export type {
|
||||
export type { HistorySummaryConfig } from './HistorySummary';
|
||||
export type { KnowledgeInjectorConfig } from './KnowledgeInjector';
|
||||
export type { LocalSystemToolSnapshotInjectorConfig } from './LocalSystemToolSnapshotInjector';
|
||||
export type { ModelKnowledgeCutoffProviderConfig } from './ModelKnowledgeCutoffProvider';
|
||||
export type {
|
||||
OnboardingContext,
|
||||
OnboardingContextInjectorConfig,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Combined workspace-scoped DB rollout (formerly two separate 0111 migrations):
|
||||
-- 1. ai_infra surrogate `_id` PK + workspace-scoped partial uniques (LOBE-10056)
|
||||
-- 2. workspace-scoped device unique + workspace `frozen` columns (LOBE-10315)
|
||||
--
|
||||
-- The two parts touch disjoint tables (ai_providers / ai_models vs.
|
||||
-- devices / workspaces). Every statement is guarded so the migration is a
|
||||
-- NO-OP on databases that already have the shape (cloud production, where the
|
||||
-- ai_infra side was applied online via manual steps) and a full rebuild on
|
||||
-- fresh / self-hosted databases.
|
||||
|
||||
-- ===========================================================================
|
||||
-- Part 1 — ai_infra surrogate `_id` PK + workspace-scoped partial uniques
|
||||
-- (LOBE-10056 Phase 5)
|
||||
--
|
||||
-- On cloud production this whole part is a NO-OP: the manual steps [3]~[7]
|
||||
-- (LOBE-10073 .. LOBE-10077) already performed the backfill, NOT NULL, PK swap
|
||||
-- and partial indexes online / CONCURRENTLY. Every statement below is guarded
|
||||
-- (UPDATE … WHERE _id IS NULL / IF EXISTS / catalog check / IF NOT EXISTS) so
|
||||
-- it skips cleanly there, while still fully rebuilding the schema on a fresh or
|
||||
-- self-hosted database (where [3]~[7] never ran).
|
||||
-- ===========================================================================
|
||||
|
||||
-- 1) backfill rows still missing _id (no-op on prod; fills self-host history) --
|
||||
UPDATE "ai_providers" SET "_id" = gen_random_uuid() WHERE "_id" IS NULL;--> statement-breakpoint
|
||||
UPDATE "ai_models" SET "_id" = gen_random_uuid() WHERE "_id" IS NULL;--> statement-breakpoint
|
||||
|
||||
-- 2) enforce NOT NULL (no-op if already set) --
|
||||
ALTER TABLE "ai_providers" ALTER COLUMN "_id" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "ai_models" ALTER COLUMN "_id" SET NOT NULL;--> statement-breakpoint
|
||||
|
||||
-- 3) drop old composite PKs (no-op on prod, already dropped in [7]) --
|
||||
ALTER TABLE "ai_providers" DROP CONSTRAINT IF EXISTS "ai_providers_id_user_id_pk";--> statement-breakpoint
|
||||
ALTER TABLE "ai_models" DROP CONSTRAINT IF EXISTS "ai_models_id_provider_id_user_id_pk";--> statement-breakpoint
|
||||
|
||||
-- 4) promote _id to PK only when the table has no PK yet
|
||||
-- (Postgres has no `ADD PRIMARY KEY IF NOT EXISTS`; guard via pg_constraint) --
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conrelid = 'ai_providers'::regclass AND contype = 'p'
|
||||
) THEN
|
||||
ALTER TABLE "ai_providers" ADD CONSTRAINT "ai_providers_pkey" PRIMARY KEY ("_id");
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conrelid = 'ai_models'::regclass AND contype = 'p'
|
||||
) THEN
|
||||
ALTER TABLE "ai_models" ADD CONSTRAINT "ai_models_pkey" PRIMARY KEY ("_id");
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
|
||||
-- 5) workspace-scoped partial unique indexes (no-op on prod, already built in [6]) --
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ai_providers_id_user_id_unique" ON "ai_providers" USING btree ("id","user_id") WHERE "workspace_id" IS NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ai_providers_id_user_id_workspace_id_unique" ON "ai_providers" USING btree ("id","user_id","workspace_id") WHERE "workspace_id" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ai_models_id_provider_id_user_id_unique" ON "ai_models" USING btree ("id","provider_id","user_id") WHERE "workspace_id" IS NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ai_models_id_provider_id_user_id_workspace_id_unique" ON "ai_models" USING btree ("id","provider_id","user_id","workspace_id") WHERE "workspace_id" IS NOT NULL;--> statement-breakpoint
|
||||
|
||||
-- ===========================================================================
|
||||
-- Part 2 — workspace-scoped device unique + workspace `frozen` columns
|
||||
-- (LOBE-10315)
|
||||
--
|
||||
-- Replace the full (user_id, device_id) unique with two partial uniques scoped
|
||||
-- by workspace_id (null vs. not null), so personal and workspace-enrolled rows
|
||||
-- live in independent identity spaces. Also add the workspace freeze trio
|
||||
-- (mirrors users.banned) backing cloud workspace-freeze risk control.
|
||||
-- ===========================================================================
|
||||
|
||||
DROP INDEX IF EXISTS "devices_user_id_device_id_unique";--> statement-breakpoint
|
||||
ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "frozen" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "frozen_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "frozen_at" timestamp with time zone;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "devices_workspace_id_device_id_unique" ON "devices" USING btree ("workspace_id","device_id") WHERE "devices"."workspace_id" IS NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "devices_user_id_device_id_unique" ON "devices" USING btree ("user_id","device_id") WHERE "devices"."workspace_id" IS NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -777,7 +777,14 @@
|
||||
"when": 1780832120210,
|
||||
"tag": "0110_add_verify_tables_and_ai_infra_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 111,
|
||||
"version": "7",
|
||||
"when": 1781883177374,
|
||||
"tag": "0111_workspace_device_and_ai_infra_surrogate_pk",
|
||||
"breakpoints": true
|
||||
}
|
||||
],
|
||||
"version": "6"
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,20 @@ import { and, eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { devices, users } from '../../schemas';
|
||||
import { devices, users, workspaces } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { DeviceModel } from '../device';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'device-model-test-user-id';
|
||||
const otherUserId = 'device-model-other-user';
|
||||
const wsId = 'device-model-ws-1';
|
||||
const deviceModel = new DeviceModel(serverDB, userId);
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: 'device-model-other-user' }]);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -113,6 +115,82 @@ describe('DeviceModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace devices', () => {
|
||||
beforeEach(async () => {
|
||||
await serverDB
|
||||
.insert(workspaces)
|
||||
.values({ id: wsId, name: 'WS 1', primaryOwnerId: userId, slug: 'device-model-ws-1-slug' });
|
||||
});
|
||||
|
||||
it('queryPersonal excludes workspace-enrolled rows', async () => {
|
||||
await deviceModel.register({ deviceId: 'p1', identitySource: 'machine-id' });
|
||||
// an admin-enrolled workspace device owned by this user
|
||||
await serverDB
|
||||
.insert(devices)
|
||||
.values({ deviceId: 'w1', identitySource: 'machine-id', userId, workspaceId: wsId });
|
||||
|
||||
const personal = await deviceModel.queryPersonal();
|
||||
expect(personal.map((d) => d.deviceId)).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('queryWorkspaceDevices returns every enrolled device (any owner), scoped to the workspace', async () => {
|
||||
// enrolled by two different admins into the same workspace
|
||||
await serverDB.insert(devices).values([
|
||||
{ deviceId: 'w1', identitySource: 'machine-id', userId, workspaceId: wsId },
|
||||
{ deviceId: 'w2', identitySource: 'machine-id', userId: otherUserId, workspaceId: wsId },
|
||||
]);
|
||||
// a personal device must not appear
|
||||
await deviceModel.register({ deviceId: 'p1', identitySource: 'machine-id' });
|
||||
|
||||
const wsModel = new DeviceModel(serverDB, userId, wsId);
|
||||
const ids = (await wsModel.queryWorkspaceDevices()).map((d) => d.deviceId).sort();
|
||||
expect(ids).toEqual(['w1', 'w2']);
|
||||
});
|
||||
|
||||
it('queryWorkspaceDevices returns [] without workspace context', async () => {
|
||||
await serverDB
|
||||
.insert(devices)
|
||||
.values({ deviceId: 'w1', identitySource: 'machine-id', userId, workspaceId: wsId });
|
||||
expect(await deviceModel.queryWorkspaceDevices()).toEqual([]);
|
||||
});
|
||||
|
||||
it('dedupes a machine enrolled into one workspace by different admins to a single row', async () => {
|
||||
// admin A enrolls machine "wdev" into the workspace
|
||||
await new DeviceModel(serverDB, userId, wsId).registerWorkspaceDevice({
|
||||
deviceId: 'wdev',
|
||||
hostname: 'A-host',
|
||||
identitySource: 'machine-id',
|
||||
workspaceId: wsId,
|
||||
});
|
||||
// admin B enrolls the SAME machine (same deviceId) into the SAME workspace
|
||||
await new DeviceModel(serverDB, otherUserId, wsId).registerWorkspaceDevice({
|
||||
deviceId: 'wdev',
|
||||
hostname: 'B-host',
|
||||
identitySource: 'machine-id',
|
||||
workspaceId: wsId,
|
||||
});
|
||||
|
||||
const rows = (await new DeviceModel(serverDB, userId, wsId).queryWorkspaceDevices()).filter(
|
||||
(d) => d.deviceId === 'wdev',
|
||||
);
|
||||
// one row, not two — (workspace_id, device_id) is unique
|
||||
expect(rows).toHaveLength(1);
|
||||
// the original enroller is preserved; only machine fields are refreshed
|
||||
expect(rows[0].userId).toBe(userId);
|
||||
expect(rows[0].hostname).toBe('B-host');
|
||||
});
|
||||
|
||||
it('findWorkspaceDeviceById is scoped to the workspace', async () => {
|
||||
await serverDB
|
||||
.insert(devices)
|
||||
.values({ deviceId: 'w1', identitySource: 'machine-id', userId, workspaceId: wsId });
|
||||
const wsModel = new DeviceModel(serverDB, userId, wsId);
|
||||
expect((await wsModel.findWorkspaceDeviceById('w1'))?.deviceId).toBe('w1');
|
||||
// a non-workspace model never resolves it
|
||||
expect(await deviceModel.findWorkspaceDeviceById('w1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update user-editable fields', async () => {
|
||||
await deviceModel.register({ deviceId: 'dev-1', identitySource: 'machine-id' });
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
// @vitest-environment node
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { agents, chatGroups, messages, sessions, topics, users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { TopicModel } from '../topic';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'topic-model-test-user';
|
||||
const otherUserId = 'topic-model-test-other-user';
|
||||
|
||||
const topicModel = new TopicModel(serverDB, userId);
|
||||
|
||||
const now = () => new Date();
|
||||
const minutesAgo = (n: number) => new Date(Date.now() - n * 60 * 1000);
|
||||
|
||||
describe('TopicModel', () => {
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a topic owned by the calling user with null owner columns by default', async () => {
|
||||
const topic = await topicModel.create({ title: 'Hello' });
|
||||
|
||||
expect(topic.title).toBe('Hello');
|
||||
expect(topic.userId).toBe(userId);
|
||||
expect(topic.agentId).toBeNull();
|
||||
expect(topic.sessionId).toBeNull();
|
||||
expect(topic.groupId).toBeNull();
|
||||
// personal mode → workspaceId stays null
|
||||
expect(topic.workspaceId).toBeNull();
|
||||
});
|
||||
|
||||
it('coerces falsy owner ids to null', async () => {
|
||||
const topic = await topicModel.create({
|
||||
agentId: '',
|
||||
groupId: '',
|
||||
sessionId: '',
|
||||
title: 'falsy owners',
|
||||
});
|
||||
|
||||
expect(topic.agentId).toBeNull();
|
||||
expect(topic.groupId).toBeNull();
|
||||
expect(topic.sessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('attaches given messages to the new topic in a transaction', async () => {
|
||||
await serverDB.insert(messages).values([
|
||||
{ content: 'm1', id: 'msg-1', role: 'user', userId },
|
||||
{ content: 'm2', id: 'msg-2', role: 'assistant', userId },
|
||||
]);
|
||||
|
||||
const topic = await topicModel.create({ messages: ['msg-1', 'msg-2'], title: 'with msgs' });
|
||||
|
||||
const linked = await serverDB
|
||||
.select({ id: messages.id, topicId: messages.topicId })
|
||||
.from(messages)
|
||||
.where(eq(messages.topicId, topic.id));
|
||||
|
||||
expect(linked.map((m) => m.id).sort()).toEqual(['msg-1', 'msg-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchCreate', () => {
|
||||
it('keeps a session topic session-scoped and a group topic group-scoped', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-b', userId });
|
||||
await serverDB.insert(sessions).values({ id: 'session-x', userId });
|
||||
await serverDB.insert(chatGroups).values({ id: 'group-b', userId });
|
||||
|
||||
const created = await topicModel.batchCreate([
|
||||
{ agentId: 'agent-b', sessionId: 'session-x', title: 'session topic' },
|
||||
{ groupId: 'group-b', title: 'group topic' },
|
||||
]);
|
||||
|
||||
const bySession = created.find((t) => t.title === 'session topic')!;
|
||||
const byGroup = created.find((t) => t.title === 'group topic')!;
|
||||
|
||||
// sessionId given (no groupId) → sessionId kept, groupId stays null
|
||||
expect(bySession.sessionId).toBe('session-x');
|
||||
expect(bySession.groupId).toBeNull();
|
||||
|
||||
// groupId given (no sessionId) → groupId kept, sessionId stays null
|
||||
expect(byGroup.groupId).toBe('group-b');
|
||||
expect(byGroup.sessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('drops both owner ids when sessionId and groupId are passed together', async () => {
|
||||
await serverDB.insert(sessions).values({ id: 'session-both', userId });
|
||||
await serverDB.insert(chatGroups).values({ id: 'group-both', userId });
|
||||
|
||||
// Each field is nulled based on the *other* being present, so passing both
|
||||
// detaches the topic from both — callers must pick exactly one.
|
||||
const [created] = await topicModel.batchCreate([
|
||||
{ groupId: 'group-both', sessionId: 'session-both', title: 'ambiguous' },
|
||||
]);
|
||||
|
||||
expect(created.sessionId).toBeNull();
|
||||
expect(created.groupId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns the topic for the owner', async () => {
|
||||
const topic = await topicModel.create({ title: 'findable' });
|
||||
const found = await topicModel.findById(topic.id);
|
||||
expect(found?.id).toBe(topic.id);
|
||||
});
|
||||
|
||||
it('does not return a topic owned by another user', async () => {
|
||||
await serverDB
|
||||
.insert(topics)
|
||||
.values({ id: 'topic-foreign', title: 'nope', userId: otherUserId });
|
||||
|
||||
const found = await topicModel.findById('topic-foreign');
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
it('orders favorites first then by recent activity', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-q', userId });
|
||||
await serverDB.insert(topics).values([
|
||||
{
|
||||
agentId: 'agent-q',
|
||||
id: 't-fav',
|
||||
title: 'fav',
|
||||
favorite: true,
|
||||
updatedAt: minutesAgo(60),
|
||||
userId,
|
||||
},
|
||||
{ agentId: 'agent-q', id: 't-new', title: 'new', updatedAt: minutesAgo(1), userId },
|
||||
{ agentId: 'agent-q', id: 't-old', title: 'old', updatedAt: minutesAgo(30), userId },
|
||||
]);
|
||||
|
||||
const { items, total } = await topicModel.query({ agentId: 'agent-q' });
|
||||
|
||||
expect(total).toBe(3);
|
||||
expect(items.map((t) => t.id)).toEqual(['t-fav', 't-new', 't-old']);
|
||||
});
|
||||
|
||||
it('adopts orphan rows for the inbox agent only', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-inbox', slug: 'inbox', userId });
|
||||
await serverDB.insert(topics).values([
|
||||
{ agentId: 'agent-inbox', id: 't-direct', title: 'direct', userId },
|
||||
// legacy orphan: every owner column null
|
||||
{ id: 't-orphan', title: 'orphan', userId },
|
||||
]);
|
||||
|
||||
const inbox = await topicModel.query({ agentId: 'agent-inbox', isInbox: true });
|
||||
expect(inbox.items.map((t) => t.id).sort()).toEqual(['t-direct', 't-orphan']);
|
||||
|
||||
const nonInbox = await topicModel.query({ agentId: 'agent-inbox' });
|
||||
expect(nonInbox.items.map((t) => t.id)).toEqual(['t-direct']);
|
||||
});
|
||||
|
||||
it('filters by groupId directly', async () => {
|
||||
await serverDB.insert(chatGroups).values({ id: 'group-q', userId });
|
||||
await serverDB.insert(topics).values([
|
||||
{ groupId: 'group-q', id: 't-g1', title: 'g1', userId },
|
||||
{ id: 't-no-group', title: 'no group', userId },
|
||||
]);
|
||||
|
||||
const { items } = await topicModel.query({ groupId: 'group-q' });
|
||||
expect(items.map((t) => t.id)).toEqual(['t-g1']);
|
||||
});
|
||||
|
||||
describe('status filtering & ordering', () => {
|
||||
it('excludes topics whose status is in excludeStatuses but keeps null status', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-s', userId });
|
||||
await serverDB.insert(topics).values([
|
||||
{ agentId: 'agent-s', id: 't-active', status: 'active', title: 'active', userId },
|
||||
{ agentId: 'agent-s', id: 't-done', status: 'completed', title: 'done', userId },
|
||||
{ agentId: 'agent-s', id: 't-null', title: 'null status', userId },
|
||||
]);
|
||||
|
||||
const { items } = await topicModel.query({
|
||||
agentId: 'agent-s',
|
||||
excludeStatuses: ['completed'],
|
||||
});
|
||||
|
||||
expect(items.map((t) => t.id).sort()).toEqual(['t-active', 't-null']);
|
||||
});
|
||||
|
||||
it('orders by status priority floating unread above active/completed', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-rank', userId });
|
||||
// all share the same activity time so only the status rank decides order
|
||||
const at = minutesAgo(5);
|
||||
await serverDB.insert(topics).values([
|
||||
{
|
||||
agentId: 'agent-rank',
|
||||
id: 't-completed',
|
||||
status: 'completed',
|
||||
title: 'c',
|
||||
updatedAt: at,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-rank',
|
||||
id: 't-active',
|
||||
status: 'active',
|
||||
title: 'a',
|
||||
updatedAt: at,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-rank',
|
||||
id: 't-unread',
|
||||
status: 'unread',
|
||||
title: 'u',
|
||||
updatedAt: at,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-rank',
|
||||
id: 't-waiting',
|
||||
status: 'waitingForHuman',
|
||||
title: 'w',
|
||||
updatedAt: at,
|
||||
userId,
|
||||
},
|
||||
]);
|
||||
|
||||
const { items } = await topicModel.query({ agentId: 'agent-rank', sortBy: 'status' });
|
||||
|
||||
// waitingForHuman(0) < unread(2) < active(4) < completed(6)
|
||||
expect(items.map((t) => t.id)).toEqual([
|
||||
't-waiting',
|
||||
't-unread',
|
||||
't-active',
|
||||
't-completed',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger filtering', () => {
|
||||
beforeEach(async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-trig', userId });
|
||||
await serverDB.insert(topics).values([
|
||||
{ agentId: 'agent-trig', id: 't-chat', title: 'chat', trigger: 'chat', userId },
|
||||
{ agentId: 'agent-trig', id: 't-cron', title: 'cron', trigger: 'cron', userId },
|
||||
{ agentId: 'agent-trig', id: 't-none', title: 'none', userId },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps only the requested triggers when `triggers` is set', async () => {
|
||||
const { items } = await topicModel.query({ agentId: 'agent-trig', triggers: ['cron'] });
|
||||
expect(items.map((t) => t.id)).toEqual(['t-cron']);
|
||||
});
|
||||
|
||||
it('drops excluded triggers but keeps null-trigger topics', async () => {
|
||||
const { items } = await topicModel.query({
|
||||
agentId: 'agent-trig',
|
||||
excludeTriggers: ['cron'],
|
||||
});
|
||||
expect(items.map((t) => t.id).sort()).toEqual(['t-chat', 't-none']);
|
||||
});
|
||||
|
||||
it('includeTriggers takes precedence over excludeTriggers', async () => {
|
||||
const { items } = await topicModel.query({
|
||||
agentId: 'agent-trig',
|
||||
excludeTriggers: ['cron'],
|
||||
includeTriggers: ['cron'],
|
||||
});
|
||||
expect(items.map((t) => t.id)).toEqual(['t-cron']);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns card-detail columns only when withDetails is set', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-d', userId });
|
||||
await serverDB.insert(topics).values({
|
||||
agentId: 'agent-d',
|
||||
description: 'desc',
|
||||
id: 't-detail',
|
||||
title: 'detail',
|
||||
trigger: 'chat',
|
||||
userId,
|
||||
});
|
||||
await serverDB.insert(messages).values([
|
||||
{ content: 'first user message', id: 'dm-1', role: 'user', topicId: 't-detail', userId },
|
||||
{ content: 'assistant reply', id: 'dm-2', role: 'assistant', topicId: 't-detail', userId },
|
||||
]);
|
||||
|
||||
const lean = await topicModel.query({ agentId: 'agent-d' });
|
||||
expect(lean.items[0]).not.toHaveProperty('firstUserMessage');
|
||||
expect(lean.items[0]).not.toHaveProperty('messageCount');
|
||||
|
||||
const detailed = await topicModel.query({ agentId: 'agent-d', withDetails: true });
|
||||
expect(detailed.items[0]).toMatchObject({
|
||||
description: 'desc',
|
||||
firstUserMessage: 'first user message',
|
||||
messageCount: 2,
|
||||
trigger: 'chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryTopics', () => {
|
||||
it('filters by the given statuses and is scoped to the owner', async () => {
|
||||
await serverDB.insert(topics).values([
|
||||
{ id: 't-running', status: 'running', title: 'r', userId },
|
||||
{ id: 't-active', status: 'active', title: 'a', userId },
|
||||
{ id: 't-running-other', status: 'running', title: 'ro', userId: otherUserId },
|
||||
]);
|
||||
|
||||
const result = await topicModel.queryTopics({ statuses: ['running'] });
|
||||
expect(result.map((t) => t.id)).toEqual(['t-running']);
|
||||
});
|
||||
|
||||
it('returns all owned topics when no statuses filter is given', async () => {
|
||||
await serverDB.insert(topics).values([
|
||||
{ id: 't1', status: 'running', title: '1', userId },
|
||||
{ id: 't2', status: 'active', title: '2', userId },
|
||||
]);
|
||||
|
||||
const result = await topicModel.queryTopics();
|
||||
expect(result.map((t) => t.id).sort()).toEqual(['t1', 't2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('count', () => {
|
||||
it('counts all owned topics and can scope to an agent', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-c', userId });
|
||||
await serverDB.insert(topics).values([
|
||||
{ agentId: 'agent-c', id: 'c1', title: '1', userId },
|
||||
{ id: 'c2', title: '2', userId },
|
||||
{ id: 'c-other', title: 'x', userId: otherUserId },
|
||||
]);
|
||||
|
||||
expect(await topicModel.count()).toBe(2);
|
||||
expect(await topicModel.count({ agentId: 'agent-c' })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates status and bumps updatedAt', async () => {
|
||||
const topic = await topicModel.create({ title: 'to update' });
|
||||
const before = topic.updatedAt.getTime();
|
||||
|
||||
const [updated] = await topicModel.update(topic.id, { status: 'unread' });
|
||||
expect(updated.status).toBe('unread');
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
const [cleared] = await topicModel.update(topic.id, { status: 'active' });
|
||||
expect(cleared.status).toBe('active');
|
||||
});
|
||||
|
||||
it('does not update a topic owned by another user', async () => {
|
||||
await serverDB
|
||||
.insert(topics)
|
||||
.values({ id: 't-foreign-upd', status: 'active', title: 'foreign', userId: otherUserId });
|
||||
|
||||
const result = await topicModel.update('t-foreign-upd', { status: 'unread' });
|
||||
expect(result).toHaveLength(0);
|
||||
|
||||
const [row] = await serverDB.select().from(topics).where(eq(topics.id, 't-foreign-upd'));
|
||||
expect(row.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMetadata', () => {
|
||||
it('merges new metadata into existing metadata', async () => {
|
||||
const topic = await topicModel.create({
|
||||
metadata: { model: 'gpt-4', provider: 'openai' },
|
||||
title: 'meta',
|
||||
});
|
||||
|
||||
const [updated] = await topicModel.updateMetadata(topic.id, { workingDirectory: '/tmp' });
|
||||
|
||||
expect(updated.metadata).toMatchObject({
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
workingDirectory: '/tmp',
|
||||
});
|
||||
});
|
||||
|
||||
it('deep-merges the onboardingSession sub-object', async () => {
|
||||
const topic = await topicModel.create({
|
||||
metadata: {
|
||||
onboardingSession: {
|
||||
lastActiveAt: '2026-01-01',
|
||||
phase: 'discovery',
|
||||
startedAt: '2026-01-01',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
title: 'onboarding',
|
||||
});
|
||||
|
||||
const [updated] = await topicModel.updateMetadata(topic.id, {
|
||||
onboardingSession: { phase: 'summary' },
|
||||
});
|
||||
|
||||
expect(updated.metadata?.onboardingSession).toMatchObject({
|
||||
lastActiveAt: '2026-01-01',
|
||||
phase: 'summary',
|
||||
startedAt: '2026-01-01',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes a single owned topic', async () => {
|
||||
const topic = await topicModel.create({ title: 'del' });
|
||||
await topicModel.delete(topic.id);
|
||||
expect(await topicModel.findById(topic.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('batch deletes only the given ids', async () => {
|
||||
await serverDB.insert(topics).values([
|
||||
{ id: 'b1', title: '1', userId },
|
||||
{ id: 'b2', title: '2', userId },
|
||||
{ id: 'b3', title: '3', userId },
|
||||
]);
|
||||
|
||||
await topicModel.batchDelete(['b1', 'b2']);
|
||||
|
||||
const remaining = await topicModel.queryTopics();
|
||||
expect(remaining.map((t) => t.id)).toEqual(['b3']);
|
||||
});
|
||||
|
||||
it('deleteAll removes only the calling user rows', async () => {
|
||||
await serverDB.insert(topics).values([
|
||||
{ id: 'mine-1', title: '1', userId },
|
||||
{ id: 'theirs-1', title: '2', userId: otherUserId },
|
||||
]);
|
||||
|
||||
await topicModel.deleteAll();
|
||||
|
||||
expect(await topicModel.queryTopics()).toHaveLength(0);
|
||||
const theirs = await serverDB.select().from(topics).where(eq(topics.id, 'theirs-1'));
|
||||
expect(theirs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('batchDeleteByAgentId removes all topics under one agent', async () => {
|
||||
await serverDB.insert(agents).values([
|
||||
{ id: 'agent-del', userId },
|
||||
{ id: 'agent-keep', userId },
|
||||
]);
|
||||
await serverDB.insert(topics).values([
|
||||
{ agentId: 'agent-del', id: 'd1', title: '1', userId },
|
||||
{ agentId: 'agent-del', id: 'd2', title: '2', userId },
|
||||
{ agentId: 'agent-keep', id: 'k1', title: '3', userId },
|
||||
]);
|
||||
|
||||
await topicModel.batchDeleteByAgentId('agent-del');
|
||||
|
||||
const remaining = await topicModel.queryTopics();
|
||||
expect(remaining.map((t) => t.id)).toEqual(['k1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate', () => {
|
||||
it('copies the topic and its messages under a new id', async () => {
|
||||
const topic = await topicModel.create({ title: 'original' });
|
||||
await serverDB.insert(messages).values([
|
||||
{ content: 'hi', id: 'dup-m1', role: 'user', topicId: topic.id, userId },
|
||||
{ content: 'yo', id: 'dup-m2', role: 'assistant', topicId: topic.id, userId },
|
||||
]);
|
||||
|
||||
const { topic: cloned, messages: clonedMessages } = await topicModel.duplicate(
|
||||
topic.id,
|
||||
'copy',
|
||||
);
|
||||
|
||||
expect(cloned.id).not.toBe(topic.id);
|
||||
expect(cloned.title).toBe('copy');
|
||||
expect(clonedMessages).toHaveLength(2);
|
||||
expect(clonedMessages.every((m) => m.topicId === cloned.id)).toBe(true);
|
||||
expect(clonedMessages.map((m) => m.id)).not.toContain('dup-m1');
|
||||
});
|
||||
|
||||
it('throws when the source topic does not exist', async () => {
|
||||
await expect(topicModel.duplicate('nope')).rejects.toThrow('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchMoveToAgent', () => {
|
||||
it('reassigns agentId, clears sessionId, and moves child messages', async () => {
|
||||
await serverDB.insert(agents).values([
|
||||
{ id: 'agent-src', userId },
|
||||
{ id: 'agent-dst', userId },
|
||||
]);
|
||||
await serverDB.insert(topics).values({
|
||||
agentId: 'agent-src',
|
||||
id: 'move-1',
|
||||
sessionId: null,
|
||||
title: 'movable',
|
||||
userId,
|
||||
});
|
||||
await serverDB.insert(messages).values({
|
||||
agentId: 'agent-src',
|
||||
content: 'm',
|
||||
id: 'move-msg',
|
||||
role: 'user',
|
||||
topicId: 'move-1',
|
||||
userId,
|
||||
});
|
||||
|
||||
await topicModel.batchMoveToAgent(['move-1'], 'agent-dst');
|
||||
|
||||
const [topic] = await serverDB.select().from(topics).where(eq(topics.id, 'move-1'));
|
||||
expect(topic.agentId).toBe('agent-dst');
|
||||
expect(topic.sessionId).toBeNull();
|
||||
|
||||
const [msg] = await serverDB.select().from(messages).where(eq(messages.id, 'move-msg'));
|
||||
expect(msg.agentId).toBe('agent-dst');
|
||||
});
|
||||
|
||||
it('throws when the target agent is not accessible', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-foreign', userId: otherUserId });
|
||||
await serverDB.insert(topics).values({ id: 'move-x', title: 'x', userId });
|
||||
|
||||
await expect(topicModel.batchMoveToAgent(['move-x'], 'agent-foreign')).rejects.toThrow(
|
||||
'not found or not accessible',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op for an empty id list', async () => {
|
||||
await expect(topicModel.batchMoveToAgent([], 'whatever')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCronTopicsGroupedByCronJob', () => {
|
||||
it('groups cron-triggered topics by their cronJobId and skips topics without one', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-cron', userId });
|
||||
await serverDB.insert(topics).values([
|
||||
{
|
||||
agentId: 'agent-cron',
|
||||
id: 'cron-a1',
|
||||
metadata: { cronJobId: 'job-a' },
|
||||
title: 'a1',
|
||||
trigger: 'cron',
|
||||
userId,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-cron',
|
||||
id: 'cron-a2',
|
||||
metadata: { cronJobId: 'job-a' },
|
||||
title: 'a2',
|
||||
trigger: 'cron',
|
||||
userId,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-cron',
|
||||
id: 'cron-b1',
|
||||
metadata: { cronJobId: 'job-b' },
|
||||
title: 'b1',
|
||||
trigger: 'cron',
|
||||
userId,
|
||||
},
|
||||
// cron trigger but no cronJobId → excluded by the SQL filter
|
||||
{
|
||||
agentId: 'agent-cron',
|
||||
id: 'cron-nojob',
|
||||
metadata: {},
|
||||
title: 'nojob',
|
||||
trigger: 'cron',
|
||||
userId,
|
||||
},
|
||||
]);
|
||||
|
||||
const grouped = await topicModel.getCronTopicsGroupedByCronJob('agent-cron');
|
||||
const byJob = Object.fromEntries(grouped.map((g) => [g.cronJobId, g.topics.length]));
|
||||
|
||||
expect(byJob).toEqual({ 'job-a': 2, 'job-b': 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryRecent', () => {
|
||||
it('orders recent topics by latest activity and tags type', async () => {
|
||||
await serverDB.insert(agents).values({ id: 'agent-recent', slug: 'inbox', userId });
|
||||
await serverDB.insert(chatGroups).values({ id: 'group-recent', userId });
|
||||
await serverDB.insert(topics).values([
|
||||
{
|
||||
agentId: 'agent-recent',
|
||||
id: 'r-agent',
|
||||
title: 'agent',
|
||||
updatedAt: minutesAgo(10),
|
||||
userId,
|
||||
},
|
||||
{
|
||||
groupId: 'group-recent',
|
||||
id: 'r-group',
|
||||
title: 'group',
|
||||
updatedAt: minutesAgo(1),
|
||||
userId,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await topicModel.queryRecent();
|
||||
expect(result.map((t) => t.id)).toEqual(['r-group', 'r-agent']);
|
||||
expect(result.find((t) => t.id === 'r-group')?.type).toBe('group');
|
||||
expect(result.find((t) => t.id === 'r-agent')?.type).toBe('agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTopicsForMemoryExtractor', () => {
|
||||
it('omits topics already marked completed unless ignoreExtracted is set', async () => {
|
||||
await serverDB.insert(topics).values([
|
||||
{ createdAt: minutesAgo(2), id: 'mem-pending', title: 'pending', userId },
|
||||
{
|
||||
createdAt: minutesAgo(1),
|
||||
id: 'mem-done',
|
||||
metadata: { userMemoryExtractStatus: 'completed' },
|
||||
title: 'done',
|
||||
userId,
|
||||
},
|
||||
]);
|
||||
|
||||
const pendingOnly = await topicModel.listTopicsForMemoryExtractor();
|
||||
expect(pendingOnly.map((t) => t.id)).toEqual(['mem-pending']);
|
||||
|
||||
const all = await topicModel.listTopicsForMemoryExtractor({ ignoreExtracted: true });
|
||||
expect(all.map((t) => t.id).sort()).toEqual(['mem-done', 'mem-pending']);
|
||||
});
|
||||
|
||||
it('countTopicsForMemoryExtractor matches the list length', async () => {
|
||||
await serverDB.insert(topics).values([
|
||||
{ id: 'mem-1', title: '1', userId },
|
||||
{
|
||||
id: 'mem-2',
|
||||
metadata: { userMemoryExtractStatus: 'completed' },
|
||||
title: '2',
|
||||
userId,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await topicModel.countTopicsForMemoryExtractor()).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkingDirEntry } from '@lobechat/types';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import type { DeviceItem } from '../schemas';
|
||||
import { devices } from '../schemas';
|
||||
@@ -20,23 +20,31 @@ export interface UpdateDeviceParams {
|
||||
}
|
||||
|
||||
/**
|
||||
* Devices are intentionally USER-LEVEL, not workspace-scoped.
|
||||
* Two distinct kinds of device live in this table, told apart by `workspace_id`:
|
||||
*
|
||||
* Even though the `devices` table carries a nullable `workspace_id` column, a
|
||||
* physical machine belongs to the user across every workspace they're in (the
|
||||
* unique key is `(userId, deviceId)`). This model therefore scopes all reads
|
||||
* and writes by `userId` only and deliberately does NOT take a `workspaceId`
|
||||
* argument or use `buildWorkspaceWhere` / `buildWorkspacePayload`. Switching it
|
||||
* to workspace-scoped lookups would hide a user's own device inside their
|
||||
* workspaces. See the matching note on `devices.workspaceId` in the schema.
|
||||
* - **Personal devices** (`workspace_id IS NULL`): a user's own machine, keyed
|
||||
* by `(userId, deviceId)`. The personal read/write path (`query` / `register`
|
||||
* / `update` / `delete` / `findByDeviceId`) is scoped by `userId` and must
|
||||
* stay that way — a user's machine belongs to them across all their
|
||||
* workspaces.
|
||||
* - **Workspace devices** (`workspace_id = <ws>`): a machine enrolled into a
|
||||
* workspace by an admin (e.g. a shared build server). Owned by the workspace,
|
||||
* reachable by every member. `userId` records the enrolling admin. These are
|
||||
* read via `queryWorkspaceDevices` / `findWorkspaceDeviceById` (scoped by
|
||||
* `workspace_id`), never mixed into the personal `query`.
|
||||
*
|
||||
* `workspaceId` here is the caller's current workspace (for the workspace
|
||||
* reads); the personal path ignores it.
|
||||
*/
|
||||
export class DeviceModel {
|
||||
private userId: string;
|
||||
private db: LobeChatDatabase;
|
||||
private workspaceId?: string;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
this.userId = userId;
|
||||
this.db = db;
|
||||
this.workspaceId = workspaceId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +73,45 @@ export class DeviceModel {
|
||||
platform: params.platform,
|
||||
},
|
||||
target: [devices.userId, devices.deviceId],
|
||||
targetWhere: sql`${devices.workspaceId} IS NULL`,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enroll a machine as a WORKSPACE device (admin-driven). Upserts on
|
||||
* `(userId, deviceId)` like {@link register}, but stamps `workspace_id` so the
|
||||
* row belongs to the workspace and surfaces to all its members. `userId`
|
||||
* records the enrolling admin.
|
||||
*/
|
||||
registerWorkspaceDevice = async (params: RegisterDeviceParams & { workspaceId: string }) => {
|
||||
const now = new Date();
|
||||
const [result] = await this.db
|
||||
.insert(devices)
|
||||
.values({
|
||||
deviceId: params.deviceId,
|
||||
hostname: params.hostname,
|
||||
identitySource: params.identitySource,
|
||||
lastSeenAt: now,
|
||||
platform: params.platform,
|
||||
userId: this.userId,
|
||||
workspaceId: params.workspaceId,
|
||||
})
|
||||
// Dedupe on (workspaceId, deviceId): a machine enrolled into a workspace is
|
||||
// ONE device no matter which admin (re-)runs the enrollment. `userId` is
|
||||
// left untouched on conflict — it stays the original enroller. The partial
|
||||
// unique index requires its predicate be repeated in `targetWhere`.
|
||||
.onConflictDoUpdate({
|
||||
set: {
|
||||
hostname: params.hostname,
|
||||
identitySource: params.identitySource,
|
||||
lastSeenAt: now,
|
||||
platform: params.platform,
|
||||
},
|
||||
target: [devices.workspaceId, devices.deviceId],
|
||||
targetWhere: sql`${devices.workspaceId} IS NOT NULL`,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -73,14 +120,36 @@ export class DeviceModel {
|
||||
|
||||
query = async (): Promise<DeviceItem[]> => {
|
||||
return this.db.query.devices.findMany({
|
||||
// `lastSeenAt` is written from a JS `new Date()` (ms precision), so two
|
||||
// rapid registers can tie on it and leave the order undefined. Break ties
|
||||
// by `createdAt` (DB-side now(), µs precision) for a stable ordering.
|
||||
orderBy: [desc(devices.lastSeenAt), desc(devices.createdAt)],
|
||||
orderBy: [desc(devices.lastSeenAt)],
|
||||
where: eq(devices.userId, this.userId),
|
||||
});
|
||||
};
|
||||
|
||||
/** The caller's PERSONAL devices only (excludes any workspace-enrolled rows). */
|
||||
queryPersonal = async (): Promise<DeviceItem[]> => {
|
||||
return this.db.query.devices.findMany({
|
||||
orderBy: [desc(devices.lastSeenAt)],
|
||||
where: and(eq(devices.userId, this.userId), isNull(devices.workspaceId)),
|
||||
});
|
||||
};
|
||||
|
||||
/** Every device enrolled into the current workspace (any enrolling admin). */
|
||||
queryWorkspaceDevices = async (): Promise<DeviceItem[]> => {
|
||||
if (!this.workspaceId) return [];
|
||||
return this.db.query.devices.findMany({
|
||||
orderBy: [desc(devices.lastSeenAt)],
|
||||
where: eq(devices.workspaceId, this.workspaceId),
|
||||
});
|
||||
};
|
||||
|
||||
/** A single workspace device by id, scoped to the current workspace. */
|
||||
findWorkspaceDeviceById = async (deviceId: string) => {
|
||||
if (!this.workspaceId) return undefined;
|
||||
return this.db.query.devices.findFirst({
|
||||
where: and(eq(devices.workspaceId, this.workspaceId), eq(devices.deviceId, deviceId)),
|
||||
});
|
||||
};
|
||||
|
||||
findByDeviceId = async (deviceId: string) => {
|
||||
return this.db.query.devices.findFirst({
|
||||
where: and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)),
|
||||
@@ -99,4 +168,25 @@ export class DeviceModel {
|
||||
.delete(devices)
|
||||
.where(and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a WORKSPACE device's user-editable fields, scoped by `workspace_id`
|
||||
* (not the enrolling admin's userId), so any workspace owner can manage it.
|
||||
* Caller must be a workspace owner — enforced at the router (`wsOwnerProcedure`).
|
||||
*/
|
||||
updateWorkspaceDevice = async (deviceId: string, value: UpdateDeviceParams) => {
|
||||
if (!this.workspaceId) return;
|
||||
return this.db
|
||||
.update(devices)
|
||||
.set({ ...value, updatedAt: new Date() })
|
||||
.where(and(eq(devices.workspaceId, this.workspaceId), eq(devices.deviceId, deviceId)));
|
||||
};
|
||||
|
||||
/** Remove a WORKSPACE device, scoped by `workspace_id`. Owner-gated at the router. */
|
||||
deleteWorkspaceDevice = async (deviceId: string) => {
|
||||
if (!this.workspaceId) return;
|
||||
return this.db
|
||||
.delete(devices)
|
||||
.where(and(eq(devices.workspaceId, this.workspaceId), eq(devices.deviceId, deviceId)));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2363,7 +2363,7 @@ export class MessageModel {
|
||||
* Id of the latest main-thread (`threadId IS NULL`) "spine" message in a
|
||||
* topic: the most recent message that is NOT a tool and NOT a signal-tagged
|
||||
* reactive turn (Monitor stdout callbacks etc.). This is the chain anchor for
|
||||
* the heterogeneous-agent write side (LOBE-10445 phase 2): the next normal
|
||||
* the heterogeneous-agent write side: the next normal
|
||||
* turn parents off it, producing a `user → asst → asst …` spine with tools as
|
||||
* inline children.
|
||||
*
|
||||
|
||||
@@ -135,18 +135,20 @@ export interface ListTopicsForMemoryExtractorCursor {
|
||||
// higher in the list. A NULL / unknown status falls through to `active` (3),
|
||||
// matching the client which treats a missing status as active. Keep this in
|
||||
// sync with `STATUS_GROUP_ORDER` / `resolveStatusBucket` in `@lobechat/utils`
|
||||
// (client-side bucketing): `waitingForHuman` and `failed` both collapse into the
|
||||
// top `pending` bucket, so they must float to the top here too — otherwise a
|
||||
// failed topic could fall off the first page and vanish from the pending group.
|
||||
// (client-side bucketing): `waitingForHuman`, `failed` and `unread` all collapse
|
||||
// into the top `pending` bucket, so they must float to the top here too —
|
||||
// otherwise such a topic could fall off the first page and vanish from the
|
||||
// pending group.
|
||||
const STATUS_SORT_RANK = sql`CASE ${topics.status}
|
||||
WHEN 'waitingForHuman' THEN 0
|
||||
WHEN 'failed' THEN 1
|
||||
WHEN 'running' THEN 2
|
||||
WHEN 'active' THEN 3
|
||||
WHEN 'paused' THEN 4
|
||||
WHEN 'completed' THEN 5
|
||||
WHEN 'archived' THEN 6
|
||||
ELSE 3 END`;
|
||||
WHEN 'unread' THEN 2
|
||||
WHEN 'running' THEN 3
|
||||
WHEN 'active' THEN 4
|
||||
WHEN 'paused' THEN 5
|
||||
WHEN 'completed' THEN 6
|
||||
WHEN 'archived' THEN 7
|
||||
ELSE 4 END`;
|
||||
|
||||
// Favorites always float to the top; the rest are ordered by the requested
|
||||
// strategy. `status` adds the priority bucket before the recency tiebreaker.
|
||||
|
||||
@@ -4,10 +4,17 @@ import {
|
||||
type SidebarGroup,
|
||||
} from '@lobechat/types';
|
||||
import { cleanObject } from '@lobechat/utils';
|
||||
import { and, desc, eq, not, sql } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, not, sql } from 'drizzle-orm';
|
||||
|
||||
import { ChatGroupModel } from '../../models/chatGroup';
|
||||
import { agents, agentsToSessions, chatGroups, sessionGroups, sessions } from '../../schemas';
|
||||
import {
|
||||
agents,
|
||||
agentsToSessions,
|
||||
chatGroups,
|
||||
sessionGroups,
|
||||
sessions,
|
||||
topics,
|
||||
} from '../../schemas';
|
||||
import { type LobeChatDatabase } from '../../type';
|
||||
import { sanitizeBm25Query } from '../../utils/bm25';
|
||||
import { normalizeInboxAgentMeta } from '../../utils/inboxAgent';
|
||||
@@ -85,6 +92,12 @@ export class HomeRepository {
|
||||
// 2.1 Query member avatars for each chat group
|
||||
const memberAvatarsMap = await this.getChatGroupMemberAvatars(chatGroupList.map((g) => g.id));
|
||||
|
||||
// 2.2 Unread completion counts per agent / group, derived from persisted
|
||||
// `topics.status === 'unread'`. The list query covers all agents, so this is
|
||||
// the source of truth for the sidebar badge even on agents the client hasn't
|
||||
// loaded topics for.
|
||||
const { agentUnread, groupUnread } = await this.getUnreadCounts();
|
||||
|
||||
// 3. Query all sessionGroups (user-defined folders)
|
||||
const groupList = await this.db
|
||||
.select({
|
||||
@@ -97,7 +110,58 @@ export class HomeRepository {
|
||||
.orderBy(sessionGroups.sort);
|
||||
|
||||
// 4. Process and categorize
|
||||
return this.processAgentList(agentList, chatGroupList, groupList, memberAvatarsMap);
|
||||
return this.processAgentList(
|
||||
agentList,
|
||||
chatGroupList,
|
||||
groupList,
|
||||
memberAvatarsMap,
|
||||
agentUnread,
|
||||
groupUnread,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count topics with an unread completed generation, grouped by agent and by
|
||||
* group. Returns plain maps keyed by agentId / groupId.
|
||||
*/
|
||||
private async getUnreadCounts(): Promise<{
|
||||
agentUnread: Map<string, number>;
|
||||
groupUnread: Map<string, number>;
|
||||
}> {
|
||||
const isUnread = eq(topics.status, 'unread');
|
||||
|
||||
const [byAgent, byGroup] = await Promise.all([
|
||||
this.db
|
||||
.select({ id: topics.agentId, value: count() })
|
||||
.from(topics)
|
||||
.where(
|
||||
and(
|
||||
buildWorkspaceWhere(this.scope, topics),
|
||||
isUnread,
|
||||
sql`${topics.agentId} is not null`,
|
||||
),
|
||||
)
|
||||
.groupBy(topics.agentId),
|
||||
this.db
|
||||
.select({ id: topics.groupId, value: count() })
|
||||
.from(topics)
|
||||
.where(
|
||||
and(
|
||||
buildWorkspaceWhere(this.scope, topics),
|
||||
isUnread,
|
||||
sql`${topics.groupId} is not null`,
|
||||
),
|
||||
)
|
||||
.groupBy(topics.groupId),
|
||||
]);
|
||||
|
||||
const agentUnread = new Map<string, number>();
|
||||
for (const row of byAgent) if (row.id) agentUnread.set(row.id, row.value);
|
||||
|
||||
const groupUnread = new Map<string, number>();
|
||||
for (const row of byGroup) if (row.id) groupUnread.set(row.id, row.value);
|
||||
|
||||
return { agentUnread, groupUnread };
|
||||
}
|
||||
|
||||
private processAgentList(
|
||||
@@ -132,6 +196,8 @@ export class HomeRepository {
|
||||
sort: number | null;
|
||||
}>,
|
||||
memberAvatarsMap: Map<string, Array<{ avatar: string; background?: string }>>,
|
||||
agentUnread: Map<string, number> = new Map(),
|
||||
groupUnread: Map<string, number> = new Map(),
|
||||
): SidebarAgentListResponse {
|
||||
// Convert to unified format
|
||||
// For pinned status: agents.pinned takes priority, fallback to sessions.pinned for backward compatibility
|
||||
@@ -154,6 +220,7 @@ export class HomeRepository {
|
||||
sessionId: a.sessionId,
|
||||
title: meta.title,
|
||||
type: 'agent' as const,
|
||||
unreadCount: agentUnread.get(a.id) ?? 0,
|
||||
updatedAt: a.updatedAt,
|
||||
};
|
||||
}),
|
||||
@@ -169,6 +236,7 @@ export class HomeRepository {
|
||||
sessionId: null,
|
||||
title: g.title,
|
||||
type: 'group' as const,
|
||||
unreadCount: groupUnread.get(g.id) ?? 0,
|
||||
updatedAt: g.updatedAt,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { AiProviderConfig, AiProviderSettings } from '@lobechat/types';
|
||||
import { isNotNull, isNull } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
@@ -23,14 +24,11 @@ export const aiProviders = pgTable(
|
||||
name: text('name'),
|
||||
|
||||
/**
|
||||
* Surrogate primary key for the ai_providers workspace-scoped unique
|
||||
* constraints migration. The original composite PK (id, user_id) was
|
||||
* incompatible with workspace-scoped duplicates because workspace_id can
|
||||
* be NULL for personal rows. Added nullable + DEFAULT to avoid a full
|
||||
* table rewrite; a later manual step backfills history and adds NOT NULL
|
||||
* before the unique index + PK swap.
|
||||
* Surrogate primary key for the workspace-scoped rebuild (LOBE-10056). The
|
||||
* business uniqueness now lives in the workspace-scoped partial unique
|
||||
* indexes below, so the PK no longer carries it.
|
||||
*/
|
||||
_id: uuid('_id').defaultRandom(),
|
||||
_id: uuid('_id').defaultRandom().notNull().primaryKey(),
|
||||
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
@@ -59,7 +57,12 @@ export const aiProviders = pgTable(
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.id, table.userId] }),
|
||||
uniqueIndex('ai_providers_id_user_id_unique')
|
||||
.on(table.id, table.userId)
|
||||
.where(isNull(table.workspaceId)),
|
||||
uniqueIndex('ai_providers_id_user_id_workspace_id_unique')
|
||||
.on(table.id, table.userId, table.workspaceId)
|
||||
.where(isNotNull(table.workspaceId)),
|
||||
index('ai_providers_user_id_idx').on(table.userId),
|
||||
index('ai_providers_workspace_id_idx').on(table.workspaceId),
|
||||
],
|
||||
@@ -74,14 +77,11 @@ export const aiModels = pgTable(
|
||||
id: varchar('id', { length: 150 }).notNull(),
|
||||
|
||||
/**
|
||||
* Surrogate primary key for the ai_models workspace-scoped unique
|
||||
* constraints migration. The original composite PK (id, provider_id,
|
||||
* user_id) was incompatible with workspace-scoped duplicates because
|
||||
* workspace_id can be NULL for personal rows. Added nullable + DEFAULT
|
||||
* to avoid a full table rewrite (~4M rows); a later manual step
|
||||
* backfills history and adds NOT NULL before the unique index + PK swap.
|
||||
* Surrogate primary key for the workspace-scoped rebuild (LOBE-10056). The
|
||||
* business uniqueness now lives in the workspace-scoped partial unique
|
||||
* indexes below, so the PK no longer carries it.
|
||||
*/
|
||||
_id: uuid('_id').defaultRandom(),
|
||||
_id: uuid('_id').defaultRandom().notNull().primaryKey(),
|
||||
|
||||
displayName: varchar('display_name', { length: 200 }),
|
||||
description: text('description'),
|
||||
@@ -108,7 +108,12 @@ export const aiModels = pgTable(
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.id, table.providerId, table.userId] }),
|
||||
uniqueIndex('ai_models_id_provider_id_user_id_unique')
|
||||
.on(table.id, table.providerId, table.userId)
|
||||
.where(isNull(table.workspaceId)),
|
||||
uniqueIndex('ai_models_id_provider_id_user_id_workspace_id_unique')
|
||||
.on(table.id, table.providerId, table.userId, table.workspaceId)
|
||||
.where(isNotNull(table.workspaceId)),
|
||||
index('ai_models_user_id_idx').on(table.userId),
|
||||
index('ai_models_workspace_id_idx').on(table.workspaceId),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkingDirEntry } from '@lobechat/types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { index, jsonb, pgTable, text, uniqueIndex, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { timestamps, timestamptz } from './_helpers';
|
||||
@@ -22,14 +23,15 @@ export const devices = pgTable(
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
// NOTE: devices are a USER-LEVEL identity, not workspace-scoped content. A
|
||||
// physical machine belongs to the user across all of their workspaces (the
|
||||
// unique key is (userId, deviceId), see below). `workspaceId` here only
|
||||
// records which workspace the device was registered from — it is NOT used to
|
||||
// filter device lookups. So `DeviceModel`/`deviceRouter` intentionally scope
|
||||
// by userId only and do NOT use `buildWorkspaceWhere`. Do not "fix" them to
|
||||
// workspace-scope reads, or a user's device would disappear inside their own
|
||||
// workspaces.
|
||||
// `workspace_id` distinguishes the two kinds of device row:
|
||||
// - NULL → a PERSONAL device, identified by (userId, deviceId).
|
||||
// - <workspaceId> → a device ENROLLED into that workspace (shared infra),
|
||||
// identified by (workspaceId, deviceId). `userId` then only records the
|
||||
// enrolling admin — it is NOT part of the identity, so two admins
|
||||
// enrolling the same machine resolve to ONE row (see the partial unique
|
||||
// below). The same physical machine produces a distinct `deviceId` per
|
||||
// principal (the hash mixes in userId / `workspace:<id>`), so personal
|
||||
// and workspace rows never collide.
|
||||
workspaceId: text('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** Machine-derived id (sha256 truncated to 32 chars; 64 leaves room for fallback randomUUID) */
|
||||
@@ -54,8 +56,24 @@ export const devices = pgTable(
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
/** One row per (user, machine); register() upserts on this target */
|
||||
uniqueIndex('devices_user_id_device_id_unique').on(t.userId, t.deviceId),
|
||||
/**
|
||||
* One row per (user, machine) for PERSONAL devices; register() upserts on
|
||||
* this target (partial → ON CONFLICT must repeat the
|
||||
* `WHERE workspace_id IS NULL` predicate). Workspace rows are excluded so
|
||||
* `user_id` is not part of their identity (see workspace partial below).
|
||||
*/
|
||||
uniqueIndex('devices_user_id_device_id_unique')
|
||||
.on(t.userId, t.deviceId)
|
||||
.where(sql`${t.workspaceId} IS NULL`),
|
||||
/**
|
||||
* One row per (workspace, machine) for enrolled devices, regardless of which
|
||||
* admin ran the enrollment. registerWorkspaceDevice() upserts on this target
|
||||
* (partial → ON CONFLICT must repeat the `WHERE workspace_id IS NOT NULL`
|
||||
* predicate).
|
||||
*/
|
||||
uniqueIndex('devices_workspace_id_device_id_unique')
|
||||
.on(t.workspaceId, t.deviceId)
|
||||
.where(sql`${t.workspaceId} IS NOT NULL`),
|
||||
index('devices_user_id_idx').on(t.userId),
|
||||
index('devices_workspace_id_idx').on(t.workspaceId),
|
||||
],
|
||||
|
||||
@@ -44,7 +44,16 @@ export const topics = pgTable(
|
||||
trigger: text('trigger'), // 'cron' | 'chat' | 'api' | 'eval' | 'share' - topic creation trigger source
|
||||
mode: text('mode'), // 'temp' | 'test' | 'default' - topic usage scenario
|
||||
status: text('status', {
|
||||
enum: ['active', 'running', 'paused', 'waitingForHuman', 'failed', 'completed', 'archived'],
|
||||
enum: [
|
||||
'active',
|
||||
'running',
|
||||
'paused',
|
||||
'waitingForHuman',
|
||||
'failed',
|
||||
'completed',
|
||||
'archived',
|
||||
'unread',
|
||||
],
|
||||
}),
|
||||
completedAt: timestamptz('completed_at'),
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { index, jsonb, pgTable, primaryKey, text, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
import { createNanoId } from '../utils/idGenerator';
|
||||
import { createdAt, timestamptz, updatedAt } from './_helpers';
|
||||
@@ -23,6 +32,12 @@ export const workspaces = pgTable(
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
settings: jsonb('settings').default({}),
|
||||
// Freeze state, mirrors the `users.banned` / `banReason` / `banExpires`
|
||||
// trio. Driven by cloud risk control (abnormal spend) and admin tooling;
|
||||
// OSS column with no desktop/open-source behavior attached.
|
||||
frozen: boolean('frozen').default(false),
|
||||
frozenReason: text('frozen_reason'),
|
||||
frozenAt: timestamptz('frozen_at'),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
|
||||
@@ -21,6 +21,12 @@ export type ListLocalFileSortBy = 'name' | 'modifiedTime' | 'createdTime' | 'siz
|
||||
export type ListLocalFileSortOrder = 'asc' | 'desc';
|
||||
|
||||
export interface ListLocalFileParams {
|
||||
/**
|
||||
* Working directory a relative `path` resolves against (the device-bound
|
||||
* directory, injected by the server runtime — not model-supplied). Absolute
|
||||
* paths ignore it; absent → the daemon's process cwd.
|
||||
*/
|
||||
cwd?: string;
|
||||
/**
|
||||
* Maximum number of files to return
|
||||
* @default 100
|
||||
@@ -59,6 +65,8 @@ export interface MoveLocalFileParams {
|
||||
}
|
||||
|
||||
export interface MoveLocalFilesParams {
|
||||
/** Working directory each item's relative paths resolve against. See {@link ListLocalFileParams.cwd}. */
|
||||
cwd?: string;
|
||||
items: MoveLocalFileParams[];
|
||||
}
|
||||
|
||||
@@ -81,12 +89,16 @@ export interface RenameLocalFileResult {
|
||||
}
|
||||
|
||||
export interface LocalReadFileParams {
|
||||
/** Working directory a relative `path` resolves against. See {@link ListLocalFileParams.cwd}. */
|
||||
cwd?: string;
|
||||
fullContent?: boolean;
|
||||
loc?: [number, number];
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface LocalReadFilesParams {
|
||||
/** Working directory each relative path resolves against. See {@link ListLocalFileParams.cwd}. */
|
||||
cwd?: string;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
@@ -95,6 +107,8 @@ export interface WriteLocalFileParams {
|
||||
* Content to write
|
||||
*/
|
||||
content: string;
|
||||
/** Working directory a relative `path` resolves against. See {@link ListLocalFileParams.cwd}. */
|
||||
cwd?: string;
|
||||
|
||||
/**
|
||||
* File path to write to
|
||||
@@ -351,6 +365,8 @@ export interface GlobFilesResult {
|
||||
|
||||
// Edit types
|
||||
export interface EditLocalFileParams {
|
||||
/** Working directory a relative `file_path` resolves against. See {@link ListLocalFileParams.cwd}. */
|
||||
cwd?: string;
|
||||
file_path: string;
|
||||
new_string: string;
|
||||
old_string: string;
|
||||
|
||||
@@ -176,6 +176,38 @@ describe('ClaudeCodeAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat an allowed rate_limit_event window as a quota limit on a later network error', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
// CC stamps a rate_limit_info onto an *allowed* request — it carries the
|
||||
// rolling-window metadata (resetsAt / rateLimitType) even though nothing
|
||||
// was rejected. A later ECONNRESET must surface as a generic error, NOT
|
||||
// inherit this window and render a bogus "usage limit reached" guide.
|
||||
const rawError = 'API Error: Unable to connect to API (ECONNRESET)';
|
||||
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
adapter.adapt({
|
||||
rate_limit_info: {
|
||||
isUsingOverage: false,
|
||||
rateLimitType: 'five_hour',
|
||||
resetsAt: 1_781_853_000,
|
||||
status: 'allowed',
|
||||
},
|
||||
type: 'rate_limit_event',
|
||||
});
|
||||
|
||||
const events = adapter.adapt({
|
||||
api_error_status: null,
|
||||
is_error: true,
|
||||
result: rawError,
|
||||
type: 'result',
|
||||
});
|
||||
|
||||
expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']);
|
||||
expect(events[1].data).toMatchObject({ error: rawError, message: rawError });
|
||||
expect(events[1].data).not.toHaveProperty('code', 'rate_limit');
|
||||
expect(events[1].data).not.toHaveProperty('rateLimitInfo');
|
||||
});
|
||||
|
||||
it('classifies rate-limit failures from paired rate_limit_event + result events', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
const rawError = "You've hit your limit · resets 9am (Asia/Shanghai)";
|
||||
@@ -2558,6 +2590,51 @@ describe('ClaudeCodeAdapter', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* Real-world regression (recorded on tpc_joZS2mksoY5L): a slow `git commit`
|
||||
* (running a lint-staged hook) makes CC track the Bash call as a task and
|
||||
* emit `task_started` + `task_notification` back-to-back, with NO out-of-band
|
||||
* callback turn in between, immediately followed by the tool_result. That is
|
||||
* an inline synchronous tool, not a Monitor-style long-running task — the next
|
||||
* turn is the normal main-chain continuation and must NOT be tagged
|
||||
* `task-completion` (doing so mis-anchors it and drops it from the rendered
|
||||
* chain).
|
||||
*/
|
||||
it('does NOT tag the next turn when a task started and ended with no callbacks (inline tool)', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
init(adapter);
|
||||
|
||||
// A Bash `git commit` tool_use.
|
||||
adapter.adapt({
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
id: 'toolu_commit',
|
||||
input: { command: 'git commit' },
|
||||
name: 'Bash',
|
||||
type: 'tool_use',
|
||||
},
|
||||
],
|
||||
id: 'msg_01',
|
||||
},
|
||||
type: 'assistant',
|
||||
});
|
||||
|
||||
// CC tracks the slow commit as a task, then notifies completion
|
||||
// back-to-back — NO callback turn opened while it was alive.
|
||||
adapter.adapt(ccTaskStarted('task_1', 'toolu_commit'));
|
||||
adapter.adapt(ccTaskNotification('task_1'));
|
||||
|
||||
// The commit's tool_result is consumed inline by the next turn.
|
||||
adapter.adapt(ccUser('toolu_commit', 'committed'));
|
||||
|
||||
// Next turn is plain continuation — must carry NO externalSignal.
|
||||
const next = adapter.adapt(ccMessageStart('msg_02'));
|
||||
expect(
|
||||
next.find((e) => e.type === 'stream_start' && e.data?.newStep)!.data.externalSignal,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears unconsumed task-completion lineage on `result`', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
init(adapter);
|
||||
@@ -2572,13 +2649,18 @@ describe('ClaudeCodeAdapter', () => {
|
||||
adapter.adapt(ccTaskStarted('task_1', 'toolu_mon'));
|
||||
adapter.adapt(ccUser('toolu_mon', 'Monitor started'));
|
||||
adapter.adapt(ccMessageStart('msg_02'));
|
||||
// A signal callback fires while the task is alive (callbackCount > 0), so
|
||||
// `task_notification` genuinely arms pendingTaskCompletion — otherwise (an
|
||||
// inline tool with no callbacks) nothing is armed and this test would pass
|
||||
// vacuously, no longer guarding the `result` clear path.
|
||||
adapter.adapt(ccMessageStart('msg_03'));
|
||||
adapter.adapt(ccTaskNotification('task_1'));
|
||||
// Run ends before the summary turn fires (unusual but possible).
|
||||
adapter.adapt({ result: 'ok', type: 'result', usage: undefined });
|
||||
|
||||
// A later turn (e.g. follow-up user message) must NOT inherit
|
||||
// the unconsumed task-completion lineage.
|
||||
const next = adapter.adapt(ccMessageStart('msg_03'));
|
||||
// the unconsumed task-completion lineage — `result` dropped it.
|
||||
const next = adapter.adapt(ccMessageStart('msg_04'));
|
||||
expect(
|
||||
next.find((e) => e.type === 'stream_start' && e.data?.newStep)!.data.externalSignal,
|
||||
).toBeUndefined();
|
||||
|
||||
@@ -218,18 +218,27 @@ const CLI_OVERLOADED_PATTERNS = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The one reliable discriminator between a user-side plan/quota limit and a
|
||||
* transient server throttle: only the genuine user limit carries a concrete
|
||||
* reset window in the structured `rate_limit_event` — `resetsAt` (epoch
|
||||
* seconds) and/or a named `rateLimitType` (e.g. `seven_day`). Anthropic's
|
||||
* transient throttle emits a rate_limit_event too, but with just
|
||||
* `status: 'rejected'` and no reset info. Status codes (429 / 529) alone are
|
||||
* ambiguous, so this structured signal — not the HTTP status, not the message
|
||||
* text — is what decides whether we show the "usage limit reached, resets at
|
||||
* X" guide vs the "temporarily overloaded, retry" guide.
|
||||
* Discriminates a user-side plan/quota limit from everything else.
|
||||
*
|
||||
* Two signals must BOTH hold:
|
||||
* 1. The request was actually `status: 'rejected'`. Anthropic stamps a
|
||||
* `rate_limit_info` onto its events even when the request goes through
|
||||
* (`status: 'allowed'`) — that block is just the rolling-window metadata
|
||||
* (`resetsAt`, `rateLimitType`) for an *allowed* call, NOT evidence the
|
||||
* limit was hit. Leaning on the presence of a reset window alone made a
|
||||
* later unrelated terminal failure (e.g. an `ECONNRESET` network drop)
|
||||
* inherit the last allowed event's window and render a bogus "usage limit
|
||||
* reached, resets at X" guide. The `status` is the gate.
|
||||
* 2. A concrete reset window (`resetsAt` epoch seconds and/or a named
|
||||
* `rateLimitType` such as `seven_day`). A bare `rejected` with no window is
|
||||
* Anthropic's transient server throttle — left to the overloaded (retry)
|
||||
* classifier, not the usage-limit guide.
|
||||
*
|
||||
* Status codes (429 / 529) and message text are deliberately not consulted
|
||||
* here — only this structured signal decides the "usage limit reached" guide.
|
||||
*/
|
||||
const isUserQuotaRateLimit = (info?: HeterogeneousRateLimitInfo): boolean =>
|
||||
!!info && (info.resetsAt != null || info.rateLimitType != null);
|
||||
!!info && info.status === 'rejected' && (info.resetsAt != null || info.rateLimitType != null);
|
||||
|
||||
const getCliResultMessage = (result: unknown): string | undefined => {
|
||||
if (typeof result === 'string') return result;
|
||||
@@ -663,8 +672,19 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
// task-ended notification) can be tagged with `task-completion`.
|
||||
// Last-task-wins if multiple tasks end before a summary fires — in
|
||||
// practice CC summarizes once per LLM call.
|
||||
//
|
||||
// Gate on `callbackCount > 0`: only a task that actually fired out-of-band
|
||||
// callback turns while alive is a genuine long-running task whose ending
|
||||
// produces a post-task summary (the summary "keeps it inside the same
|
||||
// AssistantGroup as the preceding callbacks" — so there must BE preceding
|
||||
// callbacks). A task that fires `task_started` and `task_notification`
|
||||
// back-to-back with no intervening callback turn was an inline synchronous
|
||||
// tool that CC merely tracked as a task (e.g. a slow `git commit` running a
|
||||
// lint-staged hook); its `tool_result` is consumed by the next turn in the
|
||||
// normal main chain. Tagging that turn `task-completion` mis-anchors it and
|
||||
// drops it from the rendered chain — so leave it untagged.
|
||||
const ending = this.activeTasks.get(raw.task_id);
|
||||
if (ending) {
|
||||
if (ending && ending.callbackCount > 0) {
|
||||
this.pendingTaskCompletion = {
|
||||
sourceToolCallId: ending.toolUseId,
|
||||
sourceToolName: ending.sourceToolName,
|
||||
|
||||
@@ -100,7 +100,7 @@ const delegateSubagent = (
|
||||
// ─── Chain rule ───
|
||||
|
||||
/**
|
||||
* Parent for the NEXT turn's assistant (LOBE-10445 phase 2 — write-side spine).
|
||||
* Parent for the NEXT turn's assistant (write-side spine).
|
||||
*
|
||||
* Normal turns parent off the run's spine (`lastSpineMessageId`, the most recent
|
||||
* non-tool / non-signal main message) so the persisted shape is
|
||||
|
||||
@@ -27,8 +27,8 @@ import type { ExternalSignalContext, ToolCallPayload } from '../types';
|
||||
* by delegating subagent-scoped events to `reduceSubagentRuns`, so a single
|
||||
* `reduce` call is the only entry point both engines need.
|
||||
*
|
||||
* The CHAIN RULE lives here and is authoritative for both engines (LOBE-10445
|
||||
* phase 2): the next turn's assistant parents off the most recent NON-tool,
|
||||
* The CHAIN RULE lives here and is authoritative for both engines:
|
||||
* the next turn's assistant parents off the most recent NON-tool,
|
||||
* NON-signal main-thread message — the run's "spine" (`lastSpineMessageId`) —
|
||||
* so the persisted shape is `user → asst → asst …` with tools as inline
|
||||
* children. The read side (`conversation-flow`) reconstructs the
|
||||
@@ -82,7 +82,7 @@ export interface MainAgentRunState {
|
||||
/** Set once a terminal event has been reduced (idempotent finalize). */
|
||||
ended: boolean;
|
||||
/**
|
||||
* Chain rule (LOBE-10445 phase 2): the most recent NON-tool, NON-signal
|
||||
* Chain rule: the most recent NON-tool, NON-signal
|
||||
* main-thread message — the run's spine. The next NORMAL turn's assistant
|
||||
* parents off this (signal-tagged reactive turns parent off `lastToolMsgIdEver`
|
||||
* instead). Advances on every normal turn; a signal turn does NOT advance it,
|
||||
@@ -95,7 +95,7 @@ export interface MainAgentRunState {
|
||||
lastTextSnapshotSeq: number;
|
||||
/**
|
||||
* Run-lifetime id of the most recent main-agent tool message. Since
|
||||
* LOBE-10445 phase 2 this anchors ONLY signal-tagged reactive turns (Monitor
|
||||
* this anchors ONLY signal-tagged reactive turns (Monitor
|
||||
* stdout pushes) onto a tool, so the reader renders them as tool-child
|
||||
* callbacks; normal turns parent off `lastSpineMessageId`. Only advances on
|
||||
* tool batches; never reset across turns.
|
||||
|
||||
@@ -349,7 +349,7 @@ const reduceToolsChunk = (
|
||||
})),
|
||||
});
|
||||
|
||||
// Chain rule (LOBE-10445 phase 2): the next turn's assistant parents off the
|
||||
// Chain rule: the next turn's assistant parents off the
|
||||
// prior assistant (the spine), NOT this batch's last tool — so
|
||||
// `lastChainParentId` stays at `currentAssistantId` here, tools become inline
|
||||
// children, and the read side reconstructs the zigzag. (Subagent threads have
|
||||
|
||||
@@ -304,6 +304,39 @@ describe('file operations', () => {
|
||||
expect(result.linesAdded).toBeGreaterThan(0);
|
||||
expect(result.linesDeleted).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should match a multi-line LF old_string against a CRLF file (Windows)', async () => {
|
||||
const filePath = path.join(tmpDir, 'crlf.txt');
|
||||
await writeFile(filePath, 'line1\r\nline2\r\nline3\r\n');
|
||||
|
||||
// LLM emits `\n` even though the file on disk uses `\r\n`.
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'lineA\nlineB',
|
||||
old_string: 'line1\nline2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(1);
|
||||
// Existing CRLF line-ending style is preserved.
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('lineA\r\nlineB\r\nline3\r\n');
|
||||
});
|
||||
|
||||
it('should replace_all with an LF old_string against a CRLF file', async () => {
|
||||
const filePath = path.join(tmpDir, 'crlf-all.txt');
|
||||
await writeFile(filePath, 'a\r\nb\r\na\r\nb\r\n');
|
||||
|
||||
const result = await editLocalFile({
|
||||
file_path: filePath,
|
||||
new_string: 'x\ny',
|
||||
old_string: 'a\nb',
|
||||
replace_all: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.replacements).toBe(2);
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('x\r\ny\r\nx\r\ny\r\n');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── listLocalFiles ───
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAgainstCwd } from '../expandTilde';
|
||||
|
||||
describe('resolveAgainstCwd', () => {
|
||||
const cwd = '/Users/me/repo';
|
||||
|
||||
it('anchors a relative path to cwd', () => {
|
||||
expect(resolveAgainstCwd('src/index.ts', cwd)).toBe(path.join(cwd, 'src/index.ts'));
|
||||
expect(resolveAgainstCwd('./pkg/a.ts', cwd)).toBe(path.join(cwd, 'pkg/a.ts'));
|
||||
});
|
||||
|
||||
it('leaves an absolute path untouched', () => {
|
||||
expect(resolveAgainstCwd('/etc/hosts', cwd)).toBe('/etc/hosts');
|
||||
});
|
||||
|
||||
it('expands ~ before considering cwd', () => {
|
||||
expect(resolveAgainstCwd('~/notes.md', cwd)).toBe(path.join(os.homedir(), 'notes.md'));
|
||||
});
|
||||
|
||||
it('falls back to expandTilde behavior when cwd is absent (no regression)', () => {
|
||||
expect(resolveAgainstCwd('src/index.ts')).toBe('src/index.ts');
|
||||
expect(resolveAgainstCwd('src/index.ts', undefined)).toBe('src/index.ts');
|
||||
});
|
||||
|
||||
it('passes through empty / undefined input', () => {
|
||||
expect(resolveAgainstCwd(undefined, cwd)).toBeUndefined();
|
||||
expect(resolveAgainstCwd('', cwd)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -3,19 +3,37 @@ import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createPatch } from 'diff';
|
||||
|
||||
import type { EditFileParams, EditFileResult } from '../types';
|
||||
import { expandTilde } from './expandTilde';
|
||||
import { resolveAgainstCwd } from './expandTilde';
|
||||
|
||||
export async function editLocalFile({
|
||||
file_path: rawPath,
|
||||
old_string,
|
||||
new_string,
|
||||
replace_all = false,
|
||||
cwd,
|
||||
}: EditFileParams): Promise<EditFileResult> {
|
||||
const filePath = expandTilde(rawPath) ?? rawPath;
|
||||
const filePath = resolveAgainstCwd(rawPath, cwd) ?? rawPath;
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
|
||||
if (!content.includes(old_string)) {
|
||||
// Resolve the search/replace strings against the file's actual line endings.
|
||||
// LLMs almost always emit `\n` even when the on-disk file uses CRLF (the norm
|
||||
// on Windows), so a literal match would fail and the edit appears broken. When
|
||||
// the raw old_string isn't present but its CRLF-adjusted form is, edit against
|
||||
// that — keeping the file's existing line-ending style and producing a minimal
|
||||
// diff instead of rewriting every line.
|
||||
let search = old_string;
|
||||
let replace = new_string;
|
||||
if (!content.includes(search) && content.includes('\r\n')) {
|
||||
const toCRLF = (s: string) => s.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n');
|
||||
const crlfSearch = toCRLF(search);
|
||||
if (content.includes(crlfSearch)) {
|
||||
search = crlfSearch;
|
||||
replace = toCRLF(replace);
|
||||
}
|
||||
}
|
||||
|
||||
if (!content.includes(search)) {
|
||||
return {
|
||||
error: 'The specified old_string was not found in the file',
|
||||
replacements: 0,
|
||||
@@ -27,16 +45,16 @@ export async function editLocalFile({
|
||||
let replacements: number;
|
||||
|
||||
if (replace_all) {
|
||||
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
|
||||
const regex = new RegExp(search.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
|
||||
const matches = content.match(regex);
|
||||
replacements = matches ? matches.length : 0;
|
||||
newContent = content.replaceAll(old_string, new_string);
|
||||
newContent = content.replaceAll(search, replace);
|
||||
} else {
|
||||
const index = content.indexOf(old_string);
|
||||
const index = content.indexOf(search);
|
||||
if (index === -1) {
|
||||
return { error: 'Old string not found', replacements: 0, success: false };
|
||||
}
|
||||
newContent = content.slice(0, index) + new_string + content.slice(index + old_string.length);
|
||||
newContent = content.slice(0, index) + replace + content.slice(index + search.length);
|
||||
replacements = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,3 +15,23 @@ export const expandTilde = (input: string | undefined): string | undefined => {
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a filesystem path for Node fs APIs: first expand a leading `~`, then
|
||||
* anchor a still-relative path to `cwd` — the device-bound working directory.
|
||||
*
|
||||
* Absolute paths pass through untouched. When `cwd` is absent the behavior is
|
||||
* identical to {@link expandTilde}, so callers that don't carry a working
|
||||
* directory (e.g. desktop client-mode today) keep resolving relative paths
|
||||
* against the process cwd and nothing regresses. Without this, a relative path
|
||||
* supplied by the model resolves against the daemon's `process.cwd()` (= `/`
|
||||
* for a Finder/Dock-launched app) instead of the user's bound directory.
|
||||
*/
|
||||
export const resolveAgainstCwd = (
|
||||
input: string | undefined,
|
||||
cwd?: string,
|
||||
): string | undefined => {
|
||||
const expanded = expandTilde(input);
|
||||
if (!expanded || !cwd) return expanded;
|
||||
return path.isAbsolute(expanded) ? expanded : path.join(cwd, expanded);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'node:path';
|
||||
import { SYSTEM_FILES_TO_IGNORE } from '@lobechat/file-loaders';
|
||||
|
||||
import type { FileEntry, ListFilesParams, ListFilesResult } from '../types';
|
||||
import { expandTilde } from './expandTilde';
|
||||
import { resolveAgainstCwd } from './expandTilde';
|
||||
|
||||
export interface ListFilesOptions {
|
||||
/** Whether to filter out system files like .DS_Store, Thumbs.db, etc. */
|
||||
@@ -12,11 +12,11 @@ export interface ListFilesOptions {
|
||||
}
|
||||
|
||||
export async function listLocalFiles(
|
||||
{ path: rawPath, sortBy = 'modifiedTime', sortOrder = 'desc', limit = 100 }: ListFilesParams,
|
||||
{ path: rawPath, sortBy = 'modifiedTime', sortOrder = 'desc', limit = 100, cwd }: ListFilesParams,
|
||||
options?: ListFilesOptions,
|
||||
): Promise<ListFilesResult> {
|
||||
const { ignoreSystemFiles = true } = options || {};
|
||||
const dirPath = expandTilde(rawPath) ?? rawPath;
|
||||
const dirPath = resolveAgainstCwd(rawPath, cwd) ?? rawPath;
|
||||
|
||||
try {
|
||||
const entries = await readdir(dirPath);
|
||||
|
||||
@@ -3,9 +3,9 @@ import { access, mkdir, rename } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MoveFileResultItem, MoveFilesParams } from '../types';
|
||||
import { expandTilde } from './expandTilde';
|
||||
import { resolveAgainstCwd } from './expandTilde';
|
||||
|
||||
export async function moveLocalFiles({ items }: MoveFilesParams): Promise<MoveFileResultItem[]> {
|
||||
export async function moveLocalFiles({ items, cwd }: MoveFilesParams): Promise<MoveFileResultItem[]> {
|
||||
const results: MoveFileResultItem[] = [];
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
@@ -13,8 +13,8 @@ export async function moveLocalFiles({ items }: MoveFilesParams): Promise<MoveFi
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const sourcePath = expandTilde(item.oldPath) ?? item.oldPath;
|
||||
const newPath = expandTilde(item.newPath) ?? item.newPath;
|
||||
const sourcePath = resolveAgainstCwd(item.oldPath, cwd) ?? item.oldPath;
|
||||
const newPath = resolveAgainstCwd(item.newPath, cwd) ?? item.newPath;
|
||||
const resultItem: MoveFileResultItem = {
|
||||
newPath: undefined,
|
||||
sourcePath,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@lobechat/file-loaders';
|
||||
|
||||
import type { ReadFileParams, ReadFileResult } from '../types';
|
||||
import { expandTilde } from './expandTilde';
|
||||
import { resolveAgainstCwd } from './expandTilde';
|
||||
|
||||
/** Hard cap on file size we will read into memory at all (10MB). */
|
||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
@@ -46,8 +46,9 @@ export async function readLocalFile({
|
||||
path: rawPath,
|
||||
loc,
|
||||
fullContent,
|
||||
cwd,
|
||||
}: ReadFileParams): Promise<ReadFileResult> {
|
||||
const filePath = expandTilde(rawPath) ?? rawPath;
|
||||
const filePath = resolveAgainstCwd(rawPath, cwd) ?? rawPath;
|
||||
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
|
||||
|
||||
let stats;
|
||||
|
||||
@@ -2,16 +2,17 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { WriteFileParams, WriteFileResult } from '../types';
|
||||
import { expandTilde } from './expandTilde';
|
||||
import { resolveAgainstCwd } from './expandTilde';
|
||||
|
||||
export async function writeLocalFile({
|
||||
path: rawPath,
|
||||
content,
|
||||
cwd,
|
||||
}: WriteFileParams): Promise<WriteFileResult> {
|
||||
if (!rawPath) return { error: 'Path cannot be empty', success: false };
|
||||
if (content === undefined) return { error: 'Content cannot be empty', success: false };
|
||||
|
||||
const filePath = expandTilde(rawPath) ?? rawPath;
|
||||
const filePath = resolveAgainstCwd(rawPath, cwd) ?? rawPath;
|
||||
|
||||
try {
|
||||
const dirname = path.dirname(filePath);
|
||||
|
||||
@@ -82,6 +82,12 @@ export interface KillCommandResult {
|
||||
// ─── File Types ───
|
||||
|
||||
export interface ReadFileParams {
|
||||
/**
|
||||
* Working directory a relative `path` is resolved against (the device-bound
|
||||
* directory, injected by the runtime). Absolute paths ignore it; absent → the
|
||||
* process cwd, as before.
|
||||
*/
|
||||
cwd?: string;
|
||||
fullContent?: boolean;
|
||||
loc?: [number, number];
|
||||
path: string;
|
||||
@@ -106,6 +112,8 @@ export interface ReadFileResult {
|
||||
|
||||
export interface WriteFileParams {
|
||||
content: string;
|
||||
/** Working directory a relative `path` resolves against. See {@link ReadFileParams.cwd}. */
|
||||
cwd?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
@@ -115,6 +123,8 @@ export interface WriteFileResult {
|
||||
}
|
||||
|
||||
export interface EditFileParams {
|
||||
/** Working directory a relative `file_path` resolves against. See {@link ReadFileParams.cwd}. */
|
||||
cwd?: string;
|
||||
file_path: string;
|
||||
new_string: string;
|
||||
old_string: string;
|
||||
@@ -131,6 +141,8 @@ export interface EditFileResult {
|
||||
}
|
||||
|
||||
export interface ListFilesParams {
|
||||
/** Working directory a relative `path` resolves against. See {@link ReadFileParams.cwd}. */
|
||||
cwd?: string;
|
||||
limit?: number;
|
||||
path: string;
|
||||
sortBy?: 'createdTime' | 'modifiedTime' | 'name' | 'size';
|
||||
@@ -232,6 +244,11 @@ export interface MoveFileItem {
|
||||
}
|
||||
|
||||
export interface MoveFilesParams {
|
||||
/**
|
||||
* Working directory each item's relative `oldPath`/`newPath` resolves against.
|
||||
* See {@link ReadFileParams.cwd}.
|
||||
*/
|
||||
cwd?: string;
|
||||
items: MoveFileItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export default {
|
||||
'ModelSwitch.title': 'Model',
|
||||
'active': 'Active',
|
||||
'audioPlayer.pause': 'Pause audio',
|
||||
'audioPlayer.play': 'Play audio',
|
||||
'agentBuilder.installPlugin.authRequired': 'Cloud MCP requires sign-in to continue',
|
||||
'agentBuilder.installPlugin.cancel': 'Cancel',
|
||||
'agentBuilder.installPlugin.clickApproveToConnect':
|
||||
@@ -184,8 +186,14 @@ export default {
|
||||
'heteroAgent.cloudRepo.notSet': 'No repo selected',
|
||||
'heteroAgent.cloudRepo.noRepos': 'No repositories configured. Add them in agent settings.',
|
||||
'heteroAgent.cloudRepo.multiSelected': '{{count}} repos selected',
|
||||
'heteroAgent.executionTarget.auto': 'Auto',
|
||||
'heteroAgent.executionTarget.autoDesc':
|
||||
'Use an online device automatically, picking one when several are available',
|
||||
'heteroAgent.executionTarget.infoTooltip':
|
||||
'Pick a device and the agent uses it as its runtime environment — reading and writing files and operating the computer. Cloud sandbox is provided by LobeHub Marketplace.',
|
||||
'heteroAgent.executionTarget.gateway': 'Gateway',
|
||||
'heteroAgent.executionTarget.gatewayDesc':
|
||||
'Run through the device gateway so other clients can follow progress',
|
||||
'heteroAgent.executionTarget.loading': 'Loading devices…',
|
||||
'heteroAgent.executionTarget.local': 'This device',
|
||||
'heteroAgent.executionTarget.localDesc': 'Run as a local process on this desktop app',
|
||||
@@ -332,18 +340,18 @@ export default {
|
||||
'createModal.placeholder': 'Describe what this Agent should do...',
|
||||
'createModal.skillSuggestion.actions.createAnyway': 'Create Agent Anyway',
|
||||
'createModal.skillSuggestion.actions.createAnywayHint': 'Skill not a fit?',
|
||||
'createModal.skillSuggestion.actions.install': 'Add Skill',
|
||||
'createModal.skillSuggestion.actions.installing': 'Adding…',
|
||||
'createModal.skillSuggestion.actions.install': 'Install Skill',
|
||||
'createModal.skillSuggestion.actions.installing': 'Installing…',
|
||||
'createModal.skillSuggestion.actions.openSkills': 'View in Skills',
|
||||
'createModal.skillSuggestion.actions.tryInLobeAI': 'Use in LobeAI',
|
||||
'createModal.skillSuggestion.actions.tryInLobeAI': 'Use in {{name}}',
|
||||
'createModal.skillSuggestion.description':
|
||||
'This looks like a reusable workflow. Install the Skill once, then use it across Agents.',
|
||||
'createModal.skillSuggestion.installed.description':
|
||||
'You can use this Skill in LobeAI or add it to any Agent.',
|
||||
'createModal.skillSuggestion.installed.ready': 'Ready in LobeAI',
|
||||
'createModal.skillSuggestion.installed.title': 'Skill added',
|
||||
'You can use this Skill in {{name}}, or enable it for any Agent.',
|
||||
'createModal.skillSuggestion.installed.ready': 'Ready in {{name}}',
|
||||
'createModal.skillSuggestion.installed.title': 'Skill installed',
|
||||
'createModal.skillSuggestion.installError':
|
||||
"Skill wasn't added. Retry, or create an Agent anyway.",
|
||||
"Skill wasn't installed. Retry, or create an Agent anyway.",
|
||||
'createModal.skillSuggestion.title': 'A Skill may fit better',
|
||||
'createModal.title': 'What should this Agent do?',
|
||||
'claudeCodeInstallGuide.actions.openDocs': 'Open Install Guide',
|
||||
|
||||
@@ -504,6 +504,25 @@ export default {
|
||||
'sync.title': 'Sync Status',
|
||||
'sync.unconnected.tip':
|
||||
'Signaling server connection failed, and peer-to-peer communication channel cannot be established. Please check the network and try again.',
|
||||
'taskTemplate.action.connect.button': 'Connect {{provider}}',
|
||||
'taskTemplate.action.connect.error': 'Connection failed, please try again.',
|
||||
'taskTemplate.action.connect.popupBlocked':
|
||||
'Connection popup blocked. Allow popups in your browser to continue.',
|
||||
'taskTemplate.action.connect.short': 'Connect',
|
||||
'taskTemplate.action.connecting': 'Waiting for authorization…',
|
||||
'taskTemplate.action.create.error': 'Failed to create task. Please try again.',
|
||||
'taskTemplate.action.create.success': 'Scheduled task added. Find it in Lobe AI.',
|
||||
'taskTemplate.action.createButton': 'Add task',
|
||||
'taskTemplate.action.creating': 'Creating...',
|
||||
'taskTemplate.action.dismiss.error': 'Failed to dismiss. Please try again.',
|
||||
'taskTemplate.action.dismiss.tooltip': 'Not interested',
|
||||
'taskTemplate.action.refresh.button': 'Refresh',
|
||||
'taskTemplate.card.templateTag': 'Template',
|
||||
'taskTemplate.schedule.daily': 'Every day at {{time}}',
|
||||
'taskTemplate.schedule.editableAfterCreateTooltip':
|
||||
'You can adjust the schedule after creating the task.',
|
||||
'taskTemplate.schedule.weekly': 'Every {{weekday}} at {{time}}',
|
||||
'taskTemplate.section.title': 'Try these scheduled tasks',
|
||||
'tab.image': 'Image',
|
||||
'tab.audio': 'Audio',
|
||||
'tab.chat': 'Chat',
|
||||
@@ -556,6 +575,7 @@ export default {
|
||||
'userPanel.email': 'Email Support',
|
||||
'userPanel.feedback': 'Contact Us',
|
||||
'userPanel.help': 'Help Center',
|
||||
'userPanel.inviteFriend': 'Invite a friend',
|
||||
'userPanel.moveGuide': 'The settings button has been moved here',
|
||||
'userPanel.plans': 'Subscription Plans',
|
||||
'userPanel.profile': 'Account',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user