mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 21:08:36 +00:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d450efd3b2 | |||
| ab2e95ad7d | |||
| 9ffd9d39e3 | |||
| f1ea407d74 | |||
| 98d77166cc | |||
| 9821328d43 | |||
| 980c2e74d8 | |||
| 84598524df | |||
| 5e2ef88c13 | |||
| 403de538d6 | |||
| 8949e89535 | |||
| 8aa075cd80 | |||
| 9cc5f9e1a0 | |||
| bcf97d9487 | |||
| 3e4b81d2cc | |||
| 651d1a203a | |||
| 4c29515e4c | |||
| b4b1205ee9 | |||
| 8c0e66b633 | |||
| 1ae8498fc7 | |||
| c4b147554b | |||
| 5fb1f339a7 | |||
| 81fc1aaf7f | |||
| b14f1dba5c | |||
| 1d2b32bafc | |||
| 347e2eec0c | |||
| e8275a93ff | |||
| 49d191d2a7 | |||
| 35052416cc | |||
| 0c5ccc8770 | |||
| c8ff3ac43d | |||
| 718096e306 | |||
| f0eded2941 | |||
| 1f6d350dca | |||
| 5eee6d21e3 | |||
| bcc31ca331 | |||
| 72d34046c0 | |||
| 60f08f58e4 | |||
| 202f062a0d | |||
| be81c35e94 | |||
| 14357a3f51 | |||
| 0561a1d7eb | |||
| 3e0a396968 | |||
| 5f27cd8f26 | |||
| 1c80146a07 | |||
| 1d4d5c1c73 | |||
| d45257615a | |||
| b3cbc9a710 | |||
| e295f80235 | |||
| 5cd02b937b | |||
| cce2741de3 | |||
| 362d137a2b | |||
| 6859ee2374 | |||
| d6e641b790 | |||
| 2ee53bcd60 | |||
| 8b96d14347 | |||
| 248d6ecf76 | |||
| d4e8d6df6e | |||
| 4c6a3999c1 | |||
| 506b96af64 | |||
| 924ae8bf1f | |||
| 302755057e | |||
| eea9464b04 | |||
| 82cc885394 | |||
| e4ad195df9 | |||
| 47b6f3503a | |||
| bb4924fc5b | |||
| 46f884d5ed | |||
| 0fcc21895e | |||
| 3c52998157 | |||
| 8d4c48749f | |||
| 26aa28c263 | |||
| f3d5d03cf5 | |||
| d71686ba88 | |||
| f16c280e93 | |||
| be62847e00 | |||
| a8faccff66 | |||
| 63d8e07453 | |||
| 44e69af6cc | |||
| eedf46a11d | |||
| ff61f4b3fa | |||
| 192111840c | |||
| 837a3daa58 | |||
| 5f6f053039 | |||
| 775be47513 | |||
| 2f265a9307 | |||
| 0fa2e2349c | |||
| 930344ae23 | |||
| 538195dfb4 |
@@ -14,7 +14,7 @@ In `NODE_ENV=development`, `AgentRuntimeService.executeStep()` automatically rec
|
||||
|
||||
**Data flow**: executeStep loop -> build `StepPresentationData` -> write partial snapshot to disk -> on completion, finalize to `.agent-tracing/{timestamp}_{traceId}.json`
|
||||
|
||||
**Context engine capture**: In `RuntimeExecutors.ts`, the `call_llm` executor emits a `context_engine_result` event after `serverMessagesEngine()` processes messages. This event carries the full `contextEngineInput` (DB messages, systemRole, model, knowledge, tools, userMemory, etc.) and the processed `output` messages (the final LLM payload).
|
||||
**Context engine capture**: In `RuntimeExecutors.ts`, the `call_llm` executor calls `ctx.tracingContextEngine(input, output)` after `serverMessagesEngine()` processes messages. `AgentRuntimeService.executeStep` buffers the call per step and forwards it to `OperationTraceRecorder.appendStep` as the typed `contextEngine` field. CE flows through this side channel rather than the `events` array so its heavy payload (agentDocuments, systemRole, …) never enters the Redis state pipeline (LOBE-9110).
|
||||
|
||||
## Package Location
|
||||
|
||||
@@ -199,9 +199,10 @@ interface StepSnapshot {
|
||||
messages?: any[]; // DB messages before step
|
||||
context?: { phase: string; payload?: unknown; stepContext?: unknown };
|
||||
events?: Array<{ type: string; [key: string]: unknown }>;
|
||||
// context_engine_result event contains:
|
||||
// input: full contextEngineInput (messages, systemRole, model, knowledge, tools, userMemory, ...)
|
||||
// output: processed messages array (final LLM payload)
|
||||
contextEngine?: {
|
||||
input?: unknown; // contextEngineInput minus messages + toolsConfig (reconstructible from baseline)
|
||||
output?: unknown; // processed messages array (final LLM payload)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
@@ -216,5 +217,5 @@ When using `--messages`, the output shows three sections (if context engine data
|
||||
## Integration Points
|
||||
|
||||
- **Recording**: `src/server/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
|
||||
- **Context engine event**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, emits `context_engine_result` event
|
||||
- **Context engine capture**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
|
||||
- **Store**: `FileSnapshotStore` reads/writes to `.agent-tracing/` relative to `process.cwd()`
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
---
|
||||
name: chat-sdk
|
||||
description: >
|
||||
Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to
|
||||
(1) Build a Slack, Teams, Google Chat, Discord, GitHub, or Linear bot,
|
||||
(2) Use the Chat SDK to handle mentions, messages, reactions, slash commands, cards, modals, or streaming,
|
||||
(3) Set up webhook handlers for chat platforms,
|
||||
(4) Send interactive cards or stream AI responses to chat platforms.
|
||||
Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter",
|
||||
building bots that work across multiple chat platforms.
|
||||
description: "Build multi-platform chat bots with the Chat SDK (`chat` npm package) — Slack, Teams, Google Chat, Discord, GitHub, Linear. Use when building a chat bot, handling mentions / messages / reactions / slash commands / cards / modals / streaming, setting up a webhook handler, or sending interactive cards / streaming AI responses to a chat platform. Triggers on `@chat-adapter`, 'chat sdk', 'chat bot', 'slack bot', 'teams bot', 'discord bot', 'webhook handler', 'cross-platform bot'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: data-fetching
|
||||
description: Data fetching architecture guide using Service layer + Zustand Store + SWR. Use when implementing data fetching, creating services, working with store hooks, or migrating from useEffect. Triggers on data loading, API calls, service creation, or store data fetching tasks.
|
||||
name: data-fetching-architecture
|
||||
description: Standardized data-fetching pipeline guide — Service layer + Zustand Store + SWR. Use when implementing a data-fetching feature, creating a `xxxService`, adding a `useFetchXxx` hook, wiring `useClientDataSWR`, or migrating ad-hoc `useEffect + fetch` to the standard pipeline. Triggers on `lambdaClient`, `useClientDataSWR`, `xxxService`, `useFetchXxx`, 'data fetching', 'fetch architecture', 'service layer', 'SWR hook', 'migrate useEffect'.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: docs-changelog
|
||||
description: 'Writing guide for website changelog pages under docs/changelog/*.mdx. Use when creating or editing product update posts in EN/ZH. Not for GitHub Release notes.'
|
||||
description: "Writing guide for website changelog pages under `docs/changelog/*.mdx` (NOT GitHub Release notes — those live in the `version-release` skill). Use when creating or editing a product update post in EN/ZH. Triggers on `docs/changelog/*.mdx`, 'changelog post', 'product update post', 'add a changelog', '更新日志', 'changelog 文案'."
|
||||
---
|
||||
|
||||
# Docs Changelog Writing Guide
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# LobeHub gateway streaming + tab-switch test harness
|
||||
|
||||
Captures store + DOM state at 200ms intervals so we can prove or disprove
|
||||
claims like "切回 tab 后消息回到了很早以前". Built for gateway-mode chat but
|
||||
works for any LobeHub streaming session.
|
||||
|
||||
## Files
|
||||
|
||||
`scripts/agent-gateway/`
|
||||
|
||||
| File | Role |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| `probe.js` | Injects a 200ms sampler + `__PROBE_EVENT` marker + `__switchTab` |
|
||||
| `probe-dump.js` | Stops the sampler and returns `{events, samples}` as JSON string |
|
||||
| `tab-switch.js` | Runs N round-trip switches between two tabs, marks each step |
|
||||
| `analyze.mjs` | Node post-processor: timeline + regression detection |
|
||||
|
||||
## Standard workflow
|
||||
|
||||
```bash
|
||||
# 1. Start Electron with CDP
|
||||
./.agents/skills/local-testing/scripts/electron-dev.sh start
|
||||
|
||||
# 2. Navigate to a chat, switch runtime to Cloud Sandbox (gateway mode)
|
||||
|
||||
# 3. Install the probe + helpers
|
||||
agent-browser --cdp 9222 eval --stdin \
|
||||
< .agents/skills/local-testing/scripts/agent-gateway/probe.js
|
||||
|
||||
# 4. Send a tool-call message — manually or via type+press
|
||||
agent-browser --cdp 9222 eval "window.__PROBE_EVENT('SENT')"
|
||||
|
||||
# 5. Run the multi-switch driver (auto-picks active tab as BACK and the
|
||||
# rightmost inactive tab as AWAY — edit ROUND_TRIPS / DWELL_MS in the
|
||||
# file if you want different timing)
|
||||
agent-browser --cdp 9222 eval --stdin \
|
||||
< .agents/skills/local-testing/scripts/agent-gateway/tab-switch.js
|
||||
|
||||
# 6. Wait for streaming to finish, then dump
|
||||
agent-browser --cdp 9222 eval --stdin \
|
||||
< .agents/skills/local-testing/scripts/agent-gateway/probe-dump.js \
|
||||
> /tmp/probe.json
|
||||
|
||||
# 7. Analyze
|
||||
node .agents/skills/local-testing/scripts/agent-gateway/analyze.mjs /tmp/probe.json
|
||||
```
|
||||
|
||||
The analyzer prints three sections: EVENTS, TIMELINE, REGRESSIONS. If
|
||||
REGRESSIONS is non-empty it means content/reasoning/childN dropped on the
|
||||
same topic — the symptom users describe.
|
||||
|
||||
## What the probe tracks (and why)
|
||||
|
||||
`chat.messagesMap` only stores the top-level `assistantGroup` shell. The
|
||||
actual streamed content, reasoning, and tool calls live in
|
||||
`assistantGroup.children: AssistantContentBlock[]`. Any probe that only
|
||||
reads `m.content` / `m.reasoning` will see zeros throughout streaming and
|
||||
miss everything that matters. probe.js walks both levels and sums:
|
||||
|
||||
- `cT` total content length
|
||||
- `rT` total reasoning length
|
||||
- `toolT` total tool-call count
|
||||
- `childN` number of content blocks
|
||||
|
||||
Plus DOM-side signals (`domLen`, search/crawl indicator counts) so you can
|
||||
tell store-side regressions apart from render-side regressions.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Optimistic new-topic state.** Before the first chunk lands, messages
|
||||
live under the `<scope>_new` key with `tmp_*` ids and no `topicId` field.
|
||||
probe.js falls back to those when `activeTopicId` is null.
|
||||
- **Reasoning resets to 0 are not bugs.** When the assistant finishes
|
||||
thinking and starts tool-use or text, the streaming reasoning buffer
|
||||
empties and the finalised reasoning gets sealed into a completed block.
|
||||
Filter these out manually if needed.
|
||||
- **DOM length jitters by a handful of chars** because counters like "(10)"
|
||||
in tool-call labels change as results arrive. analyze.mjs only flags
|
||||
`domLen` drops greater than 100 chars to ignore that noise.
|
||||
- **Never identify tabs by innerText.** The active tab's text embeds a
|
||||
` · <agent name>` suffix, so a search like `'LobeHub Growth'` matches the
|
||||
active tab when the active agent happens to be LobeHub Growth — and you
|
||||
end up clicking the tab you're already on. probe.js uses the stable
|
||||
`data-contextmenu-trigger` attribute (a React `useId()` value that's set
|
||||
per-tab and survives focus changes) plus `data-active="true"` to mark
|
||||
the active one. Helpers exposed:
|
||||
`__listTabs()` / `__clickTabByKey(key)` / `__clickTabByIndex(i)` /
|
||||
`__activeTabKey()`.
|
||||
- **`tab-switch.js` fires-and-forgets.** The IIFE kicks off an async loop
|
||||
and returns immediately so the agent-browser CLI eval doesn't blow past
|
||||
its default 25 s timeout. Wait on the `SWITCH_LOOP_DONE` event marker
|
||||
before dumping. Re-running while a loop is in flight is refused — the
|
||||
chaotic data from overlapping runs is not worth debugging.
|
||||
@@ -0,0 +1,243 @@
|
||||
// Analyzer for probe-events dumps. Reads a JSON file produced by `run.ts dump`
|
||||
// and prints a layered breakdown:
|
||||
//
|
||||
// 1. STREAM EVENTS — every non-chunk WS/SSE event in receipt order
|
||||
// 2. CHUNKS SUMMARY — collapsed per-step chunk counts (otherwise floods)
|
||||
// 3. ACTION CALLS — replaceMessages / refreshMessages / MARK:* with stack
|
||||
// 4. CORRELATION — calls ↔ nearest stream event within ±300ms
|
||||
// 5. PER-KEY ASSISTANT GROWTH — for each messagesMap key, when the leading
|
||||
// assistant message's cLen / rLen actually moves (this is what reveals
|
||||
// "chunks arrived but the message never grew" regressions)
|
||||
// 6. ROLLBACKS — msgN / childN / role drops in the active-topic timeline
|
||||
//
|
||||
// Usage:
|
||||
// bun run .agents/skills/local-testing/scripts/agent-gateway/analyze-events.ts <dump.json>
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import type {
|
||||
ProbeActionCall,
|
||||
ProbeDump,
|
||||
ProbeMessageSummary,
|
||||
ProbeStreamEvent,
|
||||
ProbeTimelineSample,
|
||||
} from './types';
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error('usage: bun run analyze-events.ts <dump.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = readFileSync(file, 'utf8');
|
||||
// agent-browser eval --stdin wraps return values in quotes when the value is
|
||||
// a string — so the JSON file may be double-encoded depending on how it was
|
||||
// captured. Handle both.
|
||||
const parsedOnce = JSON.parse(raw) as ProbeDump | string;
|
||||
const dump: ProbeDump = typeof parsedOnce === 'string' ? JSON.parse(parsedOnce) : parsedOnce;
|
||||
|
||||
const { streamEvents = [], actionCalls = [], timeline = [] } = dump;
|
||||
|
||||
const pad = (v: unknown, n: number) => String(v).padStart(n);
|
||||
|
||||
// ── META ───────────────────────────────────────────────────────────
|
||||
console.log('=== META ===');
|
||||
console.log(` events: ${streamEvents.length}`);
|
||||
console.log(` calls: ${actionCalls.length}`);
|
||||
console.log(` timeline: ${timeline.length}`);
|
||||
|
||||
// ── 1. STREAM EVENTS (non-chunk) ───────────────────────────────────
|
||||
const nonChunkEvents = streamEvents.filter((e) => e.type !== 'stream_chunk');
|
||||
const chunkEvents = streamEvents.filter((e) => e.type === 'stream_chunk');
|
||||
|
||||
console.log(
|
||||
`\n=== STREAM EVENTS (${nonChunkEvents.length} non-chunk + ${chunkEvents.length} chunks elided) ===`,
|
||||
);
|
||||
for (const e of nonChunkEvents) {
|
||||
const dataStr = e.dataKeys?.length ? ` [${e.dataKeys.join(',')}]` : '';
|
||||
const data = e.data as Record<string, unknown> | undefined;
|
||||
const uiHint = data?.uiMessagesPreview
|
||||
? ` uiPreview=${JSON.stringify(data.uiMessagesPreview)}`
|
||||
: data?.uiMessagesTotal
|
||||
? ` uiTotal=${data.uiMessagesTotal}`
|
||||
: '';
|
||||
const phaseHint = data?.phase ? ` phase=${data.phase}` : '';
|
||||
const extra = e.serverType ? ` serverType=${e.serverType}` : '';
|
||||
console.log(
|
||||
` t=${pad(e.t, 7)} [${(e.transport ?? '?').padEnd(3)}] step=${pad(e.stepIndex ?? '-', 2)} ` +
|
||||
`type=${(e.type ?? '').padEnd(22)} op=${e.opIdTail ?? '-'}${phaseHint}${uiHint}${extra}${dataStr}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. CHUNK SUMMARY ───────────────────────────────────────────────
|
||||
console.log('\n=== CHUNKS SUMMARY (per step / chunkType) ===');
|
||||
const chunkBuckets = new Map<string, { count: number; firstT: number; lastT: number }>();
|
||||
for (const c of chunkEvents) {
|
||||
const data = c.data as Record<string, unknown> | undefined;
|
||||
const ct = (data?.chunkType as string | undefined) ?? '?';
|
||||
const key = `step=${c.stepIndex ?? '-'} chunkType=${ct.padEnd(8)} op=${c.opIdTail}`;
|
||||
const slot = chunkBuckets.get(key);
|
||||
if (slot) {
|
||||
slot.count += 1;
|
||||
slot.lastT = c.t;
|
||||
} else {
|
||||
chunkBuckets.set(key, { count: 1, firstT: c.t, lastT: c.t });
|
||||
}
|
||||
}
|
||||
for (const [k, v] of chunkBuckets) {
|
||||
console.log(` ${k} count=${pad(v.count, 4)} t=${pad(v.firstT, 7)}..${pad(v.lastT, 7)}`);
|
||||
}
|
||||
|
||||
// ── 3. ACTION CALLS ───────────────────────────────────────────────
|
||||
console.log('\n=== ACTION CALLS (replace/refresh/MARK) ===');
|
||||
for (const c of actionCalls) {
|
||||
if (c.name?.startsWith('MARK:')) {
|
||||
console.log(` t=${pad(c.t, 7)} ${c.name}`);
|
||||
continue;
|
||||
}
|
||||
const snapshot = (c.args as any)?.snapshot as
|
||||
| Array<{ id: string; role: string; cLen: number; rLen: number }>
|
||||
| undefined;
|
||||
const snapStr = snapshot?.length
|
||||
? ' snapshot=' + snapshot.map((m) => `${m.id}:${m.role}/c${m.cLen}/r${m.rLen}`).join(' | ')
|
||||
: '';
|
||||
const summary =
|
||||
c.name === 'replaceMessages'
|
||||
? `count=${c.args?.count} action=${(c.args?.params as any)?.action ?? '-'}${snapStr}`
|
||||
: c.name === 'refreshMessages'
|
||||
? `ctx=${JSON.stringify(c.args?.context)}`
|
||||
: c.error
|
||||
? `error=${c.error}`
|
||||
: '';
|
||||
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(20)} ${summary}`);
|
||||
if (c.stack) {
|
||||
const frames = c.stack
|
||||
.split(' ← ')
|
||||
.filter((f) => !!f && !f.includes('Object.<anonymous>'))
|
||||
.slice(0, 3);
|
||||
for (const f of frames) console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. CORRELATION ────────────────────────────────────────────────
|
||||
function nearestEventForCall(
|
||||
call: ProbeActionCall,
|
||||
windowMs = 300,
|
||||
): { event: ProbeStreamEvent; delta: number } | null {
|
||||
let best: ProbeStreamEvent | null = null;
|
||||
let bestDelta = Infinity;
|
||||
for (const e of streamEvents) {
|
||||
const d = Math.abs(e.t - call.t);
|
||||
if (d < bestDelta && d <= windowMs) {
|
||||
bestDelta = d;
|
||||
best = e;
|
||||
}
|
||||
}
|
||||
return best ? { event: best, delta: bestDelta } : null;
|
||||
}
|
||||
|
||||
console.log('\n=== CORRELATION (replace/refresh ↔ nearest event within ±300ms) ===');
|
||||
for (const c of actionCalls) {
|
||||
if (c.name !== 'refreshMessages' && c.name !== 'replaceMessages') continue;
|
||||
const hit = nearestEventForCall(c);
|
||||
if (hit) {
|
||||
const phase = (hit.event.data as Record<string, unknown> | undefined)?.phase;
|
||||
console.log(
|
||||
` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← Δ${pad(hit.delta, 4)}ms ${hit.event.type}` +
|
||||
(phase ? ` phase=${phase}` : ''),
|
||||
);
|
||||
} else {
|
||||
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← (no event nearby — external trigger)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. PER-KEY ASSISTANT GROWTH ───────────────────────────────────
|
||||
// For each messagesMap key, find the trailing assistant message and report
|
||||
// the points in time where its cLen / rLen actually changed. If the timeline
|
||||
// shows chunks arriving but the assistant cLen never moves, that's the
|
||||
// signature of "dispatch queue blocked / messageId mismatch".
|
||||
console.log('\n=== PER-KEY ASSISTANT GROWTH ===');
|
||||
const keysEverSeen = new Set<string>();
|
||||
for (const s of timeline) for (const k of Object.keys(s.byKey ?? {})) keysEverSeen.add(k);
|
||||
|
||||
for (const key of keysEverSeen) {
|
||||
console.log(`\n key=${key}`);
|
||||
let lastSig: string | null = null;
|
||||
for (const s of timeline) {
|
||||
const slot = s.byKey?.[key];
|
||||
if (!slot) continue;
|
||||
const last = slot.msgs.at(-1) as ProbeMessageSummary | undefined;
|
||||
if (!last) continue;
|
||||
const sig = `${last.id}|c${last.cLen}|r${last.rLen}|n${slot.n}`;
|
||||
if (sig === lastSig) continue;
|
||||
lastSig = sig;
|
||||
console.log(
|
||||
` t=${pad(s.t, 7)} msgN=${pad(slot.n, 3)} ` +
|
||||
`lastAssistant=${last.id} cLen=${pad(last.cLen, 5)} rLen=${pad(last.rLen, 5)}` +
|
||||
` runOps=${s.runOps}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. ROLLBACKS (active-topic msgN / childN / role drops) ─────────
|
||||
console.log('\n=== ROLLBACKS (active-topic msgN / childN / role drops) ===');
|
||||
let prev: ProbeTimelineSample | null = null;
|
||||
const rollbacks: Array<{ t: number; topic: string | null; drops: string[] }> = [];
|
||||
|
||||
const flatten = (s: ProbeTimelineSample) => {
|
||||
if (!s.activeTopic) return [];
|
||||
return Object.entries(s.byKey ?? {})
|
||||
.filter(([k]) => k.includes(s.activeTopic!))
|
||||
.flatMap(([, v]) => v.msgs);
|
||||
};
|
||||
|
||||
for (const s of timeline) {
|
||||
if (s.err) {
|
||||
prev = null;
|
||||
continue;
|
||||
}
|
||||
if (!prev || prev.activeTopic !== s.activeTopic) {
|
||||
prev = s;
|
||||
continue;
|
||||
}
|
||||
const prevMsgs = flatten(prev);
|
||||
const curMsgs = flatten(s);
|
||||
const drops: string[] = [];
|
||||
|
||||
if (curMsgs.length < prevMsgs.length) drops.push(`msgN ${prevMsgs.length}→${curMsgs.length}`);
|
||||
|
||||
let prevChild = 0;
|
||||
let curChild = 0;
|
||||
for (const m of prevMsgs) prevChild += m.chN ?? 0;
|
||||
for (const m of curMsgs) curChild += m.chN ?? 0;
|
||||
if (curChild < prevChild) drops.push(`childN ${prevChild}→${curChild}`);
|
||||
|
||||
const prevById = new Map(prevMsgs.map((m) => [m.id, m]));
|
||||
for (const m of curMsgs) {
|
||||
const pr = prevById.get(m.id);
|
||||
if (!pr) continue;
|
||||
if (m.cLen < pr.cLen) drops.push(`cLen[${m.id}] ${pr.cLen}→${m.cLen}`);
|
||||
if (m.rLen < pr.rLen) drops.push(`rLen[${m.id}] ${pr.rLen}→${m.rLen}`);
|
||||
}
|
||||
|
||||
if (drops.length) rollbacks.push({ t: s.t, topic: s.activeTopic, drops });
|
||||
prev = s;
|
||||
}
|
||||
|
||||
if (rollbacks.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const r of rollbacks) {
|
||||
const nearEvent = streamEvents
|
||||
.filter((e) => Math.abs(e.t - r.t) <= 300)
|
||||
.map((e) => `${e.type}${(e.data as any)?.phase ? ':' + (e.data as any).phase : ''}`);
|
||||
const nearCall = actionCalls
|
||||
.filter((c) => Math.abs(c.t - r.t) <= 300 && !c.name?.startsWith('MARK:'))
|
||||
.map((c) => c.name);
|
||||
console.log(
|
||||
` t=${pad(r.t, 7)} topic=${r.topic} ${r.drops.join(' | ')}` +
|
||||
(nearEvent.length ? ` near-event:[${nearEvent.join(',')}]` : '') +
|
||||
(nearCall.length ? ` near-call:[${nearCall.join(',')}]` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// Analyze a probe dump captured by probe.js + probe-dump.js.
|
||||
//
|
||||
// node analyze.mjs /tmp/probe.json
|
||||
//
|
||||
// Prints:
|
||||
// 1. EVENTS — user-action markers with their relative timestamps
|
||||
// 2. TIMELINE — periodic samples (~1 per second + event-adjacent samples)
|
||||
// showing every interesting field; columns:
|
||||
// t(ms) | runOps | msgN | childN | content | reasoning | tools | domLen | search | crawl | topic | event
|
||||
// 3. REGRESSIONS — every place a tracked counter *dropped* on the same
|
||||
// topic between adjacent samples. A "true" UI rollback shows up as a
|
||||
// drop in content/reasoning/tools/childN/domLen without a topic change.
|
||||
//
|
||||
// Whitelisted transitions (not flagged):
|
||||
// - topic change → all drops expected (focus moved away)
|
||||
// - reasoning length 0 after content starts → reasoning gets sealed into a
|
||||
// completed sub-block; the parent's running reasoning resets to ''.
|
||||
// - msgN drop when topic transitions from `_new` placeholder to a real id.
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error('usage: node analyze.mjs <probe.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
// probe-dump.js wraps the payload in JSON.stringify so agent-browser returns
|
||||
// it as a single quoted string. Unwrap.
|
||||
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
const { events, samples } = data;
|
||||
|
||||
const fmt = {
|
||||
pad(v, n) {
|
||||
return String(v).padStart(n);
|
||||
},
|
||||
};
|
||||
|
||||
console.log('=== EVENTS ===');
|
||||
for (const e of events) console.log(` t=${fmt.pad(e.t, 7)} ${e.name}`);
|
||||
|
||||
console.log(
|
||||
'\n=== TIMELINE (~1s cadence, plus event-adjacent samples) ===\n' +
|
||||
' t(ms) runOps msgN childN content reasoning tools domLen search crawl topic event',
|
||||
);
|
||||
|
||||
let lastSampledAt = -1e9;
|
||||
const eventBuckets = events.map((e) => e.t);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = samples[i];
|
||||
const nearEvent = eventBuckets.some((et) => Math.abs(et - s.t) < 110);
|
||||
if (!nearEvent && s.t - lastSampledAt < 1000) continue;
|
||||
lastSampledAt = s.t;
|
||||
|
||||
const ev = events.find((e) => Math.abs(e.t - s.t) < 110);
|
||||
const evMarker = ev ? ` ◀ ${ev.name}` : '';
|
||||
const topicSuffix = s.topicId ? s.topicId.slice(-6) : '(none)';
|
||||
const search = s.ind?.search ?? 0;
|
||||
const crawl = s.ind?.crawl ?? 0;
|
||||
console.log(
|
||||
` ${fmt.pad(s.t, 6)} ` +
|
||||
`${fmt.pad(s.runOps, 6)} ` +
|
||||
`${fmt.pad(s.msgN, 4)} ` +
|
||||
`${fmt.pad(s.childN ?? 0, 5)} ` +
|
||||
`${fmt.pad(s.cT ?? 0, 8)} ` +
|
||||
`${fmt.pad(s.rT ?? 0, 9)} ` +
|
||||
`${fmt.pad(s.toolT ?? 0, 5)} ` +
|
||||
`${fmt.pad(s.domLen ?? 0, 7)} ` +
|
||||
`${fmt.pad(search, 6)} ` +
|
||||
`${fmt.pad(crawl, 5)} ` +
|
||||
`${topicSuffix.padEnd(8)}${evMarker}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== REGRESSIONS (same topic, value dropped) ===');
|
||||
const regressions = [];
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
const prev = samples[i - 1];
|
||||
const cur = samples[i];
|
||||
if (!cur.topicId || prev.topicId !== cur.topicId) continue;
|
||||
|
||||
const drops = [];
|
||||
if (cur.msgN < prev.msgN) drops.push(`msgN: ${prev.msgN}→${cur.msgN}`);
|
||||
if ((cur.childN ?? 0) < (prev.childN ?? 0)) drops.push(`childN: ${prev.childN}→${cur.childN}`);
|
||||
if ((cur.cT ?? 0) < (prev.cT ?? 0)) drops.push(`content: ${prev.cT}→${cur.cT}`);
|
||||
if ((cur.rT ?? 0) < (prev.rT ?? 0)) drops.push(`reasoning: ${prev.rT}→${cur.rT}`);
|
||||
if ((cur.toolT ?? 0) < (prev.toolT ?? 0)) drops.push(`tools: ${prev.toolT}→${cur.toolT}`);
|
||||
// domLen jitters by a few chars from counter labels — only flag big drops.
|
||||
if ((cur.domLen ?? 0) < (prev.domLen ?? 0) - 100) {
|
||||
drops.push(`domLen: ${prev.domLen}→${cur.domLen}`);
|
||||
}
|
||||
if (drops.length === 0) continue;
|
||||
|
||||
const nearbyEv = events.filter((e) => Math.abs(e.t - cur.t) < 600).map((e) => e.name);
|
||||
regressions.push({ t: cur.t, topic: cur.topicId.slice(-6), drops, nearbyEv });
|
||||
}
|
||||
|
||||
if (regressions.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const r of regressions) {
|
||||
const evStr = r.nearbyEv.length ? ` near:[${r.nearbyEv.join(',')}]` : '';
|
||||
console.log(` t=${fmt.pad(r.t, 7)} topic=${r.topic} ${r.drops.join(' | ')}${evStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== SUMMARY ===`);
|
||||
console.log(` samples: ${samples.length}`);
|
||||
console.log(` events: ${events.length}`);
|
||||
console.log(` regressions: ${regressions.length}`);
|
||||
if (samples.length) {
|
||||
const last = samples.at(-1);
|
||||
console.log(
|
||||
` final: msgN=${last.msgN} childN=${last.childN ?? 0} content=${last.cT ?? 0} ` +
|
||||
`reasoning=${last.rT ?? 0} tools=${last.toolT ?? 0} runOps=${last.runOps}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Stop the probe and serialize collected data.
|
||||
//
|
||||
// agent-browser --cdp 9222 eval --stdin < probe-dump.js > /tmp/probe.json
|
||||
//
|
||||
// The whole thing is wrapped in a JSON.stringify so agent-browser returns it
|
||||
// as a single quoted string — the analyzer double-parses to handle that.
|
||||
|
||||
(function () {
|
||||
if (window.__PROBE_TIMER) {
|
||||
clearInterval(window.__PROBE_TIMER);
|
||||
window.__PROBE_TIMER = null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
events: window.__PROBE_EVENTS || [],
|
||||
samples: window.__PROBE_SAMPLES || [],
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
// Stops the events-probe timeline timer and stashes the full capture as a
|
||||
// JSON string on `window.__PROBE_LAST_DUMP_JSON`. `run.ts` wraps the bundle
|
||||
// in an IIFE that returns that global, which `agent-browser eval` prints to
|
||||
// stdout — the runner then persists it under `.agent-gateway/`.
|
||||
|
||||
import type { ProbeDump } from './types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROBE_LAST_DUMP_JSON?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const w = window;
|
||||
|
||||
if (w.__PROBE_TIMELINE_TIMER) {
|
||||
clearInterval(w.__PROBE_TIMELINE_TIMER);
|
||||
w.__PROBE_TIMELINE_TIMER = null;
|
||||
}
|
||||
|
||||
const mutations = w.__PROBE_MUTATIONS ?? [];
|
||||
|
||||
const dump: ProbeDump & { mutations: typeof mutations } = {
|
||||
meta: {
|
||||
t0: w.__PROBE_T0 ?? 0,
|
||||
collectedAt: Date.now(),
|
||||
sampleCount: (w.__PROBE_MSG_TIMELINE ?? []).length,
|
||||
eventCount: (w.__PROBE_STREAM_EVENTS ?? []).length,
|
||||
callCount: (w.__PROBE_ACTION_CALLS ?? []).length,
|
||||
},
|
||||
streamEvents: w.__PROBE_STREAM_EVENTS ?? [],
|
||||
actionCalls: w.__PROBE_ACTION_CALLS ?? [],
|
||||
timeline: w.__PROBE_MSG_TIMELINE ?? [],
|
||||
mutations,
|
||||
};
|
||||
|
||||
w.__PROBE_LAST_DUMP_JSON = JSON.stringify(dump);
|
||||
@@ -0,0 +1,637 @@
|
||||
// LobeHub gateway raw-event-stream probe.
|
||||
//
|
||||
// Gateway-mode chats subscribe via WebSocket — NOT via the `/api/agent/stream`
|
||||
// SSE endpoint (that one belongs to the direct/client durable-agent runtime).
|
||||
// `AgentStreamClient` (`packages/agent-gateway-client/src/client.ts`) opens
|
||||
// `new WebSocket('wss://.../ws?operationId=...')`, then parses JSON frames in
|
||||
// its `onmessage` handler and re-emits `agent_event.event` objects to the
|
||||
// chat store.
|
||||
//
|
||||
// To capture the RAW gateway events before the store touches them, we wrap
|
||||
// `window.WebSocket` so that for any socket whose URL contains `operationId=`
|
||||
// we intercept the `onmessage` handler / `addEventListener('message')` and
|
||||
// log every `agent_event` frame.
|
||||
//
|
||||
// We *also* keep the `window.fetch` hook for `/api/agent/stream` so this
|
||||
// probe still works for direct-mode runs — but gateway-mode events come
|
||||
// through the WebSocket path.
|
||||
//
|
||||
// Buffers (read via `dump`):
|
||||
// __PROBE_STREAM_EVENTS — raw events parsed off the wire
|
||||
// __PROBE_ACTION_CALLS — replaceMessages / refreshMessages calls (best-effort)
|
||||
// __PROBE_MSG_TIMELINE — 200ms snapshots of every messagesMap key
|
||||
|
||||
import type {
|
||||
ProbeActionCall,
|
||||
ProbeMessageSummary,
|
||||
ProbeStreamEvent,
|
||||
ProbeTimelineSample,
|
||||
} from './types';
|
||||
|
||||
// Bundled by esbuild as an IIFE. Top-level code runs once on injection.
|
||||
|
||||
const w = window;
|
||||
|
||||
// ── Buffers ─────────────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROBE_MUTATIONS?: Array<{
|
||||
t: number;
|
||||
key: string;
|
||||
n: number;
|
||||
last?: { id: string; role: string; cLen: number; rLen: number; updatedAt?: unknown };
|
||||
prevLast?: { id: string; role: string; cLen: number; rLen: number };
|
||||
delta?: string;
|
||||
}>;
|
||||
__PROBE_STORE_UNSUB?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const events: ProbeStreamEvent[] = (w.__PROBE_STREAM_EVENTS ??= []);
|
||||
const calls: ProbeActionCall[] = (w.__PROBE_ACTION_CALLS ??= []);
|
||||
const timeline: ProbeTimelineSample[] = (w.__PROBE_MSG_TIMELINE ??= []);
|
||||
const mutations = (w.__PROBE_MUTATIONS ??= []);
|
||||
events.length = 0;
|
||||
calls.length = 0;
|
||||
timeline.length = 0;
|
||||
mutations.length = 0;
|
||||
|
||||
const t0 = Date.now();
|
||||
w.__PROBE_T0 = t0;
|
||||
const now = (): number => Date.now() - t0;
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function summarizeData(data: unknown): Record<string, unknown> | unknown {
|
||||
if (!data || typeof data !== 'object') return data;
|
||||
const src = data as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(src)) {
|
||||
const v = src[k];
|
||||
if (v == null) {
|
||||
out[k] = v;
|
||||
} else if (Array.isArray(v)) {
|
||||
out[k] = `Array(${v.length})`;
|
||||
if (k === 'uiMessages') {
|
||||
out.uiMessagesPreview = v.slice(0, 5).map((m: any) => ({
|
||||
id: (m.id ?? '').slice(-8),
|
||||
role: m.role,
|
||||
cLen: (m.content ?? '').length,
|
||||
children: (m.children ?? []).length,
|
||||
tools: (m.tools ?? []).length,
|
||||
reasoning: (m.reasoning?.content ?? '').length,
|
||||
}));
|
||||
out.uiMessagesTotal = v.length;
|
||||
}
|
||||
} else if (typeof v === 'object') {
|
||||
const obj = v as Record<string, unknown>;
|
||||
out[k] =
|
||||
'Object{' +
|
||||
Object.keys(obj)
|
||||
.slice(0, 6)
|
||||
.map((kk) => kk + (typeof obj[kk] === 'string' ? `=${(obj[kk] as string).length}ch` : ''))
|
||||
.join(',') +
|
||||
'}';
|
||||
} else if (typeof v === 'string') {
|
||||
out[k] = v.length > 100 ? v.slice(0, 100) + `…(${v.length})` : v;
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeMessages(msgs: any[]): ProbeMessageSummary[] {
|
||||
return (msgs ?? []).slice(0, 80).map((m) => ({
|
||||
id: (m.id ?? '').slice(-8),
|
||||
role: m.role,
|
||||
cLen: (m.content ?? '').length,
|
||||
rLen: (m.reasoning?.content ?? '').length,
|
||||
tools: (m.tools ?? []).length,
|
||||
chN: (m.children ?? []).length,
|
||||
}));
|
||||
}
|
||||
|
||||
function shortStack(): string {
|
||||
const raw = new Error('probe-stack').stack ?? '';
|
||||
return raw
|
||||
.split('\n')
|
||||
.slice(3)
|
||||
.filter((l) => !l.includes('probe-events') && !l.includes('node_modules'))
|
||||
.map((l) => l.trim().replace(/^at\s+/, ''))
|
||||
.slice(0, 6)
|
||||
.join(' ← ');
|
||||
}
|
||||
|
||||
function recordAgentEvent(args: {
|
||||
transport: 'ws' | 'sse';
|
||||
opId: string | null;
|
||||
agentEvent: any;
|
||||
eventId?: string | null;
|
||||
rawLen?: number;
|
||||
}): void {
|
||||
const { transport, opId, agentEvent, eventId, rawLen } = args;
|
||||
if (!agentEvent || typeof agentEvent !== 'object') return;
|
||||
events.push({
|
||||
t: now(),
|
||||
transport,
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
eventId: eventId ?? null,
|
||||
type: agentEvent.type,
|
||||
stepIndex: agentEvent.stepIndex,
|
||||
dataKeys: agentEvent.data ? Object.keys(agentEvent.data) : [],
|
||||
data: summarizeData(agentEvent.data) as Record<string, unknown>,
|
||||
rawLen,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 1. Patch window.WebSocket for gateway WS events ────────────────
|
||||
|
||||
if (!w.__PROBE_ORIG_WEBSOCKET) w.__PROBE_ORIG_WEBSOCKET = w.WebSocket;
|
||||
const OrigWS = w.__PROBE_ORIG_WEBSOCKET;
|
||||
|
||||
function extractOpIdFromWsUrl(url: string | URL): string | null {
|
||||
const m = String(url ?? '').match(/operationId=([^&]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
function isGatewayWs(url: string | URL): boolean {
|
||||
return String(url ?? '').includes('operationId=');
|
||||
}
|
||||
|
||||
function handleWsFrame(rawData: unknown, opId: string | null): void {
|
||||
const rawLen = typeof rawData === 'string' ? rawData.length : -1;
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = typeof rawData === 'string' ? JSON.parse(rawData) : null;
|
||||
} catch {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'ws',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_PARSE_ERROR_',
|
||||
raw: typeof rawData === 'string' && rawData.length < 400 ? rawData : '(non-string or large)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!parsed) return;
|
||||
|
||||
if (parsed.type === 'agent_event') {
|
||||
recordAgentEvent({
|
||||
transport: 'ws',
|
||||
opId,
|
||||
agentEvent: parsed.event,
|
||||
eventId: parsed.id,
|
||||
rawLen,
|
||||
});
|
||||
} else {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'ws',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_SERVER_MSG_',
|
||||
serverType: parsed.type,
|
||||
rawLen,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the constructor. Instance `constructor` will still reflect OrigWS
|
||||
// (we share prototypes), so use the `_WS_OPEN_` sentinel events to confirm
|
||||
// the patch is firing.
|
||||
function PatchedWebSocket(this: WebSocket, url: string | URL, protocols?: string | string[]) {
|
||||
const ws: WebSocket = protocols == null ? new OrigWS(url) : new OrigWS(url, protocols);
|
||||
const opId = extractOpIdFromWsUrl(url);
|
||||
if (!isGatewayWs(url)) return ws;
|
||||
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'ws',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_WS_OPEN_',
|
||||
url: String(url),
|
||||
});
|
||||
|
||||
// One observer listener that always fires, regardless of how the consumer
|
||||
// (AgentStreamClient uses `ws.onmessage = …`) subscribes.
|
||||
ws.addEventListener('message', (e) => {
|
||||
try {
|
||||
handleWsFrame((e as MessageEvent).data, opId);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'ws',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_WS_CLOSE_',
|
||||
});
|
||||
});
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
// Preserve prototype + static fields so `instanceof WebSocket` and
|
||||
// `WebSocket.OPEN` constants still work.
|
||||
(PatchedWebSocket as unknown as { prototype: WebSocket }).prototype = OrigWS.prototype;
|
||||
for (const k of Object.keys(OrigWS) as Array<keyof typeof OrigWS>) {
|
||||
try {
|
||||
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
|
||||
} catch {
|
||||
/* readonly */
|
||||
}
|
||||
}
|
||||
(['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const).forEach((k) => {
|
||||
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
|
||||
});
|
||||
w.WebSocket = PatchedWebSocket as unknown as typeof WebSocket;
|
||||
|
||||
// ── 2. Patch window.fetch for `/api/agent/stream` (direct-mode SSE) ─
|
||||
|
||||
if (!w.__PROBE_ORIG_FETCH) w.__PROBE_ORIG_FETCH = w.fetch.bind(w);
|
||||
const origFetch = w.__PROBE_ORIG_FETCH;
|
||||
|
||||
function isAgentStreamUrl(input: RequestInfo | URL): boolean {
|
||||
let url = '';
|
||||
if (typeof input === 'string') url = input;
|
||||
else if (input instanceof URL) url = input.toString();
|
||||
else if (input && typeof (input as Request).url === 'string') url = (input as Request).url;
|
||||
return url.includes('/api/agent/stream');
|
||||
}
|
||||
|
||||
function extractOpIdFromHttpUrl(input: RequestInfo | URL): string | null {
|
||||
const url = typeof input === 'string' ? input : (input as Request | URL).toString();
|
||||
const m = url.match(/operationId=([^&]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
function pushFromSSEFrame(rawFrame: string, opId: string | null): void {
|
||||
const lines = rawFrame.split('\n');
|
||||
let dataJson = '';
|
||||
let evtName = 'message';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) evtName = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataJson += line.slice(5).trim();
|
||||
}
|
||||
if (!dataJson) return;
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(dataJson);
|
||||
} catch {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'sse',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_PARSE_ERROR_',
|
||||
sseEvent: evtName,
|
||||
raw: dataJson.length > 400 ? dataJson.slice(0, 400) + '…' : dataJson,
|
||||
});
|
||||
return;
|
||||
}
|
||||
recordAgentEvent({
|
||||
transport: 'sse',
|
||||
opId,
|
||||
agentEvent: parsed,
|
||||
eventId: null,
|
||||
rawLen: dataJson.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function teeAndDrain(response: Response, opId: string | null): Promise<Response> {
|
||||
if (!response.body) return response;
|
||||
const [a, b] = response.body.tee();
|
||||
|
||||
void (async () => {
|
||||
const reader = b.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
|
||||
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
||||
const frame = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
if (frame.trim()) pushFromSSEFrame(frame, opId);
|
||||
}
|
||||
}
|
||||
if (buf.trim()) pushFromSSEFrame(buf, opId);
|
||||
} catch (e: any) {
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'sse',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_TEE_ERROR_',
|
||||
message: String(e?.message ?? e),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return new Response(a, {
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
w.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const response = await origFetch(input as any, init);
|
||||
if (!isAgentStreamUrl(input)) return response;
|
||||
const opId = extractOpIdFromHttpUrl(input);
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input.split('?')[0]
|
||||
: (input as Request | URL).toString().split('?')[0];
|
||||
events.push({
|
||||
t: now(),
|
||||
transport: 'sse',
|
||||
opIdTail: (opId ?? '').slice(-10),
|
||||
type: '_CONNECTED_',
|
||||
url,
|
||||
status: response.status,
|
||||
});
|
||||
return teeAndDrain(response, opId);
|
||||
} as typeof fetch;
|
||||
|
||||
// ── 3. Wrap store actions (best-effort for "who called replace") ────
|
||||
|
||||
// Side-global stash for the original chat-store actions. Re-installs ALWAYS
|
||||
// rewrap from the originals so updates to the probe body take effect
|
||||
// without a page reload — using only a `__probeWrapped` flag on the chat
|
||||
// state object would freeze the first-installed wrapper across re-installs.
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROBE_ORIG_REFRESH_MESSAGES?: any;
|
||||
__PROBE_ORIG_REPLACE_MESSAGES?: any;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const chat = w.__LOBE_STORES?.chat?.();
|
||||
if (chat) {
|
||||
// First-time install: cache the originals. Re-install: restore from
|
||||
// the cached originals before wrapping again.
|
||||
if (!w.__PROBE_ORIG_REFRESH_MESSAGES) w.__PROBE_ORIG_REFRESH_MESSAGES = chat.refreshMessages;
|
||||
if (!w.__PROBE_ORIG_REPLACE_MESSAGES) w.__PROBE_ORIG_REPLACE_MESSAGES = chat.replaceMessages;
|
||||
const origRefresh = w.__PROBE_ORIG_REFRESH_MESSAGES;
|
||||
const origReplace = w.__PROBE_ORIG_REPLACE_MESSAGES;
|
||||
chat.refreshMessages = origRefresh;
|
||||
chat.replaceMessages = origReplace;
|
||||
|
||||
chat.refreshMessages = async function probeRefresh(this: unknown, ...args: any[]) {
|
||||
calls.push({
|
||||
t: now(),
|
||||
name: 'refreshMessages',
|
||||
args: { context: args[0] ?? null },
|
||||
stack: shortStack(),
|
||||
});
|
||||
return origRefresh.apply(this, args);
|
||||
};
|
||||
chat.replaceMessages = function probeReplace(this: unknown, ...args: any[]) {
|
||||
const msgs = (args[0] as any[]) ?? [];
|
||||
const snapshot = msgs.slice(-2).map((m) => ({
|
||||
id: (m.id ?? '').slice(-8),
|
||||
role: m.role,
|
||||
cLen: (m.content ?? '').length,
|
||||
rLen: (m.reasoning?.content ?? '').length,
|
||||
updatedAt: m.updatedAt,
|
||||
}));
|
||||
calls.push({
|
||||
t: now(),
|
||||
name: 'replaceMessages',
|
||||
args: { count: msgs.length, params: args[1] ?? null, snapshot } as any,
|
||||
stack: shortStack(),
|
||||
});
|
||||
|
||||
// Pair the call with a mutation row so the analyzer can build a
|
||||
// single ordered timeline across replaceMessages + dispatchMessage.
|
||||
const stackTop = shortStack().split(' ← ')[0]?.slice(0, 80);
|
||||
const last = msgs.at(-1);
|
||||
const lastSum = last
|
||||
? {
|
||||
id: (last.id ?? '').slice(-8),
|
||||
role: last.role,
|
||||
cLen: (last.content ?? '').length,
|
||||
rLen: (last.reasoning?.content ?? '').length,
|
||||
updatedAt: last.updatedAt,
|
||||
}
|
||||
: undefined;
|
||||
const params: any = args[1] ?? {};
|
||||
const ctxKey = params.context
|
||||
? `main_${params.context.agentId ?? '?'}_${
|
||||
params.context.topicId ? 'tpc_' + params.context.topicId : 'new'
|
||||
}`.replace('main_tpc_', 'main_') // crude key inference
|
||||
: '(no-ctx)';
|
||||
mutations.push({
|
||||
t: now(),
|
||||
key: ctxKey,
|
||||
n: msgs.length,
|
||||
last: lastSum,
|
||||
delta: `replaceMessages(action=${params.action ?? '-'}) src=${stackTop ?? '-'}`,
|
||||
});
|
||||
|
||||
return origReplace.apply(this, args);
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
calls.push({ t: now(), name: '_WRAP_ERROR_', error: String(e?.message ?? e) });
|
||||
}
|
||||
|
||||
// ── 3.5. Mutation log — wrap the TWO ChatStore writers (replaceMessages,
|
||||
// internal_dispatchMessage) to record EVERY dbMessagesMap[key] reference
|
||||
// change with a one-line "before/after last assistant message" delta. This
|
||||
// reveals dispatchMessage-driven collapses that the replaceMessages wrap
|
||||
// alone cannot see.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PROBE_ORIG_DISPATCH_MESSAGE?: any;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const chat = w.__LOBE_STORES?.chat?.();
|
||||
if (chat?.internal_dispatchMessage) {
|
||||
if (!w.__PROBE_ORIG_DISPATCH_MESSAGE)
|
||||
w.__PROBE_ORIG_DISPATCH_MESSAGE = chat.internal_dispatchMessage;
|
||||
const origDispatch = w.__PROBE_ORIG_DISPATCH_MESSAGE;
|
||||
chat.internal_dispatchMessage = origDispatch;
|
||||
|
||||
chat.internal_dispatchMessage = function probeDispatch(this: unknown, payload: any, ctx?: any) {
|
||||
// Snapshot BEFORE — read the would-be target key + last message.
|
||||
const before = (() => {
|
||||
try {
|
||||
const state = w.__LOBE_STORES?.chat?.();
|
||||
if (!state) return null;
|
||||
// Replicate state.internal_getConversationContext logic enough to
|
||||
// resolve a key — but most callers pass operationId on ctx, and
|
||||
// operationId-keyed lookup needs store internals. Easiest: snapshot
|
||||
// ALL keys' last-assistant cLen and compare BEFORE vs AFTER below.
|
||||
const map = state.dbMessagesMap ?? {};
|
||||
const out: Record<string, any> = {};
|
||||
for (const k of Object.keys(map)) {
|
||||
const last = (map[k] ?? []).at(-1);
|
||||
out[k] = last
|
||||
? {
|
||||
id: (last.id ?? '').slice(-8),
|
||||
cLen: (last.content ?? '').length,
|
||||
rLen: (last.reasoning?.content ?? '').length,
|
||||
n: map[k].length,
|
||||
}
|
||||
: { n: 0 };
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const result = origDispatch.apply(this, [payload, ctx]);
|
||||
|
||||
// Snapshot AFTER — find which key(s) actually changed.
|
||||
try {
|
||||
const state = w.__LOBE_STORES?.chat?.();
|
||||
if (state && before) {
|
||||
const map = state.dbMessagesMap ?? {};
|
||||
for (const k of Object.keys(map)) {
|
||||
const last = (map[k] ?? []).at(-1);
|
||||
const beforeSnap = before[k];
|
||||
const afterSnap = last
|
||||
? {
|
||||
id: (last.id ?? '').slice(-8),
|
||||
cLen: (last.content ?? '').length,
|
||||
rLen: (last.reasoning?.content ?? '').length,
|
||||
n: map[k].length,
|
||||
}
|
||||
: { n: 0 };
|
||||
const changed =
|
||||
!beforeSnap ||
|
||||
beforeSnap.n !== afterSnap.n ||
|
||||
beforeSnap.id !== (afterSnap as any).id ||
|
||||
beforeSnap.cLen !== (afterSnap as any).cLen ||
|
||||
beforeSnap.rLen !== (afterSnap as any).rLen;
|
||||
if (!changed) continue;
|
||||
let delta = '';
|
||||
if (beforeSnap?.id !== undefined && beforeSnap.id !== (afterSnap as any).id)
|
||||
delta += `id:${beforeSnap.id}→${(afterSnap as any).id};`;
|
||||
if (
|
||||
beforeSnap?.cLen !== undefined &&
|
||||
(afterSnap as any).cLen !== undefined &&
|
||||
(afterSnap as any).cLen < beforeSnap.cLen
|
||||
)
|
||||
delta += `cLen↓${beforeSnap.cLen}→${(afterSnap as any).cLen};`;
|
||||
if (
|
||||
beforeSnap?.rLen !== undefined &&
|
||||
(afterSnap as any).rLen !== undefined &&
|
||||
(afterSnap as any).rLen < beforeSnap.rLen
|
||||
)
|
||||
delta += `rLen↓${beforeSnap.rLen}→${(afterSnap as any).rLen};`;
|
||||
if (beforeSnap?.n !== undefined && afterSnap.n < beforeSnap.n)
|
||||
delta += `n↓${beforeSnap.n}→${afterSnap.n};`;
|
||||
mutations.push({
|
||||
t: now(),
|
||||
key: k,
|
||||
n: afterSnap.n,
|
||||
last: (afterSnap as any).id ? (afterSnap as any) : undefined,
|
||||
prevLast: beforeSnap?.id ? beforeSnap : undefined,
|
||||
delta: delta || `dispatch:${payload?.type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
mutations.push({
|
||||
t: now(),
|
||||
key: '_DISPATCH_PROBE_ERROR_',
|
||||
n: -1,
|
||||
delta: String(e?.message ?? e),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
calls.push({ t: now(), name: '_DISPATCH_WRAP_ERROR_', error: String(e?.message ?? e) });
|
||||
}
|
||||
|
||||
// ── 4. Periodic per-key timeline snapshots ─────────────────────────
|
||||
|
||||
function captureTimeline(): void {
|
||||
try {
|
||||
const c = w.__LOBE_STORES?.chat?.();
|
||||
if (!c) return;
|
||||
const msgsMap = (c.messagesMap ?? {}) as Record<string, any[]>;
|
||||
const dbMap = (c.dbMessagesMap ?? {}) as Record<string, any[]>;
|
||||
const byKey: ProbeTimelineSample['byKey'] = {};
|
||||
for (const k of Object.keys(msgsMap)) {
|
||||
const display = msgsMap[k] ?? [];
|
||||
const db = dbMap[k] ?? [];
|
||||
if (display.length === 0 && db.length === 0) continue;
|
||||
byKey[k] = {
|
||||
n: display.length,
|
||||
dbN: db.length,
|
||||
msgs: summarizeMessages(display),
|
||||
};
|
||||
}
|
||||
const ops = Object.values((c.operations ?? {}) as Record<string, any>);
|
||||
timeline.push({
|
||||
t: now(),
|
||||
activeTopic: ((c.activeTopicId as string | null) ?? '').slice(-10) || null,
|
||||
keys: Object.keys(byKey),
|
||||
byKey,
|
||||
runOps: ops.filter((o: any) => o.status === 'running').length,
|
||||
});
|
||||
} catch (e: any) {
|
||||
timeline.push({
|
||||
t: now(),
|
||||
activeTopic: null,
|
||||
keys: [],
|
||||
byKey: {},
|
||||
runOps: 0,
|
||||
err: e?.message ?? String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
captureTimeline();
|
||||
if (w.__PROBE_TIMELINE_TIMER) clearInterval(w.__PROBE_TIMELINE_TIMER);
|
||||
w.__PROBE_TIMELINE_TIMER = setInterval(captureTimeline, 200);
|
||||
|
||||
// ── 5. Tab-switch helpers ──────────────────────────────────────────
|
||||
|
||||
function listTopBarTabs(): HTMLElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'[data-insp-path*="TabItem.tsx"][data-contextmenu-trigger]',
|
||||
),
|
||||
).filter((t) => t.getBoundingClientRect().top < 30);
|
||||
}
|
||||
|
||||
w.__listTabs = () =>
|
||||
listTopBarTabs().map((t, i) => ({
|
||||
i,
|
||||
key: t.getAttribute('data-contextmenu-trigger'),
|
||||
active: t.getAttribute('data-active') === 'true',
|
||||
title: (t.innerText ?? '').slice(0, 60),
|
||||
}));
|
||||
|
||||
w.__clickTabByKey = (key: string) => {
|
||||
const tab = listTopBarTabs().find((t) => t.getAttribute('data-contextmenu-trigger') === key);
|
||||
if (!tab) return 'not found: ' + key;
|
||||
if (tab.getAttribute('data-active') === 'true') return 'already active: ' + key;
|
||||
tab.click();
|
||||
return 'clicked key=' + key;
|
||||
};
|
||||
|
||||
w.__PROBE_EVENT = (name: string) => {
|
||||
calls.push({ t: now(), name: 'MARK:' + name });
|
||||
};
|
||||
|
||||
// `run.ts` wraps the bundle in an IIFE and appends a `return <confirmation>`
|
||||
// after the bundle body — agent-browser then prints the confirmation back to
|
||||
// the operator. Nothing to do here at the end of the module body.
|
||||
@@ -0,0 +1,204 @@
|
||||
// LobeHub chat streaming time-series probe.
|
||||
//
|
||||
// Inject into the renderer (via agent-browser eval) to record store + DOM
|
||||
// snapshots every 200ms during a streaming session. Designed to surface
|
||||
// "UI rolled back to an earlier state" symptoms — especially around
|
||||
// gateway-mode tab switches that happen while the assistant is still writing.
|
||||
//
|
||||
// Usage:
|
||||
// agent-browser --cdp 9222 eval --stdin < probe.js
|
||||
// # ...do test interactions, call window.__PROBE_EVENT('LABEL') to mark moments...
|
||||
// agent-browser --cdp 9222 eval --stdin < probe-dump.js > /tmp/probe.json
|
||||
// node analyze.mjs /tmp/probe.json
|
||||
//
|
||||
// What it captures per sample:
|
||||
// - activeTopicId
|
||||
// - msgN: top-level messages in chat.messagesMap for this topic
|
||||
// - childN: total assistantGroup.children blocks across all msgs (THIS is
|
||||
// where streaming content actually lives — top-level assistantGroup stays empty)
|
||||
// - cT / rT / toolT: totals across messages AND their children
|
||||
// (content, reasoning, tool-call count)
|
||||
// - perMsg: per-message breakdown so regressions can be located precisely
|
||||
// - runOps: number of running operations (execServerAgentRuntime etc.)
|
||||
// - domLen: total innerText length of the rendered chat list area
|
||||
// - ind: visible UI indicators (Search pages, Crawled pages, Deeply Thought, Sending)
|
||||
//
|
||||
// Event markers: window.__PROBE_EVENT('NAME') records {t, name} into
|
||||
// __PROBE_EVENTS, used by the analyzer to align state changes with
|
||||
// user-driven actions (SENT, AWAY_1, BACK_1, ...).
|
||||
|
||||
(function () {
|
||||
if (window.__PROBE_TIMER) clearInterval(window.__PROBE_TIMER);
|
||||
window.__PROBE_SAMPLES = [];
|
||||
window.__PROBE_EVENTS = [];
|
||||
const t0 = Date.now();
|
||||
|
||||
function snapshot() {
|
||||
try {
|
||||
const chat = window.__LOBE_STORES.chat();
|
||||
const topicId = chat.activeTopicId;
|
||||
const idTail = topicId ? topicId.replace('tpc_', '') : null;
|
||||
const keys = Object.keys(chat.messagesMap || {});
|
||||
|
||||
// Collect messages for the active topic. Before a topic is committed,
|
||||
// optimistic messages live under the `<agentScope>_new` key — fall
|
||||
// back to those when no topic is active yet.
|
||||
let msgs = [];
|
||||
if (idTail) {
|
||||
keys.forEach((k) => {
|
||||
if (k.includes(idTail)) msgs = msgs.concat(chat.messagesMap[k] || []);
|
||||
});
|
||||
} else {
|
||||
keys
|
||||
.filter((k) => k.endsWith('_new'))
|
||||
.forEach((k) => {
|
||||
msgs = msgs.concat(chat.messagesMap[k] || []);
|
||||
});
|
||||
}
|
||||
|
||||
// Walk top-level + assistantGroup.children. children carry the actual
|
||||
// streamed content / reasoning / tool calls; the parent assistantGroup
|
||||
// remains a placeholder (cLen=0, rLen=0) for its whole lifetime.
|
||||
let totalContent = 0;
|
||||
let totalReason = 0;
|
||||
let totalTools = 0;
|
||||
let childCount = 0;
|
||||
const perMsg = msgs.map((m) => {
|
||||
const cLen = (m.content || '').length;
|
||||
const rLen = ((m.reasoning && m.reasoning.content) || '').length;
|
||||
const tools = (m.tools || []).length;
|
||||
totalContent += cLen;
|
||||
totalReason += rLen;
|
||||
totalTools += tools;
|
||||
|
||||
const children = m.children || [];
|
||||
let chC = 0;
|
||||
let chR = 0;
|
||||
let chT = 0;
|
||||
children.forEach((c) => {
|
||||
chC += (c.content || '').length;
|
||||
chR += ((c.reasoning && c.reasoning.content) || '').length;
|
||||
chT += (c.tools || []).length;
|
||||
});
|
||||
totalContent += chC;
|
||||
totalReason += chR;
|
||||
totalTools += chT;
|
||||
childCount += children.length;
|
||||
|
||||
return {
|
||||
id: (m.id || '').slice(-8),
|
||||
role: m.role,
|
||||
cLen,
|
||||
rLen,
|
||||
tools,
|
||||
chCount: children.length,
|
||||
chC,
|
||||
chR,
|
||||
chT,
|
||||
};
|
||||
});
|
||||
|
||||
const ops = Object.values(chat.operations || {});
|
||||
const runningOps = ops.filter((o) => o.status === 'running');
|
||||
|
||||
// DOM probe: total rendered text in the chat scroll area (proxy for
|
||||
// "how much is actually visible to the user").
|
||||
const convScroll =
|
||||
document.querySelector(
|
||||
'[data-chat-list], [class*="ChatList"], [class*="ConversationList"]',
|
||||
) ||
|
||||
document.querySelector('main [class*="scroll"]') ||
|
||||
document.querySelector('main');
|
||||
const domTxt = convScroll ? convScroll.innerText || '' : '';
|
||||
|
||||
const bodyTxt = document.body.innerText || '';
|
||||
const searchMatches = (bodyTxt.match(/Search pages?:|Searched the web/g) || []).length;
|
||||
const crawlMatches = (bodyTxt.match(/Crawl(ed|ing) pages?/g) || []).length;
|
||||
|
||||
window.__PROBE_SAMPLES.push({
|
||||
t: Date.now() - t0,
|
||||
topicId,
|
||||
msgN: msgs.length,
|
||||
childN: childCount,
|
||||
cT: totalContent,
|
||||
rT: totalReason,
|
||||
toolT: totalTools,
|
||||
perMsg,
|
||||
runOps: runningOps.length,
|
||||
runOpTypes: runningOps.map((o) => o.type),
|
||||
domLen: domTxt.length,
|
||||
ind: {
|
||||
search: searchMatches,
|
||||
crawl: crawlMatches,
|
||||
sending: bodyTxt.includes('Sending message'),
|
||||
deeplyThinking: bodyTxt.includes('Deeply Thinking'),
|
||||
deeplyThought: bodyTxt.includes('Deeply Thought'),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
window.__PROBE_SAMPLES.push({ t: Date.now() - t0, err: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
snapshot();
|
||||
window.__PROBE_TIMER = setInterval(snapshot, 200);
|
||||
window.__PROBE_EVENT = function (name) {
|
||||
window.__PROBE_EVENTS.push({ t: Date.now() - t0, name });
|
||||
};
|
||||
|
||||
// Tab-switch helpers installed alongside the probe.
|
||||
//
|
||||
// The Electron tab bar mounts each tab as a div with data-insp-path
|
||||
// ending in `TabItem.tsx:...`. The active tab is marked with
|
||||
// data-active="true". DO NOT search by innerText — the active tab's text
|
||||
// includes a ` · <agent name>` suffix that produces false matches when
|
||||
// your search string happens to overlap with the agent name.
|
||||
function listTabs() {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-insp-path*="TabItem.tsx"][data-contextmenu-trigger]'),
|
||||
).filter((t) => t.getBoundingClientRect().top < 30);
|
||||
}
|
||||
function tabKey(el) {
|
||||
// Stable for the tab's lifetime; survives focus changes.
|
||||
return el.getAttribute('data-contextmenu-trigger');
|
||||
}
|
||||
function findActiveTab() {
|
||||
return listTabs().find((t) => t.getAttribute('data-active') === 'true') || null;
|
||||
}
|
||||
|
||||
// Click by stable key captured earlier (preferred for round-trips).
|
||||
window.__clickTabByKey = function (key) {
|
||||
const tab = listTabs().find((t) => tabKey(t) === key);
|
||||
if (!tab) return 'not found: key=' + key;
|
||||
if (tab.getAttribute('data-active') === 'true') return 'already active: ' + key;
|
||||
tab.click();
|
||||
return 'clicked key=' + key;
|
||||
};
|
||||
|
||||
// Click by index in the tab strip (0-based, left-to-right).
|
||||
window.__clickTabByIndex = function (i) {
|
||||
const tabs = listTabs();
|
||||
if (i < 0 || i >= tabs.length) return 'index out of range: ' + i + '/' + tabs.length;
|
||||
const t = tabs[i];
|
||||
if (t.getAttribute('data-active') === 'true') return 'already active: i=' + i;
|
||||
t.click();
|
||||
return 'clicked i=' + i + ' key=' + tabKey(t);
|
||||
};
|
||||
|
||||
// Snapshot all tabs in order: [{key, active, title (first 60 chars of innerText)}]
|
||||
window.__listTabs = function () {
|
||||
return listTabs().map((t, i) => ({
|
||||
i,
|
||||
key: tabKey(t),
|
||||
active: t.getAttribute('data-active') === 'true',
|
||||
title: (t.innerText || '').slice(0, 60),
|
||||
}));
|
||||
};
|
||||
|
||||
window.__activeTabKey = function () {
|
||||
const a = findActiveTab();
|
||||
return a ? tabKey(a) : null;
|
||||
};
|
||||
|
||||
return 'probe installed';
|
||||
})();
|
||||
@@ -0,0 +1,211 @@
|
||||
// CLI for the agent-gateway probe.
|
||||
//
|
||||
// Bundles the TS probes with esbuild, pipes them into `agent-browser eval`,
|
||||
// and persists dumps under `.agent-gateway/` (gitignored) for later use as
|
||||
// streaming-replay test fixtures.
|
||||
//
|
||||
// Commands:
|
||||
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts install
|
||||
// Bundle probe-events.ts and inject into the CDP-attached browser.
|
||||
// Re-installing clears all buffers and re-patches WebSocket / fetch.
|
||||
//
|
||||
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts dump [name]
|
||||
// Stop the timeline timer, fetch the capture as JSON, write it to
|
||||
// `.agent-gateway/<name>-<YYYYMMDD-HHmmss>.json`. `name` defaults to
|
||||
// `dump`. Prints the absolute path written.
|
||||
//
|
||||
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts analyze [path]
|
||||
// Run analyze-events.ts on the dump. `path` defaults to the most
|
||||
// recently modified file in `.agent-gateway/`.
|
||||
//
|
||||
// Optional flags:
|
||||
// --cdp <port> CDP port (default 9222)
|
||||
// --browser <bin> agent-browser binary (default 'agent-browser')
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
// .agents/skills/local-testing/scripts/agent-gateway/ → 5 levels up
|
||||
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '../../../../..');
|
||||
const DUMP_DIR = path.join(PROJECT_ROOT, '.agent-gateway');
|
||||
|
||||
interface Flags {
|
||||
browser: string;
|
||||
cdp: string;
|
||||
positional: string[];
|
||||
}
|
||||
|
||||
function parseFlags(argv: string[]): Flags {
|
||||
const out: Flags = { cdp: '9222', browser: 'agent-browser', positional: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--cdp') out.cdp = argv[++i] ?? out.cdp;
|
||||
else if (a === '--browser') out.browser = argv[++i] ?? out.browser;
|
||||
else out.positional.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function bundle(entry: string): Promise<string> {
|
||||
// Bun.build is built into the Bun runtime — no external dep needed.
|
||||
const r = await Bun.build({
|
||||
entrypoints: [path.join(SCRIPT_DIR, entry)],
|
||||
target: 'browser',
|
||||
format: 'esm',
|
||||
minify: false,
|
||||
});
|
||||
if (!r.success) {
|
||||
const msgs = r.logs.map((l) => `${l.level}: ${l.message}`).join('\n');
|
||||
throw new Error(`bundle failed for ${entry}:\n${msgs}`);
|
||||
}
|
||||
return await r.outputs[0].text();
|
||||
}
|
||||
|
||||
function wrapIife(body: string, returnExpr: string): string {
|
||||
// Wrap as an IIFE that swallows the bundled top-level (top-level `const`
|
||||
// declarations get scoped to the IIFE, so re-injection doesn't conflict)
|
||||
// and returns the configured expression — which `agent-browser eval`
|
||||
// captures and prints to stdout.
|
||||
return `(() => {\n${body}\n;return ${returnExpr};\n})()`;
|
||||
}
|
||||
|
||||
function runAgentBrowserEval(flags: Flags, script: string): Promise<string> {
|
||||
return new Promise((resolveP, rejectP) => {
|
||||
const child = spawn(flags.browser, ['--cdp', flags.cdp, 'eval', '--stdin'], {
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
});
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', rejectP);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolveP(stdout);
|
||||
else rejectP(new Error(`agent-browser exited ${code}`));
|
||||
});
|
||||
child.stdin.write(script);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
// agent-browser prints eval results as JSON (string values are quoted).
|
||||
function unquoteAgentBrowserResult(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
try {
|
||||
return JSON.parse(trimmed) as string;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isoStamp(): string {
|
||||
const d = new Date();
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
|
||||
}
|
||||
|
||||
function ensureDumpDir(): void {
|
||||
mkdirSync(DUMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function latestDump(): string | null {
|
||||
ensureDumpDir();
|
||||
const entries = readdirSync(DUMP_DIR)
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.map((f) => ({ f, mtime: statSync(path.join(DUMP_DIR, f)).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
return entries[0] ? path.join(DUMP_DIR, entries[0].f) : null;
|
||||
}
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────
|
||||
|
||||
async function cmdInstall(flags: Flags): Promise<void> {
|
||||
const body = await bundle('probe-events.ts');
|
||||
const installMsg = JSON.stringify(
|
||||
'events probe installed: WebSocket+fetch interception. ' +
|
||||
'WS captures operationId= sockets (gateway), fetch captures /api/agent/stream (direct).',
|
||||
);
|
||||
const script = wrapIife(body, installMsg);
|
||||
const out = await runAgentBrowserEval(flags, script);
|
||||
console.log(unquoteAgentBrowserResult(out));
|
||||
}
|
||||
|
||||
async function cmdDump(flags: Flags): Promise<void> {
|
||||
const name = flags.positional[1] ?? 'dump';
|
||||
const body = await bundle('probe-dump.ts');
|
||||
const script = wrapIife(body, 'window.__PROBE_LAST_DUMP_JSON');
|
||||
const raw = await runAgentBrowserEval(flags, script);
|
||||
const json = unquoteAgentBrowserResult(raw);
|
||||
ensureDumpDir();
|
||||
const filename = `${name}-${isoStamp()}.json`;
|
||||
const dumpPath = path.join(DUMP_DIR, filename);
|
||||
writeFileSync(dumpPath, json, 'utf8');
|
||||
// Validate by parsing the meta header so we error early on bad capture
|
||||
try {
|
||||
const parsed = JSON.parse(json) as {
|
||||
meta?: { eventCount?: number; callCount?: number; sampleCount?: number };
|
||||
};
|
||||
const meta = parsed.meta ?? {};
|
||||
console.log(
|
||||
`wrote ${dumpPath} (${json.length} bytes events=${meta.eventCount ?? '?'} ` +
|
||||
`calls=${meta.callCount ?? '?'} samples=${meta.sampleCount ?? '?'})`,
|
||||
);
|
||||
} catch {
|
||||
console.log(`wrote ${dumpPath} (${json.length} bytes — JSON.parse failed; see file)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdAnalyze(flags: Flags): Promise<void> {
|
||||
const target = flags.positional[1] ?? latestDump();
|
||||
if (!target) {
|
||||
console.error('no dump file found. run `dump` first or pass a path.');
|
||||
process.exit(1);
|
||||
}
|
||||
const child = spawn('bun', ['run', path.join(SCRIPT_DIR, 'analyze-events.ts'), target], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
await new Promise<void>((resolveP, rejectP) => {
|
||||
child.on('error', rejectP);
|
||||
child.on('close', (code) => (code === 0 ? resolveP() : rejectP(new Error(`exit ${code}`))));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────
|
||||
|
||||
const flags = parseFlags(process.argv.slice(2));
|
||||
const cmd = flags.positional[0];
|
||||
|
||||
const usage = `usage:
|
||||
bun run run.ts install [--cdp 9222]
|
||||
bun run run.ts dump [name] [--cdp 9222]
|
||||
bun run run.ts analyze [path]
|
||||
`;
|
||||
|
||||
if (!cmd) {
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
if (cmd === 'install') await cmdInstall(flags);
|
||||
else if (cmd === 'dump') await cmdDump(flags);
|
||||
else if (cmd === 'analyze') await cmdAnalyze(flags);
|
||||
else {
|
||||
console.error(`unknown command: ${cmd}\n\n${usage}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e?.stack ?? e);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Run N round-trip tab switches with event markers timed against the probe.
|
||||
//
|
||||
// agent-browser --cdp 9222 eval --stdin < tab-switch.js
|
||||
//
|
||||
// Captures the currently-active tab as the BACK target and the rightmost
|
||||
// inactive tab as the AWAY target. Both are addressed by their stable
|
||||
// data-contextmenu-trigger key (NOT by visible title — the active tab's
|
||||
// innerText embeds a ` · <agent name>` suffix that breaks text matching).
|
||||
//
|
||||
// Fires the loop in the background and returns immediately so the
|
||||
// agent-browser eval doesn't have to await the full ROUND_TRIPS × DWELL_MS
|
||||
// duration. Wait on the `SWITCH_LOOP_DONE` event before dumping.
|
||||
//
|
||||
// Refuses to launch if a previous loop is still in flight.
|
||||
//
|
||||
// Requires probe.js to have been installed first (provides
|
||||
// window.__PROBE_EVENT / __listTabs / __clickTabByKey / __activeTabKey).
|
||||
|
||||
(function () {
|
||||
const ROUND_TRIPS = 4;
|
||||
const DWELL_MS = 10_000;
|
||||
|
||||
if (!window.__PROBE_EVENT || !window.__listTabs || !window.__clickTabByKey) {
|
||||
return 'probe not installed — eval probe.js first';
|
||||
}
|
||||
if (window.__SWITCH_LOOP_RUNNING) {
|
||||
return 'switch loop already running — wait for SWITCH_LOOP_DONE first';
|
||||
}
|
||||
|
||||
const tabs = window.__listTabs();
|
||||
const activeTab = tabs.find((t) => t.active);
|
||||
if (!activeTab) return 'no active tab — abort';
|
||||
|
||||
// Pick the first inactive tab as AWAY target. With multiple inactive tabs
|
||||
// you'll usually want the one that's stable across the test — feel free
|
||||
// to swap to tabs[tabs.length-1] if you want the rightmost.
|
||||
const inactives = tabs.filter((t) => !t.active);
|
||||
if (inactives.length === 0) return 'no inactive tab to switch to — abort';
|
||||
const awayTab = inactives.at(-1); // rightmost inactive
|
||||
|
||||
const BACK_KEY = activeTab.key;
|
||||
const AWAY_KEY = awayTab.key;
|
||||
|
||||
window.__SWITCH_LOOP_RUNNING = true;
|
||||
window.__PROBE_EVENT('SWITCH_LOOP_CONFIG:back=' + BACK_KEY + ',away=' + AWAY_KEY);
|
||||
|
||||
(async function () {
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
try {
|
||||
window.__PROBE_EVENT('SWITCH_LOOP_START');
|
||||
for (let i = 1; i <= ROUND_TRIPS; i++) {
|
||||
window.__PROBE_EVENT('AWAY_' + i);
|
||||
const awayResult = window.__clickTabByKey(AWAY_KEY);
|
||||
window.__PROBE_EVENT('AWAY_' + i + '_RES:' + awayResult.slice(0, 50));
|
||||
await sleep(DWELL_MS);
|
||||
|
||||
window.__PROBE_EVENT('BACK_' + i);
|
||||
const backResult = window.__clickTabByKey(BACK_KEY);
|
||||
window.__PROBE_EVENT('BACK_' + i + '_RES:' + backResult.slice(0, 50));
|
||||
await sleep(DWELL_MS);
|
||||
}
|
||||
window.__PROBE_EVENT('SWITCH_LOOP_DONE');
|
||||
} finally {
|
||||
window.__SWITCH_LOOP_RUNNING = false;
|
||||
}
|
||||
})();
|
||||
|
||||
return 'switch loop kicked off (BACK=' + BACK_KEY + ', AWAY=' + AWAY_KEY + ')';
|
||||
})();
|
||||
@@ -0,0 +1,113 @@
|
||||
// Shared types between the in-browser probe and the Node-side analyzer.
|
||||
// Kept tiny on purpose — anything the analyzer can re-derive is left off.
|
||||
|
||||
export interface ProbeStreamEvent {
|
||||
/** Summarized payload — long strings truncated, arrays printed as Array(N) */
|
||||
data?: Record<string, unknown>;
|
||||
/** Keys present on the event's `data` payload — useful at a glance */
|
||||
dataKeys?: string[];
|
||||
/** ServerMessage.id — gateway WS frames carry an event-id we may resume from */
|
||||
eventId?: string | null;
|
||||
message?: string;
|
||||
/** Last 10 chars of the operationId (full id is excessively long) */
|
||||
opIdTail: string;
|
||||
raw?: string;
|
||||
/** Raw frame byte length, when applicable */
|
||||
rawLen?: number;
|
||||
/** For non-agent_event server frames (auth_success, heartbeat_ack, …) */
|
||||
serverType?: string;
|
||||
sseEvent?: string;
|
||||
status?: number;
|
||||
stepIndex?: number;
|
||||
/** Milliseconds since the probe's t0 (install time). */
|
||||
t: number;
|
||||
/** 'ws' for gateway WebSocket frames, 'sse' for direct /api/agent/stream */
|
||||
transport: 'ws' | 'sse';
|
||||
/** Either the AgentStreamEvent.type, or a probe sentinel like `_WS_OPEN_` */
|
||||
type: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface ProbeActionCall {
|
||||
args?: {
|
||||
count?: number;
|
||||
context?: unknown;
|
||||
params?: unknown;
|
||||
};
|
||||
error?: string;
|
||||
/** `replaceMessages` / `refreshMessages` / `MARK:<label>` / `_WRAP_ERROR_` */
|
||||
name: string;
|
||||
stack?: string;
|
||||
t: number;
|
||||
}
|
||||
|
||||
export interface ProbeMessageSummary {
|
||||
/** children.length */
|
||||
chN: number;
|
||||
/** content.length */
|
||||
cLen: number;
|
||||
/** Last 8 chars of the message id */
|
||||
id: string;
|
||||
/** reasoning.content.length */
|
||||
rLen: number;
|
||||
role: string;
|
||||
/** tools.length */
|
||||
tools: number;
|
||||
}
|
||||
|
||||
export interface ProbeTimelineSample {
|
||||
/** Last 10 chars of activeTopicId, or null */
|
||||
activeTopic: string | null;
|
||||
/** Per-key breakdown: display count, db count, message summaries */
|
||||
byKey: Record<
|
||||
string,
|
||||
{
|
||||
n: number;
|
||||
dbN: number;
|
||||
msgs: ProbeMessageSummary[];
|
||||
}
|
||||
>;
|
||||
err?: string;
|
||||
/** All messagesMap keys that have content at this moment */
|
||||
keys: string[];
|
||||
/** Number of operations in 'running' status */
|
||||
runOps: number;
|
||||
t: number;
|
||||
}
|
||||
|
||||
export interface ProbeDumpMeta {
|
||||
callCount: number;
|
||||
/** Date.now() at dump call */
|
||||
collectedAt: number;
|
||||
eventCount: number;
|
||||
sampleCount: number;
|
||||
/** Date.now() at probe install */
|
||||
t0: number;
|
||||
}
|
||||
|
||||
export interface ProbeDump {
|
||||
actionCalls: ProbeActionCall[];
|
||||
meta: ProbeDumpMeta;
|
||||
streamEvents: ProbeStreamEvent[];
|
||||
timeline: ProbeTimelineSample[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Globals the probe attaches to `window`. Keeps `as any` casts at the boundary
|
||||
* instead of sprinkling them through the probe body.
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
__clickTabByKey?: (key: string) => string;
|
||||
__listTabs?: () => Array<{ i: number; key: string | null; active: boolean; title: string }>;
|
||||
__LOBE_STORES?: Record<string, () => any>;
|
||||
__PROBE_ACTION_CALLS?: ProbeActionCall[];
|
||||
__PROBE_EVENT?: (label: string) => void;
|
||||
__PROBE_MSG_TIMELINE?: ProbeTimelineSample[];
|
||||
__PROBE_ORIG_FETCH?: typeof fetch;
|
||||
__PROBE_ORIG_WEBSOCKET?: typeof WebSocket;
|
||||
__PROBE_STREAM_EVENTS?: ProbeStreamEvent[];
|
||||
__PROBE_T0?: number;
|
||||
__PROBE_TIMELINE_TIMER?: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: pr
|
||||
description: "Create a PR for the current branch. Use when the user asks to create a pull request, submit PR, or says 'pr'."
|
||||
description: "Create a PR for the current branch (targets `canary` by default). Use when the user asks to create a pull request, submit a PR, or says 'pr'. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', '提 PR', '提个 PR', '新建 PR'."
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: project-overview
|
||||
description: Complete project architecture and structure guide. Use when exploring the codebase, understanding project organization, finding files, or needing comprehensive architectural context. Triggers on architecture questions, directory navigation, or project overview needs.
|
||||
description: "LobeHub open-source monorepo architecture map — flat `apps/` + `packages/@lobechat/*` + `src/` layout, per-layer location table, and `src/business/` stubs that the cloud repo overrides. Use when exploring an unfamiliar part of the codebase, locating where a layer lives (store / service / router / schema / etc.), or onboarding to the monorepo. Triggers on 'where does X live', 'project structure', 'monorepo layout', `src/business/` stub, 'architecture overview', '项目结构', '架构总览'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -13,11 +13,12 @@ user-invocable: false
|
||||
## Project Description
|
||||
|
||||
Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat).
|
||||
This repo is the **open-source root** (`github.com/lobehub/lobehub`, package `@lobehub/lobehub`).
|
||||
|
||||
**Supported platforms:**
|
||||
|
||||
- Web desktop/mobile
|
||||
- Desktop (Electron)
|
||||
- Desktop (Electron) — `apps/desktop`
|
||||
- Mobile app (React Native) — **separate repo, already launched** (not in this monorepo)
|
||||
|
||||
**Logo emoji:** 🤯
|
||||
@@ -47,30 +48,28 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
|
||||
|
||||
## Monorepo Layout
|
||||
|
||||
This is a monorepo extending the open-source `lobehub` submodule. Two repos:
|
||||
|
||||
- **cloud repo root** — `src/` and `packages/business/` (`config`, `const`, `model-runtime`) hold cloud-only SaaS code that overrides/extends the submodule. See `AGENTS.md` for the override mechanism.
|
||||
- **`lobehub/` submodule** — the open-source product core.
|
||||
|
||||
### `lobehub/` submodule — key directories
|
||||
Flat layout — `apps/`, `packages/`, and `src/` all sit at the repo root. No
|
||||
git submodules.
|
||||
|
||||
```
|
||||
lobehub/
|
||||
(repo root)
|
||||
├── apps/
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── docs/ # changelog, development, self-hosting, usage
|
||||
├── locales/ # en-US, zh-CN, ...
|
||||
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── docs/ # changelog, development, self-hosting, usage
|
||||
├── locales/ # en-US, zh-CN, ...
|
||||
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
|
||||
│ ├── agent-runtime/ # Agent runtime core
|
||||
│ ├── agent-signal/ # Agent Signal pipeline
|
||||
│ ├── builtin-tool-*/ # Builtin tool packages
|
||||
│ ├── builtin-tools/ # Builtin tool registries
|
||||
│ ├── agent-tracing/ # Tracing / snapshots
|
||||
│ ├── builtin-tool-*/ # Per-tool packages (calculator, web-browsing, claude-code, ...)
|
||||
│ ├── builtin-tools/ # Central registries that compose builtin-tool-*
|
||||
│ ├── context-engine/
|
||||
│ ├── database/ # src/{models,schemas,repositories}
|
||||
│ ├── model-bank/ # Model definitions & provider cards
|
||||
│ ├── model-runtime/ # src/{core,providers}
|
||||
│ ├── business/ # Open-source stubs (config, const, model-bank, model-runtime) — overridden by cloud
|
||||
│ ├── types/
|
||||
│ └── utils/
|
||||
└── src/
|
||||
@@ -83,55 +82,54 @@ lobehub/
|
||||
├── spa/ # SPA entries + router config
|
||||
│ ├── entry.{web,mobile,desktop,popup}.tsx
|
||||
│ └── router/
|
||||
├── business/ # Open-source stubs (~50) overridden by cloud src/business/
|
||||
├── business/ # Open-source stubs (client/server) — cloud repo provides real impls
|
||||
├── features/ # Domain business components
|
||||
├── store/ # ~28 zustand stores — `ls` for the full set
|
||||
├── server/ # featureFlags, globalConfig, modules, routers, services
|
||||
├── store/ # ~30 zustand stores — `ls` for the full set
|
||||
├── server/ # featureFlags, globalConfig, modules, routers, services, workflows, agent-hono
|
||||
└── ... # components, hooks, layout, libs, locales, services, types, utils
|
||||
```
|
||||
|
||||
### cloud repo — key directories
|
||||
|
||||
```
|
||||
(cloud root)
|
||||
├── packages/business/ # Cloud overrides: config, const, model-runtime
|
||||
├── src/
|
||||
│ ├── business/ # Cloud impls of submodule stubs (client/server/locales)
|
||||
│ ├── routes/ # Cloud-only route groups: (cloud)/, embed/
|
||||
│ ├── store/ # Cloud-only stores (e.g. subscription/)
|
||||
│ ├── server/ # Cloud routers & services (billing, budget, risk control...)
|
||||
│ └── app/(backend)/cron/ # Vercel cron routes (schedules declared in root vercel.ts)
|
||||
└── vercel.ts # Cron schedule declarations
|
||||
```
|
||||
|
||||
> File search rule: a path like `@/store/x` resolves cloud `src/store/x` first, then
|
||||
> `lobehub/packages/store/src/x`, then `lobehub/src/store/x`. Cloud override wins.
|
||||
|
||||
## Architecture Map
|
||||
|
||||
| Layer | Location |
|
||||
| ---------------- | ---------------------------------------------------- |
|
||||
| UI Components | `src/components`, `src/features` |
|
||||
| SPA Pages | `src/routes/` |
|
||||
| React Router | `src/spa/router/` |
|
||||
| Global Providers | `src/layout` |
|
||||
| Zustand Stores | `src/store` |
|
||||
| Client Services | `src/services/` |
|
||||
| REST API | `src/app/(backend)/webapi` |
|
||||
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
|
||||
| Server Services | `src/server/services` (can access DB) |
|
||||
| Server Modules | `src/server/modules` (no DB access) |
|
||||
| Feature Flags | `src/server/featureFlags` |
|
||||
| Global Config | `src/server/globalConfig` |
|
||||
| DB Schema | `packages/database/src/schemas` |
|
||||
| DB Model | `packages/database/src/models` |
|
||||
| DB Repository | `packages/database/src/repositories` |
|
||||
| Third-party | `src/libs` (analytics, oidc, etc.) |
|
||||
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
|
||||
| Cloud-only | `src/business/*`, `packages/business/*` (cloud repo) |
|
||||
| Layer | Location |
|
||||
| ---------------- | --------------------------------------------------- |
|
||||
| UI Components | `src/components`, `src/features` |
|
||||
| SPA Pages | `src/routes/` |
|
||||
| React Router | `src/spa/router/` |
|
||||
| Global Providers | `src/layout` |
|
||||
| Zustand Stores | `src/store` |
|
||||
| Client Services | `src/services/` |
|
||||
| REST API | `src/app/(backend)/webapi` |
|
||||
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
|
||||
| Server Services | `src/server/services` (can access DB) |
|
||||
| Server Modules | `src/server/modules` (no DB access) |
|
||||
| Feature Flags | `src/server/featureFlags` |
|
||||
| Global Config | `src/server/globalConfig` |
|
||||
| DB Schema | `packages/database/src/schemas` |
|
||||
| DB Model | `packages/database/src/models` |
|
||||
| DB Repository | `packages/database/src/repositories` |
|
||||
| Third-party | `src/libs` (analytics, oidc, etc.) |
|
||||
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
|
||||
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
React UI → Store Actions → Client Service → TRPC Lambda → Server Services → DB Model → PostgreSQL
|
||||
```
|
||||
|
||||
## Note: Relationship to the Cloud Repo
|
||||
|
||||
This open-source repo is consumed by a **separate, private cloud (SaaS) repo**
|
||||
as a git submodule mounted at `lobehub/`. The cloud repo provides:
|
||||
|
||||
- **`src/business/{client,server}`** and **`packages/business/*`** implementations
|
||||
that override the stubs shipped here.
|
||||
- Cloud-only routes (e.g. `(cloud)/`, `embed/`), cloud-only stores (e.g.
|
||||
`subscription/`), cloud-only TRPC routers (billing, budget, risk control, …),
|
||||
and Vercel cron routes under `src/app/(backend)/cron/`.
|
||||
- File-resolution order in cloud: `@/store/x` → cloud `src/store/x` first, then
|
||||
`lobehub/packages/store/src/x`, then `lobehub/src/store/x`. **Cloud override wins.**
|
||||
|
||||
When working in this repo alone, ignore the cloud layer — the stubs in
|
||||
`src/business/` and `packages/business/` are the source of truth here.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: react
|
||||
description: 'Use when writing or editing any `.tsx` under `src/**`. Triggers: createStaticStyles, createStyles, cssVar, antd-style, Flexbox, Center, Select, Modal, Drawer, Button, Tooltip, DropdownMenu, Popover, Switch, ScrollArea, Link, useNavigate, react-router-dom, next/link, desktopRouter, componentMap.desktop, .desktop.tsx, new component, new page, edit layout, add styles, zustand selector, @lobehub/ui, antd import.'
|
||||
description: "LobeHub React component conventions — base-ui (`@lobehub/ui/base-ui`) first for headless primitives (Select, Modal, DropdownMenu, ContextMenu, Popover, ScrollArea, Switch, Toast, FloatingSheet), then `@lobehub/ui` root, antd as last resort; styling via `antd-style` `createStaticStyles` + `cssVar.*` (zero-runtime preferred over `createStyles` + `token`); routing via `react-router-dom` (not `next/link`). Use when writing or editing any `.tsx` under `src/**`. Triggers on `createStaticStyles`, `createStyles`, `cssVar`, `antd-style`, `Flexbox`, `Center`, `Select`, `Modal`, `Drawer`, `Button`, `Tooltip`, `DropdownMenu`, `ContextMenu`, `Popover`, `Switch`, `ScrollArea`, `Toast`, `FloatingSheet`, `Link`, `useNavigate`, `react-router-dom`, `next/link`, `desktopRouter`, `componentMap.desktop`, `.desktop.tsx`, `base-ui`, `@lobehub/ui/base-ui`, 'new component', 'new page', 'edit layout', 'add styles', 'zustand selector', '@lobehub/ui', 'antd import'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -17,22 +17,41 @@ user-invocable: false
|
||||
## Component Priority
|
||||
|
||||
1. **`src/components`** — project-specific reusable components
|
||||
2. **`@lobehub/ui/base-ui`** — headless primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…)
|
||||
3. **`@lobehub/ui`** — higher-level components (ActionIcon, Markdown, DragPage…)
|
||||
4. **Custom implementation** — last resort; never reach for antd directly
|
||||
2. **`@lobehub/ui/base-ui`** — headless primitives. **If the component lives here, use it. Do NOT import the same-named root export.**
|
||||
3. **`@lobehub/ui`** — higher-level / antd-wrapping components (only when no base-ui equivalent)
|
||||
4. **antd** — only when neither base-ui nor `@lobehub/ui` root provides it
|
||||
5. **Custom implementation** — true last resort
|
||||
|
||||
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`.
|
||||
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs` and `node_modules/@lobehub/ui/es/base-ui/`.
|
||||
|
||||
### Common @lobehub/ui Components
|
||||
### `@lobehub/ui/base-ui` — always prefer for these
|
||||
|
||||
| Category | Components |
|
||||
| ------------ | ------------------------------------------------------------------------------- |
|
||||
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
|
||||
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
|
||||
| Data Entry | CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select |
|
||||
| Feedback | Alert, Drawer, Modal |
|
||||
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
|
||||
| Navigation | Burger, Dropdown, Menu, SideNav, Tabs |
|
||||
| Component | Import |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| `Select` (+ `SelectProps`, `SelectOption`) | `import { Select } from '@lobehub/ui/base-ui';` |
|
||||
| `Modal` (imperative API) | `import { createModal, confirmModal, useModalContext, type ModalInstance } from '@lobehub/ui/base-ui';` |
|
||||
| `DropdownMenu` | `import { DropdownMenu } from '@lobehub/ui/base-ui';` |
|
||||
| `ContextMenu` | `import { ContextMenu } from '@lobehub/ui/base-ui';` |
|
||||
| `Popover` | `import { Popover } from '@lobehub/ui/base-ui';` |
|
||||
| `ScrollArea` | `import { ScrollArea } from '@lobehub/ui/base-ui';` |
|
||||
| `Switch` | `import { Switch } from '@lobehub/ui/base-ui';` |
|
||||
| `Toast` | `import { Toast } from '@lobehub/ui/base-ui';` |
|
||||
| `FloatingSheet` | `import { FloatingSheet } from '@lobehub/ui/base-ui';` |
|
||||
|
||||
For Modal specifically, see the dedicated **modal** skill — use the imperative `createModal({ content: … })` pattern over the legacy `<Modal open … />` declarative pattern. base-ui has its own `ModalHost` already mounted in `SPAGlobalProvider`.
|
||||
|
||||
> Common slip: `import { Select } from '@lobehub/ui'` looks fine but it's the antd-backed Select. Use base-ui Select. Same for `Modal`, `DropdownMenu`, etc.
|
||||
|
||||
### `@lobehub/ui` root — use when base-ui has no equivalent
|
||||
|
||||
| Category | Components |
|
||||
| ------------ | ------------------------------------------------------------------------------------- |
|
||||
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
|
||||
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
|
||||
| Data Entry | CodeEditor, CopyButton, EditableText, Form, Input, InputPassword, SearchBar, TextArea |
|
||||
| Feedback | Alert, Drawer |
|
||||
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
|
||||
| Navigation | Burger, Menu, SideNav, Tabs |
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -85,12 +104,15 @@ errorElement: <ErrorBoundary />;
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| ----------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
|
||||
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
|
||||
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
|
||||
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
|
||||
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
|
||||
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
|
||||
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
|
||||
| Mistake | Fix |
|
||||
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
|
||||
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
|
||||
| `import { Select } from '@lobehub/ui'` | `import { Select } from '@lobehub/ui/base-ui'` |
|
||||
| `import { Modal } from '@lobehub/ui'` + `<Modal open>` declarative | `createModal` / `confirmModal` from `@lobehub/ui/base-ui` (see modal skill) |
|
||||
| `import { DropdownMenu/Popover/Switch } from '@lobehub/ui'` | Import same name from `@lobehub/ui/base-ui` instead |
|
||||
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
|
||||
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
|
||||
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
|
||||
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
|
||||
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: review-checklist
|
||||
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
|
||||
description: "Common recurring mistakes in LobeHub code review — `console.*` leftovers, missing `return await`, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs `@lobehub/ui`, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing a PR, diff, or branch change. Triggers on 'code review', 'review the diff', 'review this PR', 'review changes', 'PR review checklist', '审一下', '审 PR'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: skills-audit
|
||||
description: Weekly audit of `.agents/skills/*/SKILL.md` — surfaces duplicate / overlapping / stale skills, inconsistent descriptions, broken cross-references, and merge/delete candidates. Run as a recurring health-check, not during normal feature work.
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[--verbose | --apply]'
|
||||
---
|
||||
|
||||
# Skills Audit
|
||||
|
||||
Periodic review of the project-local skill set under `.agents/skills/`. The goal is to catch drift before the catalog becomes confusing — too many skills, overlapping triggers, descriptions that no longer match the body, references to skills that were renamed/deleted.
|
||||
|
||||
**Recommended cadence:** weekly, or after any week where >1 skill was added/renamed.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1 — Inventory
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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/`).
|
||||
|
||||
### 2 — Pull frontmatter for all skills
|
||||
|
||||
```bash
|
||||
# Extract name + description for each SKILL.md
|
||||
for f in .agents/skills/*/SKILL.md; do
|
||||
echo "=== $(basename $(dirname $f)) ==="
|
||||
awk '/^---$/{c++; next} c==1' "$f" | head -20
|
||||
done
|
||||
```
|
||||
|
||||
Read the description block of every skill. The body can stay unread unless step 4 flags it.
|
||||
|
||||
### 3 — Detect overlap / redundancy
|
||||
|
||||
For each pair within the same domain, ask:
|
||||
|
||||
- **Same description**? → likely duplicate (one is probably a stale rename leftover, or a global-vs-local collision).
|
||||
- **Trigger keywords substantially overlap**? → either merge, OR tighten one description so the model can choose unambiguously.
|
||||
- **One skill's body says "see also: foo"**? → confirm `foo` still exists, AND confirm the cross-reference is still meaningful (the referenced skill may have absorbed the referrer's concerns).
|
||||
- **Skill duplicates content from `AGENTS.md`**? → fold into AGENTS.md or slim the skill to just the delta.
|
||||
|
||||
Common false positives (do NOT merge):
|
||||
|
||||
- `db-migrations` vs `drizzle` — distinct workflows (migration files vs schema authoring).
|
||||
- `microcopy` vs `i18n` — content vs mechanics.
|
||||
- `agent-runtime-hooks` vs `agent-tracing` vs `agent-signal` — different surfaces of the agent system.
|
||||
- `testing` vs `local-testing` vs `cli-backend-testing` — different test types.
|
||||
|
||||
### 4 — Description format consistency
|
||||
|
||||
Apply the **standard template**:
|
||||
|
||||
```
|
||||
{Topic + key conventions or scope}. Use when {scenarios — verbs + nouns}. Triggers on {`code-symbols`, 'natural phrases', '中文'}.
|
||||
```
|
||||
|
||||
Skills with `disable-model-invocation: true` (user-invoked only, slash commands) don't need `Triggers on` — they're never auto-routed.
|
||||
|
||||
Flag descriptions that:
|
||||
|
||||
- ❌ Have NO `Use when` clause (model can't decide when to load it).
|
||||
- ❌ Have NO `Triggers on` clause (and aren't `disable-model-invocation`).
|
||||
- ❌ Use weird formats (numbered lists `(1)(2)(3)`, `Triggers:` colon instead of `Triggers on`, `MUST use when ...` as opening word).
|
||||
- ❌ Are dramatically terse for a 200+ line body, or dramatically verbose for a 60-line body.
|
||||
- ❌ Reference deleted/renamed skills.
|
||||
|
||||
### 5 — Stale-skill check
|
||||
|
||||
For narrow domain skills (e.g. `response-compliance`, one-off CLI workflows):
|
||||
|
||||
```bash
|
||||
# Confirm the referenced code surface still exists
|
||||
rg -l "response-compliance|openresponses" packages/ src/ # adjust per skill
|
||||
git log --since="3 months ago" -- .agents/skills/ < skill > /SKILL.md # is it being maintained?
|
||||
```
|
||||
|
||||
If the underlying surface is gone and the skill hasn't been edited in 3+ months → flag for archival.
|
||||
|
||||
### 6 — Cross-reference integrity
|
||||
|
||||
Any skill body mentioning another skill by name:
|
||||
|
||||
```bash
|
||||
# Scan all skill bodies for skill-name references
|
||||
rg -o '`[a-z][a-z0-9-]+`' .agents/skills/*/SKILL.md | grep -v ':\s*$' | sort -u
|
||||
```
|
||||
|
||||
For each name extracted, confirm `.agents/skills/<name>/SKILL.md` exists. Broken references happen after renames — fix them in the same audit pass.
|
||||
|
||||
### 7 — Output report
|
||||
|
||||
Produce a markdown summary back to the user with the same structure as the original audit (this skill was created during one):
|
||||
|
||||
```markdown
|
||||
## 📊 Inventory
|
||||
|
||||
{count, domain breakdown}
|
||||
|
||||
## 🎯 Recommendations
|
||||
|
||||
### 🔴 High confidence
|
||||
|
||||
- {action} — {reason}
|
||||
|
||||
### 🟡 Medium confidence
|
||||
|
||||
- {action} — {reason needs verification}
|
||||
|
||||
### 🟢 Low confidence / no-op
|
||||
|
||||
- {item considered but skipping because ...}
|
||||
|
||||
## 📋 Suggested order
|
||||
|
||||
{table of actions with risk + LOC estimate}
|
||||
```
|
||||
|
||||
End by asking the user which actions to apply — do NOT auto-apply unless the user passed `--apply` and even then confirm destructive deletes individually.
|
||||
|
||||
## Output rules
|
||||
|
||||
- Be specific. "Skill X overlaps with Y" is useless without naming the overlapping triggers.
|
||||
- Cite line numbers when flagging description / body issues.
|
||||
- Don't recommend merges unless the call sites would actually load the merged skill in the same context.
|
||||
- Don't recommend deletes for skills that haven't been touched recently — "unused" can mean "stable", not "dead".
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- ❌ Don't rename skill directories without checking for cross-references AND user memory entries that name the old slug.
|
||||
- ❌ Don't normalize a description by removing trigger keywords just to fit the template — the keywords are the routing signal.
|
||||
- ❌ Don't fold a heavy 200+ line skill into another just because they share a domain — large skills get loaded selectively and merging makes everything load.
|
||||
- ❌ Don't propose `.agents/skills/INDEX.md` or `<domain>-<skill>` prefix renames unless the user explicitly asks — costs > benefits for cosmetic reorgs.
|
||||
|
||||
## Related history
|
||||
|
||||
- First audit: `chore/skills-audit` branch (2026-05-25) — deleted `source-command-dedupe`, renamed `data-fetching` → `data-fetching-architecture`, normalized 9 descriptions, created this skill.
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: 'source-command-dedupe'
|
||||
description: 'Find duplicate GitHub issues'
|
||||
---
|
||||
|
||||
# source-command-dedupe
|
||||
|
||||
Use this skill when the user asks to run the migrated source command `dedupe`.
|
||||
|
||||
## Command Template
|
||||
|
||||
Find up to 3 likely duplicate issues for a given GitHub issue.
|
||||
|
||||
To do this, follow these steps precisely:
|
||||
|
||||
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
|
||||
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
|
||||
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
|
||||
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
|
||||
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
|
||||
|
||||
Notes (be sure to tell this to your agents, too):
|
||||
|
||||
- Use `gh` to interact with Github, rather than web fetch
|
||||
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
|
||||
- Make a todo list first
|
||||
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
|
||||
|
||||
---
|
||||
|
||||
Found 3 possible duplicate issues:
|
||||
|
||||
1. <link to issue>
|
||||
2. <link to issue>
|
||||
3. <link to issue>
|
||||
|
||||
This issue will be automatically closed as a duplicate in 3 days.
|
||||
|
||||
- If your issue is a duplicate, please close it and 👍 the existing issue instead
|
||||
- To prevent auto-closure, add a comment or 👎 this comment
|
||||
|
||||
> 🤖 Generated with Codex
|
||||
|
||||
---
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: spa-routes
|
||||
description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/.
|
||||
description: "SPA roots-vs-features split for LobeHub — thin route segments under `src/routes/` delegate to domain components under `src/features/`. Use when editing `src/routes/` segments, `src/spa/router/desktopRouter.config.tsx` or `desktopRouter.config.desktop.tsx` (MUST update both together — `desktopRouter.sync.test.tsx` enforces this), `mobileRouter.config.tsx`, `popupRouter.config.tsx`, or moving UI/logic between `routes/` and `features/`. Triggers on `desktopRouter.config`, `mobileRouter.config`, `popupRouter.config`, `src/routes/**`, `src/features/**`, 'add a route', 'new page', 'route segment', '路由'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: store-data-structures
|
||||
description: Zustand store data structure patterns for LobeHub. Covers List vs Detail data structures, Map + Reducer patterns, type definitions, and when to use each pattern. Use when designing store state, choosing data structures, or implementing list/detail pages.
|
||||
description: "Zustand store data-shape patterns for LobeHub — List vs Detail split, Map + Reducer, type definitions sourced from `@lobechat/types` (not `@lobechat/database`). Use when designing store state, choosing between Array (list) and `Record<string, Detail>` (detail map), or implementing a list/detail page pair. Triggers on `messagesMap`, `topicsMap`, `Record<string, Detail>`, 'list vs detail', 'store data shape', 'normalize state', 'state structure'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -310,5 +310,5 @@ export interface BenchmarkListItem {
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `data-fetching` — how to fetch and update this data
|
||||
- `data-fetching-architecture` — how to fetch and update this data
|
||||
- `zustand` — general Zustand patterns
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: upstash-workflow
|
||||
description: 'Upstash Workflow implementation guide. Use when creating async workflows with QStash, implementing fan-out patterns, or building 3-layer workflow architecture (process → paginate → execute).'
|
||||
description: "Upstash Workflow + QStash implementation guide for LobeHub — 3-layer architecture (process → paginate → execute), fan-out patterns. Use when creating an async workflow, implementing fan-out (paginate → execute), or wiring `serve()` + `context.run` / `context.call` steps. Triggers on `serve()`, `context.run`, `context.call`, `context.sleep`, `qstash`, 'async workflow', 'fan-out workflow', 'QStash workflow'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ prd
|
||||
# Recordings
|
||||
.records/
|
||||
|
||||
# Agent-gateway probe captures (local debugging dumps)
|
||||
.agent-gateway/
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
temp/
|
||||
|
||||
@@ -7,6 +7,7 @@ Guidelines for using AI coding agents in this LobeHub repository.
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
|
||||
- **Component priority**: `@lobehub/ui/base-ui` (headless primitives) **first**, then `@lobehub/ui` root, then antd as last resort. When the component exists in base-ui, use it — never reach for the root or antd counterpart. Base-ui covers `Select`, `Modal` / `createModal` / `confirmModal`, `DropdownMenu`, `ContextMenu`, `Popover`, `ScrollArea`, `Switch`, `Toast`, `FloatingSheet`. Prefer `@lobehub/ui/base-ui` for new code and migrate root-package call sites opportunistically.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.20" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.22" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.22",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -44,7 +44,7 @@
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -248,14 +248,14 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
|
||||
// Handle tool call requests
|
||||
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
|
||||
const { requestId, toolCall } = request;
|
||||
const { requestId, timeout, toolCall } = request;
|
||||
if (isDaemonChild) {
|
||||
appendLog(`[TOOL] ${toolCall.apiName} (${requestId})`);
|
||||
} else {
|
||||
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
|
||||
}
|
||||
|
||||
const result = await executeToolCall(toolCall.apiName, toolCall.arguments);
|
||||
const result = await executeToolCall(toolCall.apiName, toolCall.arguments, timeout);
|
||||
|
||||
if (isDaemonChild) {
|
||||
appendLog(`[RESULT] ${result.success ? 'OK' : 'FAIL'} (${requestId})`);
|
||||
|
||||
@@ -8,11 +8,20 @@ import { registerHeteroCommand } from './hetero';
|
||||
const { mockSpawnAgent } = vi.hoisted(() => ({
|
||||
mockSpawnAgent: vi.fn(),
|
||||
}));
|
||||
const { mockGetTrpcClient, mockHeteroFinishMutate, mockHeteroIngestMutate } = vi.hoisted(() => ({
|
||||
mockGetTrpcClient: vi.fn(),
|
||||
mockHeteroFinishMutate: vi.fn(),
|
||||
mockHeteroIngestMutate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/heterogeneous-agents/spawn', () => ({
|
||||
spawnAgent: mockSpawnAgent,
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
getTrpcClient: mockGetTrpcClient,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
@@ -77,6 +86,17 @@ describe('hetero exec command', () => {
|
||||
}) as any);
|
||||
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
mockSpawnAgent.mockReset();
|
||||
mockHeteroIngestMutate.mockReset();
|
||||
mockHeteroFinishMutate.mockReset();
|
||||
mockGetTrpcClient.mockReset();
|
||||
mockHeteroIngestMutate.mockResolvedValue({ ack: true });
|
||||
mockHeteroFinishMutate.mockResolvedValue({ ack: true });
|
||||
mockGetTrpcClient.mockResolvedValue({
|
||||
aiAgent: {
|
||||
heteroFinish: { mutate: mockHeteroFinishMutate },
|
||||
heteroIngest: { mutate: mockHeteroIngestMutate },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -536,4 +556,93 @@ describe('hetero exec command', () => {
|
||||
expect(errorLine).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends full text snapshots before tools and waits for finish until all server ingests ack', async () => {
|
||||
const callOrder: string[] = [];
|
||||
mockHeteroIngestMutate.mockImplementation(async ({ events }: any) => {
|
||||
const first = events[0];
|
||||
callOrder.push(`ingest:${first.type}:${first.data?.chunkType ?? 'terminal'}`);
|
||||
return { ack: true };
|
||||
});
|
||||
mockHeteroFinishMutate.mockImplementation(async () => {
|
||||
callOrder.push('finish');
|
||||
return { ack: true };
|
||||
});
|
||||
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
createFakeHandle({
|
||||
events: [
|
||||
{
|
||||
data: { chunkType: 'text', content: 'hello ' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: { chunkType: 'text', content: 'world' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{
|
||||
apiName: 'Bash',
|
||||
arguments: '{"cmd":"ls"}',
|
||||
id: 'tc-1',
|
||||
identifier: 'bash',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
},
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 3,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
{
|
||||
data: { reason: 'success' },
|
||||
operationId: 'op-server',
|
||||
stepIndex: 1,
|
||||
timestamp: 4,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--topic',
|
||||
'topic-1',
|
||||
'--operation-id',
|
||||
'op-server',
|
||||
'--render',
|
||||
'none',
|
||||
]);
|
||||
|
||||
expect(mockHeteroIngestMutate).toHaveBeenCalledTimes(3);
|
||||
expect(mockHeteroIngestMutate.mock.calls[0][0].events[0].data).toMatchObject({
|
||||
chunkType: 'text',
|
||||
content: 'hello world',
|
||||
snapshotMode: 'replace',
|
||||
snapshotSeq: 1,
|
||||
});
|
||||
expect(callOrder).toEqual([
|
||||
'ingest:stream_chunk:text',
|
||||
'ingest:stream_chunk:tools_calling',
|
||||
'ingest:agent_runtime_end:terminal',
|
||||
'finish',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+103
-16
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -6,12 +7,12 @@ import type {
|
||||
AgentContentBlock,
|
||||
AgentImageSource,
|
||||
AgentPromptInput,
|
||||
AgentStreamEvent,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { spawnAgent } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { BatchIngester, NoopIngestSink } from '../utils/BatchIngester';
|
||||
import { log } from '../utils/logger';
|
||||
import { TrpcIngestSink } from '../utils/TrpcIngestSink';
|
||||
|
||||
@@ -200,6 +201,85 @@ const resolvePrompt = async (options: ExecOptions): Promise<ResolvedPrompt> => {
|
||||
return buildPromptFromText(raw, images);
|
||||
};
|
||||
|
||||
class SerialServerIngester {
|
||||
private accumulatedText = '';
|
||||
private fatalError: Error | null = null;
|
||||
private inflight: Promise<void> = Promise.resolve();
|
||||
private nextSnapshotSeq = 0;
|
||||
private pendingTextEvent: AgentStreamEvent | undefined;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly sink: TrpcIngestSink,
|
||||
private readonly snapshotFlushMs = 200,
|
||||
) {}
|
||||
|
||||
push(event: AgentStreamEvent): void {
|
||||
if (this.fatalError) return;
|
||||
|
||||
if (
|
||||
event.type === 'stream_chunk' &&
|
||||
event.data?.chunkType === 'text' &&
|
||||
typeof event.data?.content === 'string'
|
||||
) {
|
||||
this.accumulatedText += event.data.content;
|
||||
this.pendingTextEvent = event;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.queuePendingTextSnapshot();
|
||||
}, this.snapshotFlushMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.queuePendingTextSnapshot();
|
||||
this.enqueue(async () => {
|
||||
await this.sink.ingest([event]);
|
||||
});
|
||||
}
|
||||
|
||||
async drain(): Promise<void> {
|
||||
this.queuePendingTextSnapshot();
|
||||
try {
|
||||
await this.inflight;
|
||||
} catch {
|
||||
// `fatalError` is re-thrown below.
|
||||
}
|
||||
if (this.fatalError) throw this.fatalError;
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>) {
|
||||
this.inflight = this.inflight.then(task).catch((err) => {
|
||||
this.fatalError = err instanceof Error ? err : new Error(String(err));
|
||||
throw this.fatalError;
|
||||
});
|
||||
}
|
||||
|
||||
private queuePendingTextSnapshot() {
|
||||
if (!this.pendingTextEvent || this.fatalError) return;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
const baseEvent = this.pendingTextEvent;
|
||||
this.pendingTextEvent = undefined;
|
||||
const snapshotEvent: AgentStreamEvent = {
|
||||
...baseEvent,
|
||||
data: {
|
||||
...baseEvent.data,
|
||||
content: this.accumulatedText,
|
||||
snapshotMode: 'replace',
|
||||
snapshotSeq: ++this.nextSnapshotSeq,
|
||||
},
|
||||
};
|
||||
|
||||
this.enqueue(async () => {
|
||||
await this.sink.ingest([snapshotEvent]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const exec = async (options: ExecOptions): Promise<void> => {
|
||||
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
|
||||
log.error(
|
||||
@@ -243,17 +323,22 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// server-ingest mode. The tRPC client reads LOBEHUB_JWT (operation-scoped
|
||||
// JWT injected by the server) for authentication.
|
||||
const agentType = options.type as 'claude-code' | 'codex';
|
||||
let sink: InstanceType<typeof TrpcIngestSink> | InstanceType<typeof NoopIngestSink>;
|
||||
let sink: TrpcIngestSink | undefined;
|
||||
let serverIngester: SerialServerIngester | undefined;
|
||||
if (serverIngest) {
|
||||
const client = await getTrpcClient();
|
||||
sink = new TrpcIngestSink(client, agentType, operationId, options.topic!);
|
||||
} else {
|
||||
sink = new NoopIngestSink();
|
||||
sink = new TrpcIngestSink(
|
||||
client,
|
||||
agentType,
|
||||
operationId,
|
||||
options.topic!,
|
||||
process.env.LOBEHUB_ASSISTANT_MESSAGE_ID,
|
||||
);
|
||||
serverIngester = new SerialServerIngester(sink);
|
||||
}
|
||||
const ingester = new BatchIngester(sink);
|
||||
|
||||
/**
|
||||
* Spawn one agent process and stream all its events into `ingester`.
|
||||
* Spawn one agent process and stream all its events into the server ingester.
|
||||
*
|
||||
* When `interceptResumeErrors` is true, any `error`-type event whose
|
||||
* message matches `RESUME_RETRY_PATTERNS` is withheld from the
|
||||
@@ -297,6 +382,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// Always pipe to process.stderr too so users see auth prompts / warnings.
|
||||
const STDERR_CAP = 8 * 1024;
|
||||
let stderrContent = '';
|
||||
const stderrEnded = once(handle.stderr, 'end').then(() => undefined);
|
||||
handle.stderr.on('data', (chunk: Buffer) => {
|
||||
if (stderrContent.length < STDERR_CAP) {
|
||||
stderrContent += chunk.toString();
|
||||
@@ -314,9 +400,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
interrupted = true;
|
||||
handle.kill('SIGINT');
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort; process is exiting anyway
|
||||
@@ -325,9 +411,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
};
|
||||
const onSigterm = async () => {
|
||||
handle.kill('SIGTERM');
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort
|
||||
@@ -356,16 +442,16 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
}
|
||||
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
ingester.push(event);
|
||||
serverIngester?.push(event);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
'Stream error from agent process:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
await sink.finish({
|
||||
error: { message: String(err), type: 'stream_error' },
|
||||
result: 'error',
|
||||
@@ -381,6 +467,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
}
|
||||
|
||||
const { code, signal } = await handle.exit;
|
||||
await stderrEnded;
|
||||
|
||||
// Fallback stderr detection: CC may exit non-zero without emitting a
|
||||
// result event (e.g. it writes to stderr and quits immediately).
|
||||
@@ -451,9 +538,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
|
||||
const { code, signal, sessionId } = result;
|
||||
|
||||
if (serverIngest) {
|
||||
if (serverIngester && sink) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await serverIngester.drain();
|
||||
} catch (err) {
|
||||
log.error(
|
||||
'Failed to flush events to server:',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { getHermesPort } from './heteroTask';
|
||||
|
||||
export interface CheckPlatformCapabilityParams {
|
||||
platform: 'hermes' | 'openclaw';
|
||||
}
|
||||
@@ -42,26 +40,19 @@ export async function checkPlatformCapability(
|
||||
}
|
||||
|
||||
if (platform === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const body = (await res.json()) as { version?: string };
|
||||
version = body.version;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
return { available: true, version };
|
||||
}
|
||||
return { available: false, reason: `Hermes gateway returned HTTP ${res.status}` };
|
||||
const output = execFileSync('hermes', ['--version'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
// output is typically "Hermes Agent vX.Y.Z (...)"
|
||||
const versionMatch = output.match(/v(\d+\.\d+\.\d+)/);
|
||||
const version = versionMatch ? versionMatch[1] : output.split(/\s+/).at(-1);
|
||||
return { available: true, version };
|
||||
} catch (err) {
|
||||
return {
|
||||
available: false,
|
||||
reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`,
|
||||
reason: err instanceof Error ? err.message : 'hermes not found or failed to run',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
@@ -80,13 +81,92 @@ function getOpenClawProfile(agentId?: string): AgentProfileResult {
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the active Hermes profile name from `hermes profile list` output.
|
||||
* The active profile is marked with ◆ in the first column.
|
||||
*/
|
||||
function getActiveHermesProfileName(): string | undefined {
|
||||
try {
|
||||
const output = execFileSync('hermes', ['profile', 'list'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const match = output.match(/◆(\S+)/);
|
||||
return match?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the filesystem path of a Hermes profile from `hermes profile show <name>`.
|
||||
*/
|
||||
function getHermesProfilePath(profileName: string): string | undefined {
|
||||
try {
|
||||
const output = execFileSync('hermes', ['profile', 'show', profileName], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const match = output.match(/^Path:\s+(.+)/m);
|
||||
const raw = match?.[1]?.trim();
|
||||
// Expand leading `~` — Node does not auto-expand home-dir shorthands.
|
||||
return raw?.replace(/^~(?=\/|$)/, os.homedir());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a one-line description from a Hermes SOUL.md file.
|
||||
* Strips HTML comments and Markdown headings, then returns the first
|
||||
* non-empty line of actual content.
|
||||
*/
|
||||
function readHermesSoulDescription(soulPath: string): string | undefined {
|
||||
try {
|
||||
const content = fs.readFileSync(soulPath, 'utf8');
|
||||
// Loop until stable to handle any malformed/nested comment sequences.
|
||||
let stripped = content;
|
||||
let previous: string;
|
||||
do {
|
||||
previous = stripped;
|
||||
stripped = stripped
|
||||
.replaceAll(/<!--[\s\S]*?-->/g, '') // strip complete HTML comments
|
||||
.replaceAll(/[<>]/g, '') // strip any remaining HTML delimiter chars
|
||||
.replaceAll(/^#+\s.*$/gm, ''); // strip Markdown headings
|
||||
} while (stripped !== previous);
|
||||
const line = stripped
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0);
|
||||
return line || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getHermesProfile(): AgentProfileResult {
|
||||
const profileName = getActiveHermesProfileName();
|
||||
if (!profileName) return {};
|
||||
|
||||
const profilePath = getHermesProfilePath(profileName);
|
||||
const description = profilePath
|
||||
? readHermesSoulDescription(path.join(profilePath, 'SOUL.md'))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
avatar: '⚡',
|
||||
description,
|
||||
title: profileName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the agent profile (title, avatar, description) from the platform
|
||||
* installed on this device. Dispatched by the server via `device.getAgentProfile`.
|
||||
*
|
||||
* - openclaw: `openclaw agents list --json` for name + emoji, workspace
|
||||
* IDENTITY.md for description fallback
|
||||
* - hermes: not yet implemented — returns empty profile
|
||||
* - hermes: active profile name + SOUL.md description
|
||||
*/
|
||||
export async function getAgentProfile(params: GetAgentProfileParams): Promise<AgentProfileResult> {
|
||||
const { platform, agentId } = params;
|
||||
@@ -96,8 +176,7 @@ export async function getAgentProfile(params: GetAgentProfileParams): Promise<Ag
|
||||
}
|
||||
|
||||
if (platform === 'hermes') {
|
||||
// Profile fetch not yet implemented for Hermes — return empty
|
||||
return {};
|
||||
return getHermesProfile();
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
|
||||
|
||||
@@ -6,7 +9,36 @@ import { getTrpcClient } from '../api/client';
|
||||
import { getTask, listTasks, removeTask, saveTask } from '../daemon/taskRegistry';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const DEFAULT_HERMES_PORT = 3456;
|
||||
// ─── Hermes session persistence ───
|
||||
// Maps topicId → hermes session_id so multi-turn conversations can resume
|
||||
// the same session across separate `runHeteroTask` invocations.
|
||||
|
||||
const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
|
||||
const HERMES_SESSIONS_FILE = path.join(os.homedir(), LOBEHUB_DIR_NAME, 'hermes-sessions.json');
|
||||
|
||||
function getHermesSessionId(topicId: string): string | undefined {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
return data[topicId];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveHermesSessionId(topicId: string, sessionId: string): void {
|
||||
let data: Record<string, string> = {};
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<string, string>;
|
||||
} catch {
|
||||
// File doesn't exist yet — start fresh.
|
||||
}
|
||||
data[topicId] = sessionId;
|
||||
fs.mkdirSync(path.dirname(HERMES_SESSIONS_FILE), { recursive: true });
|
||||
fs.writeFileSync(HERMES_SESSIONS_FILE, JSON.stringify(data), 'utf8');
|
||||
}
|
||||
|
||||
/** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
|
||||
function resolveLhPath(): string {
|
||||
@@ -32,40 +64,6 @@ export interface CancelHeteroTaskParams {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export function getHermesPort(): number {
|
||||
const env = process.env.HERMES_GATEWAY_PORT;
|
||||
if (env) {
|
||||
const parsed = Number.parseInt(env, 10);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
return DEFAULT_HERMES_PORT;
|
||||
}
|
||||
|
||||
async function isHermesGatewayRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startHermesGateway(port: number): Promise<void> {
|
||||
const child = spawn('hermes', ['gateway', 'start'], {
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
if (await isHermesGatewayRunning(port)) return;
|
||||
}
|
||||
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
|
||||
}
|
||||
|
||||
async function sendAutoNotify(
|
||||
topicId: string,
|
||||
taskId: string,
|
||||
@@ -231,37 +229,84 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
|
||||
}
|
||||
|
||||
if (agentType === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
|
||||
if (!(await isHermesGatewayRunning(port))) {
|
||||
log.info(`Hermes gateway not running on port ${port}, starting...`);
|
||||
await startHermesGateway(port);
|
||||
// Kill any existing hermes process for this topicId before spawning a new one.
|
||||
for (const existing of listTasks()) {
|
||||
if (existing.topicId === topicId && existing.agentType === 'hermes') {
|
||||
try {
|
||||
process.kill(existing.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already exited — nothing to do.
|
||||
}
|
||||
removeTask(existing.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`http://localhost:${port}/message`, {
|
||||
body: JSON.stringify({ content: prompt, operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
// Resume the previous session for this topic if one exists.
|
||||
const existingSessionId = getHermesSessionId(topicId);
|
||||
const hermesArgs: string[] = ['chat', '--query', prompt, '--quiet', '--accept-hooks'];
|
||||
if (existingSessionId) {
|
||||
hermesArgs.push('--resume', existingSessionId);
|
||||
}
|
||||
|
||||
// Hermes prints "session_id: <id>\n<response>" to stdout in --quiet mode.
|
||||
// We capture stdout, parse both fields on exit, and relay the response via notify.
|
||||
const child = spawn('hermes', hermesArgs, {
|
||||
cwd: workDir,
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
|
||||
child.unref();
|
||||
|
||||
// pid is 0 for Hermes — the gateway is long-lived and cancellation uses
|
||||
// the HTTP /stop API rather than direct signal delivery.
|
||||
saveTask({
|
||||
agentId,
|
||||
agentType,
|
||||
operationId,
|
||||
pid: 0,
|
||||
pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
taskId,
|
||||
topicId,
|
||||
});
|
||||
log.info(`Hermes task dispatched: taskId=${taskId} operationId=${operationId}`);
|
||||
log.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
|
||||
|
||||
return JSON.stringify({ operationId, taskId });
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
removeTask(taskId);
|
||||
|
||||
if (code !== 0 || signal !== null) {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse "session_id: <id>" from the first line, response from the rest.
|
||||
const sessionIdMatch = stdout.match(/^session_id:\s*(\S+)/m);
|
||||
const sessionId = sessionIdMatch?.[1];
|
||||
const response = stdout.replace(/^session_id:[^\n]*\n?/, '').trim();
|
||||
|
||||
if (sessionId) saveHermesSessionId(topicId, sessionId);
|
||||
|
||||
if (response) {
|
||||
void sendAutoNotify(topicId, taskId, response, agentId).finally(() =>
|
||||
sendDoneSignal(topicId, agentId),
|
||||
);
|
||||
} else {
|
||||
void sendDoneSignal(topicId, agentId);
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({ pid, taskId });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported agentType: ${agentType as string}`);
|
||||
@@ -275,25 +320,7 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
|
||||
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
|
||||
}
|
||||
|
||||
if (entry.agentType === 'hermes') {
|
||||
const port = getHermesPort();
|
||||
try {
|
||||
await fetch(`http://localhost:${port}/stop`, {
|
||||
body: JSON.stringify({ operationId: entry.operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`Failed to send /stop to Hermes gateway: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
removeTask(taskId);
|
||||
await sendAutoNotify(entry.topicId, taskId, 'Task cancelled', entry.agentId);
|
||||
return JSON.stringify({ taskId });
|
||||
}
|
||||
|
||||
// OpenClaw: kill by PID and let the child's close handler send the notify.
|
||||
// Both openclaw and hermes: kill by PID and let the child's close handler send the notify.
|
||||
try {
|
||||
process.kill(entry.pid, signal);
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,6 +41,7 @@ const methodMap: Record<string, (args: any) => Promise<unknown>> = {
|
||||
export async function executeToolCall(
|
||||
apiName: string,
|
||||
argsStr: string,
|
||||
timeout?: number,
|
||||
): Promise<{
|
||||
content: string;
|
||||
error?: string;
|
||||
@@ -53,8 +54,12 @@ export async function executeToolCall(
|
||||
|
||||
try {
|
||||
const args = JSON.parse(argsStr);
|
||||
const finalArgs =
|
||||
typeof timeout === 'number' && Number.isFinite(timeout) && !('timeout' in args)
|
||||
? { ...args, timeout }
|
||||
: args;
|
||||
|
||||
const result = await handler(args);
|
||||
const result = await handler(finalArgs);
|
||||
const content = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
|
||||
return { content, success: true };
|
||||
|
||||
@@ -16,6 +16,7 @@ export class TrpcIngestSink implements IngestSink {
|
||||
private readonly agentType: 'claude-code' | 'codex',
|
||||
private readonly operationId: string,
|
||||
private readonly topicId: string,
|
||||
private readonly assistantMessageId?: string,
|
||||
) {}
|
||||
|
||||
async finish(params: Parameters<IngestSink['finish']>[0]): Promise<void> {
|
||||
@@ -30,6 +31,7 @@ export class TrpcIngestSink implements IngestSink {
|
||||
async ingest(events: AgentStreamEvent[]): Promise<void> {
|
||||
await this.client.aiAgent.heteroIngest.mutate({
|
||||
agentType: this.agentType,
|
||||
assistantMessageId: this.assistantMessageId,
|
||||
events: events as any,
|
||||
operationId: this.operationId,
|
||||
topicId: this.topicId,
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"stylelint": "^15.11.0",
|
||||
"superjson": "^2.2.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "8.0.14",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync, execSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
|
||||
@@ -13,8 +14,6 @@ import LocalFileCtr from './LocalFileCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
|
||||
const DEFAULT_HERMES_PORT = 3456;
|
||||
|
||||
/**
|
||||
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
|
||||
* Tells the agent binary how to push results back to the LobeHub chat UI via `lh notify`.
|
||||
@@ -66,6 +65,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
/** In-memory registry for running platform agent tasks (openclaw / hermes). */
|
||||
private readonly platformTasks = new Map<string, PlatformTaskEntry>();
|
||||
|
||||
/** Maps topicId → hermes session_id for multi-turn conversation continuity. */
|
||||
private readonly hermesSessionMap = new Map<string, string>();
|
||||
|
||||
// ─── Service Accessor ───
|
||||
|
||||
private get service() {
|
||||
@@ -172,36 +174,25 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
request: AgentRunRequestMessage,
|
||||
): Promise<{ reason?: string; status: 'accepted' | 'rejected' }> {
|
||||
try {
|
||||
const ctr = this.heterogeneousAgentCtr;
|
||||
const serverUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
|
||||
if (!serverUrl) {
|
||||
return { reason: 'Remote server URL not configured', status: 'rejected' };
|
||||
}
|
||||
|
||||
// Map agentType to binary name.
|
||||
// claude-code → `claude` CLI; all other platforms use their type name as the binary.
|
||||
const command = request.agentType === 'claude-code' ? 'claude' : request.agentType;
|
||||
|
||||
// Create a session for the hetero agent.
|
||||
const { sessionId } = await ctr.startSession({
|
||||
// Fire-and-forget: lh hetero exec handles spawn -> adapt ->
|
||||
// BatchIngester -> heteroIngest/heteroFinish -> server -> Gateway -> clients.
|
||||
// Same command as spawnHeteroSandbox() on the server side.
|
||||
this.heterogeneousAgentCtr.spawnLhHeteroExec({
|
||||
agentType: request.agentType,
|
||||
args: [],
|
||||
command,
|
||||
cwd: request.cwd,
|
||||
// Inject LOBEHUB_JWT so the CLI authenticates against heteroIngest.
|
||||
env: { LOBEHUB_JWT: request.jwt },
|
||||
jwt: request.jwt,
|
||||
operationId: request.operationId,
|
||||
prompt: request.prompt,
|
||||
resumeSessionId: request.resumeSessionId,
|
||||
serverUrl,
|
||||
topicId: request.topicId,
|
||||
});
|
||||
|
||||
// Fire-and-forget: sendPrompt runs the CLI until completion.
|
||||
ctr
|
||||
.sendPrompt({
|
||||
operationId: request.operationId,
|
||||
prompt: request.prompt,
|
||||
sessionId,
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// Errors are surfaced via heteroFinish on the server side.
|
||||
// Log locally for desktop debugging only.
|
||||
console.error('[GatewayConnectionCtr] agent run failed:', err.message);
|
||||
});
|
||||
|
||||
return { status: 'accepted' };
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
@@ -312,10 +303,71 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.getOpenClawProfile(agentId);
|
||||
}
|
||||
|
||||
// hermes and unknown platforms: not yet implemented
|
||||
if (platform === 'hermes') {
|
||||
return this.getHermesProfile();
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private getHermesProfile(): { avatar?: string; description?: string; title?: string } {
|
||||
// Find the active profile (marked with ◆ in `hermes profile list`).
|
||||
let profileName: string | undefined;
|
||||
try {
|
||||
const listOutput = execFileSync('hermes', ['profile', 'list'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
profileName = listOutput.match(/◆(\S+)/)?.[1];
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!profileName) return {};
|
||||
|
||||
// Get the profile's filesystem path.
|
||||
let profilePath: string | undefined;
|
||||
try {
|
||||
const showOutput = execFileSync('hermes', ['profile', 'show', profileName], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const raw = showOutput.match(/^Path:\s+(.+)/m)?.[1]?.trim();
|
||||
profilePath = raw?.replace(/^~(?=\/|$)/, os.homedir());
|
||||
} catch {
|
||||
// Profile path unavailable — still return name + avatar.
|
||||
}
|
||||
|
||||
const description = profilePath
|
||||
? this.readHermesSoulDescription(path.join(profilePath, 'SOUL.md'))
|
||||
: undefined;
|
||||
|
||||
return { avatar: '⚡', description, title: profileName };
|
||||
}
|
||||
|
||||
private readHermesSoulDescription(soulPath: string): string | undefined {
|
||||
try {
|
||||
const content = fs.readFileSync(soulPath, 'utf8');
|
||||
// Loop until stable to handle any malformed/nested comment sequences.
|
||||
let stripped = content;
|
||||
let previous: string;
|
||||
do {
|
||||
previous = stripped;
|
||||
stripped = stripped
|
||||
.replaceAll(/<!--[\s\S]*?-->/g, '') // strip complete HTML comments
|
||||
.replaceAll(/[<>]/g, '') // strip any remaining HTML delimiter chars
|
||||
.replaceAll(/^#+\s.*$/gm, ''); // strip Markdown headings
|
||||
} while (stripped !== previous);
|
||||
return (
|
||||
stripped
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0) || undefined
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getOpenClawProfile(agentId?: string): {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
@@ -391,6 +443,18 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
|
||||
const workDir = cwd || process.cwd();
|
||||
|
||||
const [serverUrl, accessToken] = await Promise.all([
|
||||
this.remoteServerConfigCtr.getRemoteServerUrl(),
|
||||
this.remoteServerConfigCtr.getAccessToken(),
|
||||
]);
|
||||
|
||||
// Inject auth into child env so `lh notify` can authenticate without CLI config.
|
||||
const childEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...(accessToken && { LOBEHUB_JWT: accessToken }),
|
||||
...(serverUrl && { LOBEHUB_SERVER: serverUrl }),
|
||||
};
|
||||
|
||||
if (agentType === 'openclaw') {
|
||||
const lhPath = this.resolveLhPath();
|
||||
const openclawAgent = process.env['OPENCLAW_AGENT_ID'] ?? 'main';
|
||||
@@ -426,7 +490,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
enrichedPrompt,
|
||||
'--local',
|
||||
],
|
||||
{ cwd: workDir, detached: true, env: { ...process.env }, stdio: 'ignore' },
|
||||
{ cwd: workDir, detached: true, env: childEnv, stdio: 'ignore' },
|
||||
);
|
||||
|
||||
const pid = child.pid;
|
||||
@@ -453,20 +517,74 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
if (agentType === 'hermes') {
|
||||
const port = this.getHermesPort();
|
||||
if (!(await this.isHermesRunning(port))) {
|
||||
await this.startHermesGateway(port);
|
||||
// Kill any existing hermes process for this topicId before spawning a new one.
|
||||
for (const [existingTaskId, entry] of this.platformTasks) {
|
||||
if (entry.topicId === topicId && entry.agentType === 'hermes') {
|
||||
try {
|
||||
process.kill(entry.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already exited — nothing to do.
|
||||
}
|
||||
this.platformTasks.delete(existingTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`http://localhost:${port}/message`, {
|
||||
body: JSON.stringify({ content: prompt, operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
|
||||
// Resume the previous session for this topic if one exists.
|
||||
const existingSessionId = this.hermesSessionMap.get(topicId);
|
||||
const hermesArgs: string[] = ['chat', '--query', prompt, '--quiet', '--accept-hooks'];
|
||||
if (existingSessionId) {
|
||||
hermesArgs.push('--resume', existingSessionId);
|
||||
}
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid: 0, topicId });
|
||||
return JSON.stringify({ operationId, taskId });
|
||||
// Hermes prints "session_id: <id>\n<response>" to stdout in --quiet mode.
|
||||
const child = spawn('hermes', hermesArgs, {
|
||||
cwd: workDir,
|
||||
detached: true,
|
||||
env: childEnv,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
|
||||
child.unref();
|
||||
|
||||
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
|
||||
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
this.platformTasks.delete(taskId);
|
||||
|
||||
if (code !== 0 || signal !== null) {
|
||||
const text = signal
|
||||
? `Task cancelled (signal: ${signal})`
|
||||
: `Task failed (exit code: ${code})`;
|
||||
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
|
||||
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse "session_id: <id>" from the first line, response from the rest.
|
||||
const sessionIdMatch = stdout.match(/^session_id:\s*(\S+)/m);
|
||||
const sessionId = sessionIdMatch?.[1];
|
||||
const response = stdout.replace(/^session_id:[^\n]*\n?/, '').trim();
|
||||
|
||||
if (sessionId) this.hermesSessionMap.set(topicId, sessionId);
|
||||
|
||||
if (response) {
|
||||
void this.sendNotify({ agentId, content: response, role: 'assistant', topicId }).finally(
|
||||
() => this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
|
||||
);
|
||||
} else {
|
||||
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({ pid, taskId });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported agentType: ${agentType}`);
|
||||
@@ -480,28 +598,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
|
||||
}
|
||||
|
||||
if (entry.agentType === 'hermes') {
|
||||
const port = this.getHermesPort();
|
||||
try {
|
||||
await fetch(`http://localhost:${port}/stop`, {
|
||||
body: JSON.stringify({ operationId: entry.operationId }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
// Hermes gateway may already have stopped; ignore
|
||||
}
|
||||
this.platformTasks.delete(taskId);
|
||||
await this.sendNotify({
|
||||
agentId: entry.agentId,
|
||||
content: 'Task cancelled',
|
||||
role: 'assistant',
|
||||
topicId: entry.topicId,
|
||||
});
|
||||
return JSON.stringify({ taskId });
|
||||
}
|
||||
|
||||
// openclaw: kill by PID; the close handler sends the done signal.
|
||||
// Both openclaw and hermes: kill by PID; the close handler sends the done signal.
|
||||
try {
|
||||
process.kill(entry.pid, signal);
|
||||
} catch {
|
||||
@@ -536,11 +633,11 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
]);
|
||||
if (!serverUrl || !token) return;
|
||||
|
||||
await fetch(`${serverUrl}/trpc/agentNotify.notify`, {
|
||||
await fetch(`${serverUrl}/trpc/lambda/agentNotify.notify`, {
|
||||
body: JSON.stringify({ json: params }),
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': token,
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -558,37 +655,4 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return 'lh';
|
||||
}
|
||||
}
|
||||
|
||||
private getHermesPort(): number {
|
||||
const env = process.env['HERMES_GATEWAY_PORT'];
|
||||
if (env) {
|
||||
const parsed = Number.parseInt(env, 10);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
return DEFAULT_HERMES_PORT;
|
||||
}
|
||||
|
||||
private async isHermesRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
return (await fetch(`http://localhost:${port}/health`)).ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async startHermesGateway(port: number): Promise<void> {
|
||||
const child = spawn('hermes', ['gateway', 'start'], {
|
||||
detached: true,
|
||||
env: { ...process.env },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
if (await this.isHermesRunning(port)) return;
|
||||
}
|
||||
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1251,4 +1251,69 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
process.on('SIGTERM', onSignal);
|
||||
process.on('SIGINT', onSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn `lh hetero exec` for gateway-driven agent runs.
|
||||
* The `lh` CLI handles everything downstream — no local
|
||||
* AgentStreamPipeline or IPC broadcast needed. Mirrors
|
||||
* `spawnHeteroSandbox()` on the server side.
|
||||
*/
|
||||
spawnLhHeteroExec(params: {
|
||||
agentType: string;
|
||||
cwd?: string;
|
||||
jwt: string;
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
serverUrl: string;
|
||||
topicId: string;
|
||||
}): void {
|
||||
const { agentType, cwd, jwt, operationId, prompt, resumeSessionId, serverUrl, topicId } =
|
||||
params;
|
||||
const workDir = cwd ?? process.cwd();
|
||||
|
||||
const args = [
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
agentType,
|
||||
'--operation-id',
|
||||
operationId,
|
||||
'--topic',
|
||||
topicId,
|
||||
'--render',
|
||||
'none',
|
||||
'--input-json',
|
||||
'-',
|
||||
'--cwd',
|
||||
workDir,
|
||||
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
||||
];
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...buildProxyEnv(this.app.storeManager.get('networkProxy')),
|
||||
LOBEHUB_JWT: jwt,
|
||||
LOBEHUB_SERVER: serverUrl,
|
||||
};
|
||||
|
||||
logger.info('spawnLhHeteroExec: type=%s op=%s topic=%s', agentType, operationId, topicId);
|
||||
|
||||
const child = spawn('lh', args, {
|
||||
cwd: workDir,
|
||||
env,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(prompt));
|
||||
child.stdin.end();
|
||||
|
||||
child.on('error', (err) => {
|
||||
logger.error('spawnLhHeteroExec: spawn failed — %s', err.message);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
logger.info('spawnLhHeteroExec: exited — op=%s code=%s signal=%s', operationId, code, signal);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,14 +248,15 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}> {
|
||||
logger.debug('Attempting to open file:', { filePath });
|
||||
const resolvedPath = expandTilde(filePath) ?? filePath;
|
||||
logger.debug('Attempting to open file:', { filePath: resolvedPath });
|
||||
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
logger.debug('File opened successfully:', { filePath });
|
||||
await shell.openPath(resolvedPath);
|
||||
logger.debug('File opened successfully:', { filePath: resolvedPath });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to open file ${filePath}:`, error);
|
||||
logger.error(`Failed to open file ${resolvedPath}:`, error);
|
||||
return { error: (error as Error).message, success: false };
|
||||
}
|
||||
}
|
||||
@@ -265,8 +266,13 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}> {
|
||||
const folderPath = isDirectory ? targetPath : path.dirname(targetPath);
|
||||
logger.debug('Attempting to open folder:', { folderPath, isDirectory, targetPath });
|
||||
const resolvedTarget = expandTilde(targetPath) ?? targetPath;
|
||||
const folderPath = isDirectory ? resolvedTarget : path.dirname(resolvedTarget);
|
||||
logger.debug('Attempting to open folder:', {
|
||||
folderPath,
|
||||
isDirectory,
|
||||
targetPath: resolvedTarget,
|
||||
});
|
||||
|
||||
try {
|
||||
await shell.openPath(folderPath);
|
||||
|
||||
@@ -200,11 +200,13 @@ const mockShellCommandCtr = {
|
||||
|
||||
const mockHeterogeneousAgentCtr = {
|
||||
sendPrompt: vi.fn().mockResolvedValue(undefined),
|
||||
spawnLhHeteroExec: vi.fn(),
|
||||
startSession: vi.fn().mockResolvedValue({ sessionId: 'mock-session-id' }),
|
||||
} as unknown as HeterogeneousAgentCtr;
|
||||
|
||||
const mockRemoteServerConfigCtr = {
|
||||
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
getRemoteServerUrl: vi.fn().mockResolvedValue('https://server.example.com'),
|
||||
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
|
||||
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
|
||||
} as unknown as RemoteServerConfigCtr;
|
||||
@@ -631,26 +633,23 @@ describe('GatewayConnectionCtr', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockClear();
|
||||
vi.mocked(mockHeterogeneousAgentCtr.sendPrompt).mockClear();
|
||||
vi.mocked(mockHeterogeneousAgentCtr.spawnLhHeteroExec).mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['openclaw', 'openclaw'],
|
||||
['hermes', 'hermes'],
|
||||
['codex', 'codex'],
|
||||
['claude-code', 'claude'],
|
||||
] as const)('uses command "%s" for agentType "%s"', async (agentType, expectedCommand) => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest(agentType);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
it.each(['openclaw', 'hermes', 'codex', 'claude-code'] as const)(
|
||||
'forwards agentType "%s" to spawnLhHeteroExec',
|
||||
async (agentType) => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest(agentType);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockHeterogeneousAgentCtr.startSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentType, command: expectedCommand }),
|
||||
);
|
||||
});
|
||||
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentType }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('sends accepted ack and fires sendPrompt', async () => {
|
||||
it('sends accepted ack and spawns lh hetero exec', async () => {
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest('openclaw', 'op-xyz');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -659,15 +658,37 @@ describe('GatewayConnectionCtr', () => {
|
||||
operationId: 'op-xyz',
|
||||
status: 'accepted',
|
||||
});
|
||||
expect(mockHeterogeneousAgentCtr.sendPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ operationId: 'op-xyz', sessionId: 'mock-session-id' }),
|
||||
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentType: 'openclaw',
|
||||
jwt: 'mock-jwt',
|
||||
operationId: 'op-xyz',
|
||||
prompt: 'hello',
|
||||
serverUrl: 'https://server.example.com',
|
||||
topicId: 'topic-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends rejected ack when startSession throws', async () => {
|
||||
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockRejectedValueOnce(
|
||||
new Error('binary not found'),
|
||||
);
|
||||
it('sends rejected ack when remote server URL is not configured', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getRemoteServerUrl).mockResolvedValueOnce('');
|
||||
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest('openclaw', 'op-fail');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendAgentRunAck).toHaveBeenCalledWith({
|
||||
operationId: 'op-fail',
|
||||
reason: 'Remote server URL not configured',
|
||||
status: 'rejected',
|
||||
});
|
||||
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends rejected ack when spawnLhHeteroExec throws', async () => {
|
||||
vi.mocked(mockHeterogeneousAgentCtr.spawnLhHeteroExec).mockImplementationOnce(() => {
|
||||
throw new Error('binary not found');
|
||||
});
|
||||
|
||||
const client = await connectAndOpen();
|
||||
client.simulateAgentRunRequest('openclaw', 'op-fail');
|
||||
|
||||
@@ -143,6 +143,17 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Failed to open' });
|
||||
});
|
||||
|
||||
it('should expand a leading ~ to the user home directory', async () => {
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFile({ path: '~/git/work/file.txt' });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith(path.join(os.homedir(), 'git/work/file.txt'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOpenLocalFolder', () => {
|
||||
@@ -158,6 +169,20 @@ describe('LocalFileCtr', () => {
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
|
||||
});
|
||||
|
||||
it('should expand a leading ~ when opening a directory', async () => {
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
const result = await localFileCtr.handleOpenLocalFolder({
|
||||
path: '~/git/work',
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockShell.openPath).toHaveBeenCalledWith(path.join(os.homedir(), 'git/work'));
|
||||
});
|
||||
|
||||
it('should open parent directory when isDirectory is false', async () => {
|
||||
vi.mocked(mockShell.openPath).mockResolvedValue('');
|
||||
|
||||
|
||||
@@ -29,10 +29,6 @@ vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: vi.fn(() => 'test-uuid-123'),
|
||||
}));
|
||||
|
||||
vi.mock('../CliCtr', () => ({
|
||||
default: class CliCtr {},
|
||||
}));
|
||||
@@ -59,7 +55,9 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
mockChildProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
exitCode: null,
|
||||
};
|
||||
@@ -73,6 +71,10 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.once.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') setTimeout(() => callback(Buffer.from('output\n')), 5);
|
||||
return mockChildProcess.stdout;
|
||||
@@ -89,14 +91,21 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
});
|
||||
|
||||
it('should delegate handleGetCommandOutput to processManager', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.once.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'exit') setTimeout(() => callback(0), 10);
|
||||
return mockChildProcess;
|
||||
});
|
||||
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
|
||||
if (event === 'data') setTimeout(() => callback(Buffer.from('bg output\n')), 5);
|
||||
return mockChildProcess.stdout;
|
||||
});
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await ctr.handleRunCommand({
|
||||
const runResult = await ctr.handleRunCommand({
|
||||
command: 'test',
|
||||
run_in_background: true,
|
||||
});
|
||||
@@ -104,7 +113,7 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const result = await ctr.handleGetCommandOutput({
|
||||
shell_id: 'test-uuid-123',
|
||||
shell_id: runResult.shell_id!,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@@ -113,16 +122,17 @@ describe('ShellCommandCtr (thin wrapper)', () => {
|
||||
|
||||
it('should delegate handleKillCommand to processManager', async () => {
|
||||
mockChildProcess.on.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.once.mockImplementation(() => mockChildProcess);
|
||||
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
|
||||
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
|
||||
|
||||
await ctr.handleRunCommand({
|
||||
const runResult = await ctr.handleRunCommand({
|
||||
command: 'test',
|
||||
run_in_background: true,
|
||||
});
|
||||
|
||||
const result = await ctr.handleKillCommand({
|
||||
shell_id: 'test-uuid-123',
|
||||
shell_id: runResult.shell_id!,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { app, protocol } from 'electron';
|
||||
import { LOCAL_FILE_PROTOCOL_HOST, LOCAL_FILE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { getExportMimeType } from '../../utils/mime';
|
||||
import { resolveLocalFileMimeType } from '../../utils/mime';
|
||||
|
||||
const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
allowServiceWorkers: false,
|
||||
@@ -22,20 +22,6 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
const logger = createLogger('core:LocalFileProtocolManager');
|
||||
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const EXTRA_MIME_TYPES: Record<string, string> = {
|
||||
'.avif': 'image/avif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
};
|
||||
|
||||
const getMimeType = (filePath: string): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return getExportMimeType(filePath) ?? EXTRA_MIME_TYPES[ext] ?? 'application/octet-stream';
|
||||
};
|
||||
|
||||
const normalizeAbsolutePath = (filePath: string): string | null => {
|
||||
const normalized = path.normalize(filePath);
|
||||
return path.isAbsolute(normalized) ? normalized : null;
|
||||
@@ -130,7 +116,7 @@ export class LocalFileProtocolManager {
|
||||
|
||||
const buffer = await readFile(realResolvedPath);
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', getMimeType(realResolvedPath));
|
||||
headers.set('Content-Type', resolveLocalFileMimeType(realResolvedPath, buffer));
|
||||
headers.set('Content-Length', String(buffer.byteLength));
|
||||
// Local files are immutable from the renderer's perspective for a
|
||||
// single preview session; allow short-lived caching to avoid
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getExportMimeType, resolveLocalFileMimeType } from '../mime';
|
||||
|
||||
describe('getExportMimeType', () => {
|
||||
it('returns the whitelisted MIME for a known extension', () => {
|
||||
expect(getExportMimeType('/abs/path/App.tsx')).toBe('text/plain; charset=utf-8');
|
||||
expect(getExportMimeType('icon.png')).toBe('image/png');
|
||||
});
|
||||
|
||||
it('returns undefined for unmapped extensions', () => {
|
||||
expect(getExportMimeType('.releaserc.cjs')).toBeUndefined();
|
||||
expect(getExportMimeType('Makefile')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLocalFileMimeType', () => {
|
||||
it('uses the whitelist for known source extensions', () => {
|
||||
expect(resolveLocalFileMimeType('/repo/App.tsx', Buffer.from(''))).toBe(
|
||||
'text/plain; charset=utf-8',
|
||||
);
|
||||
expect(resolveLocalFileMimeType('/repo/data.json', Buffer.from('{}'))).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('serves preview-only image formats with their image MIME', () => {
|
||||
expect(resolveLocalFileMimeType('/repo/photo.heic', Buffer.from([0xff, 0xd8]))).toBe(
|
||||
'image/heic',
|
||||
);
|
||||
expect(resolveLocalFileMimeType('/repo/diagram.bmp', Buffer.from([0x42, 0x4d]))).toBe(
|
||||
'image/bmp',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats unmapped text-looking files (.cjs/.mjs) as text via the sniff fallback', () => {
|
||||
const cjsContent = Buffer.from(`module.exports = { plugins: ['@semantic-release/npm'] };\n`);
|
||||
expect(resolveLocalFileMimeType('/repo/.releaserc.cjs', cjsContent)).toBe(
|
||||
'text/plain; charset=utf-8',
|
||||
);
|
||||
|
||||
const mjsContent = Buffer.from(`export default { settings: ['emoji'] };\n`);
|
||||
expect(resolveLocalFileMimeType('/repo/.remarkrc.mjs', mjsContent)).toBe(
|
||||
'text/plain; charset=utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats no-extension config files as text via the sniff fallback', () => {
|
||||
const editorconfig = Buffer.from('root = true\n[*]\nindent_style = space\n');
|
||||
expect(resolveLocalFileMimeType('/repo/.editorconfig', editorconfig)).toBe(
|
||||
'text/plain; charset=utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to application/octet-stream when the sniff detects binary data', () => {
|
||||
// Embedded null byte → sniff classifies as binary.
|
||||
const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02, 0x03]);
|
||||
expect(resolveLocalFileMimeType('/repo/strange.blob', binary)).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('forces known-binary extensions to octet-stream even when the prefix sniffs as text', () => {
|
||||
// PDF header + xref + dictionary is pure ASCII for the first few KB —
|
||||
// sniff would classify this as text without the extension short-circuit.
|
||||
const pdfPrintablePrefix = Buffer.from(
|
||||
'%PDF-1.7\n%\xC4\xE5\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n',
|
||||
);
|
||||
expect(resolveLocalFileMimeType('/repo/manual.pdf', pdfPrintablePrefix)).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
|
||||
// No null bytes in the first 8KB; without the short-circuit this would
|
||||
// also be misclassified as text.
|
||||
const fakeZipPrefix = Buffer.from('PK\x03\x04' + 'A'.repeat(64));
|
||||
expect(resolveLocalFileMimeType('/repo/bundle.zip', fakeZipPrefix)).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
|
||||
const fakeMp3Prefix = Buffer.from('ID3' + 'A'.repeat(64));
|
||||
expect(resolveLocalFileMimeType('/repo/song.mp3', fakeMp3Prefix)).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,156 @@
|
||||
import path from 'node:path';
|
||||
|
||||
export const getExportMimeType = (filePath: string) => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
import { sniffBinaryBuffer } from '@lobechat/file-loaders';
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'.bash': 'text/plain; charset=utf-8',
|
||||
'.c': 'text/plain; charset=utf-8',
|
||||
'.cpp': 'text/plain; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.dockerfile': 'text/plain; charset=utf-8',
|
||||
'.fish': 'text/plain; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.go': 'text/plain; charset=utf-8',
|
||||
'.graphql': 'application/graphql; charset=utf-8',
|
||||
'.h': 'text/plain; charset=utf-8',
|
||||
'.hpp': 'text/plain; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.jsx': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.log': 'text/plain; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.mdx': 'text/markdown; charset=utf-8',
|
||||
'.mp4': 'video/mp4',
|
||||
'.png': 'image/png',
|
||||
'.py': 'text/plain; charset=utf-8',
|
||||
'.rs': 'text/plain; charset=utf-8',
|
||||
'.sh': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.toml': 'application/toml; charset=utf-8',
|
||||
'.ts': 'text/plain; charset=utf-8',
|
||||
'.tsx': 'text/plain; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.yaml': 'application/yaml; charset=utf-8',
|
||||
'.yml': 'application/yaml; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.zsh': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
return map[ext];
|
||||
const EXPORT_MIME_MAP: Record<string, string> = {
|
||||
'.bash': 'text/plain; charset=utf-8',
|
||||
'.c': 'text/plain; charset=utf-8',
|
||||
'.cpp': 'text/plain; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.dockerfile': 'text/plain; charset=utf-8',
|
||||
'.fish': 'text/plain; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.go': 'text/plain; charset=utf-8',
|
||||
'.graphql': 'application/graphql; charset=utf-8',
|
||||
'.h': 'text/plain; charset=utf-8',
|
||||
'.hpp': 'text/plain; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.jsx': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.log': 'text/plain; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.mdx': 'text/markdown; charset=utf-8',
|
||||
'.mp4': 'video/mp4',
|
||||
'.png': 'image/png',
|
||||
'.py': 'text/plain; charset=utf-8',
|
||||
'.rs': 'text/plain; charset=utf-8',
|
||||
'.sh': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.toml': 'application/toml; charset=utf-8',
|
||||
'.ts': 'text/plain; charset=utf-8',
|
||||
'.tsx': 'text/plain; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.yaml': 'application/yaml; charset=utf-8',
|
||||
'.yml': 'application/yaml; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.zsh': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
/**
|
||||
* Lookup table for renderer-bundled assets. The set of extensions is closed
|
||||
* (whatever `electron-vite` produces under the renderer dir), so a whitelist
|
||||
* is appropriate here.
|
||||
*/
|
||||
export const getExportMimeType = (filePath: string): string | undefined => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return EXPORT_MIME_MAP[ext];
|
||||
};
|
||||
|
||||
// Image formats we render natively in the preview pane but don't ship as
|
||||
// bundled assets — kept separate from EXPORT_MIME_MAP so RendererProtocolManager
|
||||
// stays minimal.
|
||||
const PREVIEW_IMAGE_MIME_MAP: Record<string, string> = {
|
||||
'.avif': 'image/avif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
};
|
||||
|
||||
// Extensions whose contents are binary even when the first 8KB sniffs as
|
||||
// printable ASCII. The classic case is PDF: header + xref + dictionary are
|
||||
// ASCII and the compressed streams live deeper in the file, so the sniff
|
||||
// misses the binary body and would otherwise serve the file as text/plain
|
||||
// — the renderer then hands it to a text highlighter and shows garbage.
|
||||
//
|
||||
// Only formats where the printable-prefix problem is realistic need to be
|
||||
// listed; truly binary blobs with early null bytes still get caught by the
|
||||
// sniff fallback.
|
||||
const KNOWN_BINARY_EXTENSIONS = new Set<string>([
|
||||
// Documents
|
||||
'.doc',
|
||||
'.pdf',
|
||||
'.ppt',
|
||||
'.xls',
|
||||
// Archives
|
||||
'.7z',
|
||||
'.bz2',
|
||||
'.gz',
|
||||
'.rar',
|
||||
'.tar',
|
||||
'.tgz',
|
||||
'.zip',
|
||||
// Executables / libraries
|
||||
'.class',
|
||||
'.dll',
|
||||
'.dylib',
|
||||
'.exe',
|
||||
'.jar',
|
||||
'.so',
|
||||
'.war',
|
||||
'.wasm',
|
||||
// Disk / database images
|
||||
'.bin',
|
||||
'.dat',
|
||||
'.db',
|
||||
'.dmg',
|
||||
'.iso',
|
||||
'.sqlite',
|
||||
'.sqlite3',
|
||||
// Audio / video not already mapped above
|
||||
'.aac',
|
||||
'.avi',
|
||||
'.flac',
|
||||
'.m4a',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.ogg',
|
||||
'.opus',
|
||||
'.wav',
|
||||
'.webm',
|
||||
// Design files
|
||||
'.ai',
|
||||
'.fig',
|
||||
'.psd',
|
||||
'.sketch',
|
||||
]);
|
||||
|
||||
const SNIFF_BYTES = 8192;
|
||||
const TEXT_FALLBACK_MIME = 'text/plain; charset=utf-8';
|
||||
const BINARY_FALLBACK_MIME = 'application/octet-stream';
|
||||
|
||||
/**
|
||||
* Resolve the MIME type to serve for a local file preview.
|
||||
*
|
||||
* 1. Known source/image extensions go through the whitelist for a stable,
|
||||
* accurate type (e.g. `.ts` → `text/plain`, not `video/mp2t`).
|
||||
* 2. Known-binary extensions (PDF, archives, executables, media, …)
|
||||
* short-circuit to `application/octet-stream`. Their first 8KB can be
|
||||
* printable ASCII (PDFs are the canonical offender) and we don't want
|
||||
* the sniff to mistakenly route them through the text highlighter.
|
||||
* 3. Anything else — no extension, `.cjs` / `.mjs`, `.lock`, `.editorconfig`,
|
||||
* an arbitrary user file — falls through to a binary sniff on the first
|
||||
* 8KB. Text → `text/plain`, otherwise `application/octet-stream`. This
|
||||
* removes the need to maintain an exhaustive text-extension allow-list.
|
||||
*/
|
||||
export const resolveLocalFileMimeType = (filePath: string, buffer: Buffer): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const fromWhitelist = EXPORT_MIME_MAP[ext] ?? PREVIEW_IMAGE_MIME_MAP[ext];
|
||||
if (fromWhitelist) return fromWhitelist;
|
||||
|
||||
if (KNOWN_BINARY_EXTENSIONS.has(ext)) return BINARY_FALLBACK_MIME;
|
||||
|
||||
const { isBinary } = sniffBinaryBuffer(buffer.subarray(0, SNIFF_BYTES));
|
||||
return isBinary ? BINARY_FALLBACK_MIME : TEXT_FALLBACK_MIME;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Select } from '@base-ui/react/select';
|
||||
import type {
|
||||
OverlayCaptureUploadStatus,
|
||||
ScreenCaptureAgentOption,
|
||||
@@ -5,9 +6,8 @@ import type {
|
||||
ScreenCaptureOverlayTheme,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { AlertCircleIcon, ChevronDownIcon, Loader2Icon, XIcon } from 'lucide-react';
|
||||
import { AlertCircleIcon, CheckIcon, ChevronDownIcon, Loader2Icon, XIcon } from 'lucide-react';
|
||||
import type {
|
||||
ChangeEvent as ReactChangeEvent,
|
||||
CSSProperties,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
@@ -248,7 +248,7 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
};
|
||||
}, [activeSelection, dock]);
|
||||
|
||||
const themeStyle = useMemo<CSSProperties | undefined>(() => {
|
||||
const themeVars = useMemo<Record<string, string> | undefined>(() => {
|
||||
if (!theme) return undefined;
|
||||
|
||||
return {
|
||||
@@ -268,9 +268,24 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
'--lobe-overlay-text-quaternary': theme.colorTextQuaternary,
|
||||
'--lobe-overlay-text-secondary': theme.colorTextSecondary,
|
||||
'--lobe-overlay-text-tertiary': theme.colorTextTertiary,
|
||||
} as CSSProperties;
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
const themeStyle = themeVars as CSSProperties | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeVars) return;
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(themeVars)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
return () => {
|
||||
for (const key of Object.keys(themeVars)) {
|
||||
root.style.removeProperty(key);
|
||||
}
|
||||
};
|
||||
}, [themeVars]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hidden && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
@@ -332,12 +347,12 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
|
||||
const canSend = selected && prompt.trim().length > 0 && allUploadsReady;
|
||||
|
||||
const handleAgentChange = useCallback((e: ReactChangeEvent<HTMLSelectElement>) => {
|
||||
setAgentId(e.target.value || undefined);
|
||||
const handleAgentChange = useCallback((value: string) => {
|
||||
setAgentId(value || undefined);
|
||||
}, []);
|
||||
|
||||
const handleModelChange = useCallback((e: ReactChangeEvent<HTMLSelectElement>) => {
|
||||
setModelId(e.target.value || undefined);
|
||||
const handleModelChange = useCallback((value: string) => {
|
||||
setModelId(value || undefined);
|
||||
}, []);
|
||||
|
||||
const hasAgents = !!agents && agents.length > 0;
|
||||
@@ -467,71 +482,95 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
|
||||
<div className={styles.actionBar}>
|
||||
<div className={styles.actionBarLeft}>
|
||||
<label
|
||||
aria-label={OVERLAY_COPY.agentSelectLabel}
|
||||
className={cn(styles.selectChip, !hasAgents && styles.selectChipDisabled)}
|
||||
<Select.Root
|
||||
disabled={!hasAgents}
|
||||
value={agentId ?? ''}
|
||||
onValueChange={handleAgentChange}
|
||||
>
|
||||
<OverlayAvatar
|
||||
avatar={currentAgent?.avatar}
|
||||
background={currentAgent?.backgroundColor}
|
||||
size={18}
|
||||
title={currentAgent?.title}
|
||||
/>
|
||||
<span className={styles.chipLabel}>
|
||||
{currentAgent?.title ?? OVERLAY_COPY.agentSelectPlaceholder}
|
||||
</span>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
<select
|
||||
<Select.Trigger
|
||||
aria-label={OVERLAY_COPY.agentSelectLabel}
|
||||
className={styles.nativeSelect}
|
||||
disabled={!hasAgents}
|
||||
value={agentId ?? ''}
|
||||
onChange={handleAgentChange}
|
||||
className={cn(styles.selectChip, !hasAgents && styles.selectChipDisabled)}
|
||||
>
|
||||
{!hasAgents && <option value="">{OVERLAY_COPY.agentSelectPlaceholder}</option>}
|
||||
{agents?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.avatar && typeof item.avatar === 'string' && item.avatar.length <= 4
|
||||
? `${item.avatar} ${item.title}`
|
||||
: item.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<OverlayAvatar
|
||||
avatar={currentAgent?.avatar}
|
||||
background={currentAgent?.backgroundColor}
|
||||
size={18}
|
||||
title={currentAgent?.title}
|
||||
/>
|
||||
<Select.Value className={styles.chipLabel}>
|
||||
{currentAgent?.title ?? OVERLAY_COPY.agentSelectPlaceholder}
|
||||
</Select.Value>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Positioner
|
||||
align="start"
|
||||
className={styles.popupPositioner}
|
||||
sideOffset={6}
|
||||
>
|
||||
<Select.Popup className={styles.popup}>
|
||||
{agents?.map((item) => (
|
||||
<Select.Item className={styles.popupItem} key={item.id} value={item.id}>
|
||||
<Select.ItemIndicator className={styles.popupItemIndicator}>
|
||||
<CheckIcon size={12} strokeWidth={2.4} />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>
|
||||
{item.avatar &&
|
||||
typeof item.avatar === 'string' &&
|
||||
item.avatar.length <= 4
|
||||
? `${item.avatar} ${item.title}`
|
||||
: item.title}
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Popup>
|
||||
</Select.Positioner>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
|
||||
{showModelSelector && (
|
||||
<label
|
||||
aria-label={OVERLAY_COPY.modelSelectLabel}
|
||||
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
|
||||
<Select.Root
|
||||
disabled={!hasModels}
|
||||
value={modelId ?? ''}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{currentModel ? (
|
||||
<span className={styles.modelIconBox}>
|
||||
<ModelIcon model={currentModel.id} size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.modelIconBoxFallback} />
|
||||
)}
|
||||
<span className={styles.chipLabel}>
|
||||
{currentModel?.displayName ??
|
||||
currentModel?.id ??
|
||||
OVERLAY_COPY.modelSelectPlaceholder}
|
||||
</span>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
<select
|
||||
<Select.Trigger
|
||||
aria-label={OVERLAY_COPY.modelSelectLabel}
|
||||
className={styles.nativeSelect}
|
||||
disabled={!hasModels}
|
||||
value={modelId ?? ''}
|
||||
onChange={handleModelChange}
|
||||
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
|
||||
>
|
||||
{!hasModels && <option value="">{OVERLAY_COPY.modelSelectPlaceholder}</option>}
|
||||
{models?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.displayName ?? item.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{currentModel ? (
|
||||
<span className={styles.modelIconBox}>
|
||||
<ModelIcon model={currentModel.id} size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.modelIconBoxFallback} />
|
||||
)}
|
||||
<Select.Value className={styles.chipLabel}>
|
||||
{currentModel?.displayName ??
|
||||
currentModel?.id ??
|
||||
OVERLAY_COPY.modelSelectPlaceholder}
|
||||
</Select.Value>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Positioner
|
||||
align="start"
|
||||
className={styles.popupPositioner}
|
||||
sideOffset={6}
|
||||
>
|
||||
<Select.Popup className={styles.popup}>
|
||||
{models?.map((item) => (
|
||||
<Select.Item className={styles.popupItem} key={item.id} value={item.id}>
|
||||
<Select.ItemIndicator className={styles.popupItemIndicator}>
|
||||
<CheckIcon size={12} strokeWidth={2.4} />
|
||||
</Select.ItemIndicator>
|
||||
<Select.ItemText>{item.displayName ?? item.id}</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Popup>
|
||||
</Select.Positioner>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -534,3 +534,57 @@ globalStyle(`.${multiSelectionRail}::-webkit-scrollbar`, {
|
||||
globalStyle(`.${textarea}::selection`, {
|
||||
background: 'color-mix(in srgb, var(--lobe-overlay-primary) 22%, transparent)',
|
||||
});
|
||||
|
||||
export const popupPositioner = style({
|
||||
outline: 'none',
|
||||
zIndex: 114_514,
|
||||
});
|
||||
|
||||
export const popup = style({
|
||||
background: v(vars.colorBgElevated),
|
||||
border: `1px solid ${v(vars.colorBorderSecondary)}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: v(vars.panelShadow),
|
||||
color: v(vars.colorText),
|
||||
fontSize: 12,
|
||||
maxHeight: 240,
|
||||
minWidth: 180,
|
||||
outline: 'none',
|
||||
overflowY: 'auto',
|
||||
padding: 4,
|
||||
});
|
||||
|
||||
export const popupItem = style({
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
color: v(vars.colorText),
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
outline: 'none',
|
||||
padding: '6px 8px 6px 24px',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
selectors: {
|
||||
'&[data-highlighted]': {
|
||||
background: v(vars.colorFillTertiary),
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
cursor: 'not-allowed',
|
||||
opacity: 0.45,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const popupItemIndicator = style({
|
||||
alignItems: 'center',
|
||||
color: v(vars.colorPrimary),
|
||||
display: 'inline-flex',
|
||||
height: 12,
|
||||
justifyContent: 'center',
|
||||
left: 6,
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 12,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.12.19",
|
||||
"@cloudflare/workers-types": "^4.20260301.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "~3.2.4",
|
||||
"wrangler": "^4.70.0"
|
||||
}
|
||||
|
||||
@@ -427,10 +427,10 @@ When('用户选择删除选项', async function (this: CustomWorld) {
|
||||
When('用户确认删除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 确认删除...');
|
||||
|
||||
// A confirmation modal should appear
|
||||
const confirmButton = this.page.locator('.ant-modal-confirm-btns button.ant-btn-dangerous');
|
||||
const confirmButton = this.page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /^(ok|delete|删除|确认|确定)$/i });
|
||||
|
||||
// Wait for modal to appear
|
||||
await expect(confirmButton).toBeVisible({ timeout: 5000 });
|
||||
await confirmButton.click();
|
||||
|
||||
|
||||
@@ -294,7 +294,9 @@ When('用户在菜单中选择删除', async function (this: CustomWorld) {
|
||||
When('用户在弹窗中确认删除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 确认删除...');
|
||||
|
||||
const confirmButton = this.page.locator('.ant-modal-confirm-btns button.ant-btn-dangerous');
|
||||
const confirmButton = this.page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /^(ok|delete|删除|确认|确定)$/i });
|
||||
await expect(confirmButton).toBeVisible({ timeout: 5000 });
|
||||
await confirmButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
@@ -100,6 +100,35 @@
|
||||
"channel.groupPolicyOpenHint": "Respond in any group, channel, or thread",
|
||||
"channel.historyLimit": "History Message Limit",
|
||||
"channel.historyLimitHint": "Default number of messages to fetch when reading channel history",
|
||||
"channel.imessage.applicationIdHint": "A stable identifier shared by the cloud channel and the Desktop bridge.",
|
||||
"channel.imessage.applicationIdPlaceholder": "e.g. home-mac-mini",
|
||||
"channel.imessage.blueBubblesPassword": "BlueBubbles Password",
|
||||
"channel.imessage.blueBubblesPasswordHint": "Stored locally in LobeHub Desktop and used only to call the local BlueBubbles server.",
|
||||
"channel.imessage.blueBubblesServerUrl": "BlueBubbles Server URL",
|
||||
"channel.imessage.blueBubblesServerUrlHint": "The local BlueBubbles server URL reachable from this Desktop app.",
|
||||
"channel.imessage.bridgeEnabled": "Enable Bridge",
|
||||
"channel.imessage.bridgeEnabledHint": "When enabled, LobeHub Desktop receives local BlueBubbles webhooks and forwards them to LobeHub.",
|
||||
"channel.imessage.bridgeMissingApplicationId": "Enter the Application ID first.",
|
||||
"channel.imessage.bridgeMissingPassword": "Enter the BlueBubbles password first.",
|
||||
"channel.imessage.bridgeMissingServerUrl": "Enter the BlueBubbles Server URL first.",
|
||||
"channel.imessage.bridgeMissingWebhookSecret": "Enter the Webhook Secret first.",
|
||||
"channel.imessage.bridgePasswordSavedPlaceholder": "Leave blank to keep the saved password",
|
||||
"channel.imessage.bridgeRefresh": "Refresh",
|
||||
"channel.imessage.bridgeRefreshFailed": "Failed to refresh iMessage Desktop bridge",
|
||||
"channel.imessage.bridgeRunning": "Running",
|
||||
"channel.imessage.bridgeSave": "Save Bridge",
|
||||
"channel.imessage.bridgeSaveFailed": "Failed to save iMessage Desktop bridge",
|
||||
"channel.imessage.bridgeSaved": "iMessage Desktop bridge saved",
|
||||
"channel.imessage.bridgeStopped": "Stopped",
|
||||
"channel.imessage.bridgeTest": "Test BlueBubbles",
|
||||
"channel.imessage.bridgeTestFailed": "BlueBubbles test failed",
|
||||
"channel.imessage.bridgeTestSuccess": "BlueBubbles connection passed",
|
||||
"channel.imessage.description": "Connect this assistant to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
|
||||
"channel.imessage.desktopBridge": "Desktop Bridge",
|
||||
"channel.imessage.desktopDeviceId": "Desktop Device ID",
|
||||
"channel.imessage.desktopDeviceIdHint": "The LobeHub Desktop device that runs the local BlueBubbles bridge. Find it in Desktop Gateway settings.",
|
||||
"channel.imessage.webhookSecret": "Webhook Secret",
|
||||
"channel.imessage.webhookSecretHint": "A shared secret used between LobeHub Desktop and the cloud webhook. Use the same value in the Desktop bridge config.",
|
||||
"channel.importConfig": "Import Configuration",
|
||||
"channel.importFailed": "Failed to import configuration",
|
||||
"channel.importInvalidFormat": "Invalid configuration file format",
|
||||
@@ -176,6 +205,7 @@
|
||||
"channel.userIdHint": "Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist",
|
||||
"channel.userIdHint.discord": "Enable Developer Mode (Settings → Advanced), then right-click your avatar → Copy User ID.",
|
||||
"channel.userIdHint.feishu": "Open your app on the Feishu / Lark Open Platform → Permissions, then look up your Open ID.",
|
||||
"channel.userIdHint.imessage": "Use your iMessage handle as seen in BlueBubbles, usually an email address or E.164 phone number.",
|
||||
"channel.userIdHint.line": "Open the LINE Developers Console → your channel → Basic settings tab, and copy \"Your user ID\" (starts with U, 33 chars).",
|
||||
"channel.userIdHint.qq": "Your QQ number, shown on your QQ profile page.",
|
||||
"channel.userIdHint.slack": "Open your Slack profile → ⋮ More → Copy member ID (starts with U).",
|
||||
|
||||
+20
-7
@@ -28,8 +28,8 @@
|
||||
"agentSignal.receipts.memory.detail": "Saved this for future replies",
|
||||
"agentSignal.receipts.memory.title": "Memory saved",
|
||||
"agentSignal.receipts.recentActivity": "Recent activity",
|
||||
"agentSignal.receipts.skill.detail": "Improved how this assistant handles similar requests",
|
||||
"agentSignal.receipts.skill.title": "Skill updated",
|
||||
"agentSignal.receipts.skill.detail": "Self-refined how this agent handles similar requests",
|
||||
"agentSignal.receipts.skill.title": "Auto-learned a new skill",
|
||||
"agents": "Agents",
|
||||
"artifact.generating": "Generating",
|
||||
"artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open",
|
||||
@@ -208,6 +208,17 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
|
||||
"heteroAgent.cloudRepo.notSet": "No repo selected",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
|
||||
"heteroAgent.executionTarget.infoTooltip": "Pick a remote device to drive that machine from the web. \"This device\" runs the agent locally and is only available inside the desktop app.",
|
||||
"heteroAgent.executionTarget.loading": "Loading devices…",
|
||||
"heteroAgent.executionTarget.local": "This device",
|
||||
"heteroAgent.executionTarget.localDesc": "Run as a local process on this desktop app",
|
||||
"heteroAgent.executionTarget.noDevices": "No remote devices yet. Install the desktop app or run `lh connect` on another machine.",
|
||||
"heteroAgent.executionTarget.offline": "Offline",
|
||||
"heteroAgent.executionTarget.online": "Online",
|
||||
"heteroAgent.executionTarget.sandbox": "Cloud sandbox",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "Run in an ephemeral cloud sandbox",
|
||||
"heteroAgent.executionTarget.title": "Execution Device",
|
||||
"heteroAgent.executionTarget.unknownDevice": "Unknown device",
|
||||
"heteroAgent.fullAccess.label": "Full access",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
|
||||
@@ -367,8 +378,10 @@
|
||||
"platformAgent.create.comingSoon": "Coming Soon",
|
||||
"platformAgent.create.create": "Create Agent",
|
||||
"platformAgent.create.creating": "Creating...",
|
||||
"platformAgent.create.desc.amp": "Connect to Amp running on one of your devices",
|
||||
"platformAgent.create.desc.hermes": "Connect to Hermes running on one of your devices",
|
||||
"platformAgent.create.desc.openclaw": "Connect to OpenClaw running on one of your devices",
|
||||
"platformAgent.create.desc.opencode": "Connect to OpenCode running on one of your devices",
|
||||
"platformAgent.create.descriptionPlaceholder": "Brief description (optional)",
|
||||
"platformAgent.create.downloadDesktop": "Download Desktop App",
|
||||
"platformAgent.create.fetchingProfile": "Fetching profile...",
|
||||
@@ -750,9 +763,9 @@
|
||||
"taskSchedule.weekdays.thu": "Thu",
|
||||
"taskSchedule.weekdays.tue": "Tue",
|
||||
"taskSchedule.weekdays.wed": "Wed",
|
||||
"thread.closeSubagentThread": "Collapse SubAgent conversation",
|
||||
"thread.closeSubagentThread": "Hide Detail",
|
||||
"thread.divider": "Subtopic",
|
||||
"thread.openSubagentThread": "View full SubAgent conversation",
|
||||
"thread.openSubagentThread": "View Detail",
|
||||
"thread.subagentReadOnlyHint": "SubAgent conversations are read-only — execution is driven by the parent agent.",
|
||||
"thread.threadMessageCount": "{{messageCount}} messages",
|
||||
"thread.title": "Subtopic",
|
||||
@@ -805,7 +818,7 @@
|
||||
"tool.intervention.viewParameters": "View parameters ({{count}})",
|
||||
"toolAuth.authorize": "Authorize",
|
||||
"toolAuth.authorizing": "Authorizing...",
|
||||
"toolAuth.hint": "Without authorization or configuration, Skills may not work. This can limit the Agent or cause errors.",
|
||||
"toolAuth.hint": "When Skills aren't authorized or configured, the related Skills won't work and the Agent's capabilities may be limited or run into errors.",
|
||||
"toolAuth.signIn": "Sign In",
|
||||
"toolAuth.title": "Authorize Skills for this Agent",
|
||||
"topic.checkOpenNewTopic": "Start a new topic?",
|
||||
@@ -862,8 +875,8 @@
|
||||
"workflow.toolDisplayName.addPreferenceMemory": "Saved memory",
|
||||
"workflow.toolDisplayName.calculate": "Calculated",
|
||||
"workflow.toolDisplayName.callAgent": "Called an agent",
|
||||
"workflow.toolDisplayName.callSubAgent": "Dispatched a sub-agent",
|
||||
"workflow.toolDisplayName.callSubAgents": "Dispatched sub-agents",
|
||||
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
|
||||
"workflow.toolDisplayName.callSubAgents": "Call SubAgents",
|
||||
"workflow.toolDisplayName.clearTodos": "Cleared todos",
|
||||
"workflow.toolDisplayName.copyDocument": "Copied a document",
|
||||
"workflow.toolDisplayName.crawlMultiPages": "Crawled pages",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"messengerBanner.title": "Talk to Lobe AI on your favorite messaging apps",
|
||||
"more": "More",
|
||||
"navPanel.agent": "Agents",
|
||||
"navPanel.bottomDivider": "Items below anchor to bottom",
|
||||
"navPanel.customizeSidebar": "Customize Sidebar",
|
||||
"navPanel.displayItems": "Display Items",
|
||||
"navPanel.hidden": "Hidden",
|
||||
|
||||
@@ -60,17 +60,7 @@
|
||||
"response.520": "We apologize, the server encountered an unexpected issue that prevented it from completing your request. Please try again later; we are working to resolve this issue.",
|
||||
"response.522": "We apologize, the server connection timed out and was unable to respond to your request in a timely manner. This may be due to an unstable network or the server being temporarily inaccessible. Please try again later; we are working to restore service.",
|
||||
"response.524": "We apologize, the server timed out while waiting for a response, possibly due to a slow reply. Please try again later.",
|
||||
"response.AccountDeactivated": "Your account has been deactivated or suspended. This may be due to policy, security, or account review reasons. Please contact the provider support for assistance.",
|
||||
"response.AgentRuntimeError": "Lobe language model runtime execution error. Please troubleshoot or retry based on the following information.",
|
||||
"response.ComfyUIBizError": "An error occurred while requesting the ComfyUI service. Please troubleshoot using the information below or try again.",
|
||||
"response.ComfyUIEmptyResult": "No image was generated by ComfyUI. Please check the model configuration or try again.",
|
||||
"response.ComfyUIModelError": "Failed to load the ComfyUI model. Please ensure the model file exists.",
|
||||
"response.ComfyUIServiceUnavailable": "Failed to connect to the ComfyUI service. Please ensure it is running properly and the service URL is correctly configured.",
|
||||
"response.ComfyUIUploadFailed": "Failed to upload image to ComfyUI. Please check the server connection or try again.",
|
||||
"response.ComfyUIWorkflowError": "ComfyUI workflow execution failed. Please verify the workflow configuration.",
|
||||
"response.ConnectionCheckFailed": "The request returned empty. Please check if the API proxy address does not end with `/v1`.",
|
||||
"response.CreateMessageError": "Sorry, the message could not be sent successfully. Please copy the content and try sending it again. This message will not be retained after refreshing the page.",
|
||||
"response.ExceededContextWindow": "The current request content exceeds the length that the model can handle. Please reduce the amount of content and try again.",
|
||||
"response.ExceededContextWindowCloud": "The conversation is too long to process. Please edit your last message to reduce input or delete some messages and try again.",
|
||||
"response.FreePlanLimit": "You are currently a free user and cannot use this feature. Please upgrade to a paid plan to continue using it.",
|
||||
"response.GoogleAIBlockReason.BLOCKLIST": "The content includes blocked terms. Please rephrase and try again.",
|
||||
@@ -83,21 +73,9 @@
|
||||
"response.GoogleAIBlockReason.SPII": "The content may include sensitive personal information (SPII). Please remove sensitive details and try again.",
|
||||
"response.GoogleAIBlockReason.default": "The content was blocked ({{blockReason}}). Please adjust it and try again.",
|
||||
"response.InsufficientBudgetForModel": "Your remaining credits are insufficient for this model. Please top up credits, upgrade your plan, or try a less expensive model.",
|
||||
"response.InsufficientQuota": "Sorry, the quota for this key has been reached. Please check if your account balance is sufficient or try again after increasing the key's quota.",
|
||||
"response.InvalidAccessCode": "Invalid access code or empty. Please enter the correct access code or add a custom API Key.",
|
||||
"response.InvalidBedrockCredentials": "Bedrock authentication failed. Please check the AccessKeyId/SecretAccessKey and retry.",
|
||||
"response.InvalidComfyUIArgs": "Invalid ComfyUI configuration. Please check the settings and try again.",
|
||||
"response.InvalidGithubToken": "The GitHub Personal Access Token is incorrect or empty. Please check your GitHub Personal Access Token and try again.",
|
||||
"response.InvalidOllamaArgs": "Invalid Ollama configuration, please check Ollama configuration and try again",
|
||||
"response.InvalidProviderAPIKey": "{{provider}} API Key is incorrect or empty, please check your {{provider}} API Key and try again",
|
||||
"response.InvalidVertexCredentials": "Vertex authentication failed. Please check your credentials and try again.",
|
||||
"response.LobeHubModelDeprecated": "The model \"{{model}}\" is no longer available. Please pick a current model from the model selector.",
|
||||
"response.LocationNotSupportError": "We're sorry, your current location does not support this model service. This may be due to regional restrictions or the service not being available. Please confirm if the current location supports using this service, or try using a different location.",
|
||||
"response.ModelNotFound": "Sorry, the requested model could not be found. It may not exist or you may not have the necessary access permissions. Please try again after changing the API Key or adjusting your access permissions.",
|
||||
"response.NoOpenAIAPIKey": "OpenAI API Key is empty, please add a custom OpenAI API Key",
|
||||
"response.OllamaBizError": "Error requesting Ollama service, please troubleshoot or retry based on the following information",
|
||||
"response.OllamaServiceUnavailable": "Ollama service is unavailable. Please check if Ollama is running properly or if the cross-origin configuration of Ollama is set correctly.",
|
||||
"response.PermissionDenied": "Sorry, you do not have permission to access this service. Please check if your key has the necessary access rights.",
|
||||
"response.PluginApiNotFound": "Sorry, the API does not exist in the skill's manifest. Please check if your request method matches the skill manifest API",
|
||||
"response.PluginApiParamsError": "Sorry, the input parameter validation for the skill request failed. Please check if the input parameters match the API description",
|
||||
"response.PluginFailToTransformArguments": "Sorry, the skill failed to parse the arguments. Please try regenerating the agent message or switch to a more powerful AI model with Tools Calling capability and try again",
|
||||
@@ -111,14 +89,11 @@
|
||||
"response.PluginOpenApiInitError": "Sorry, the OpenAPI client failed to initialize. Please check if the OpenAPI configuration information is correct.",
|
||||
"response.PluginServerError": "Skill server request returned an error. Please check your skill manifest file, skill configuration, or server implementation based on the error information below",
|
||||
"response.PluginSettingsInvalid": "This skill needs to be correctly configured before it can be used. Please check if your configuration is correct",
|
||||
"response.ProviderBizError": "Error requesting {{provider}} service, please troubleshoot or retry based on the following information",
|
||||
"response.ProviderContentModeration": "Content policy check failed. Revise your prompt and try again.",
|
||||
"response.ProviderContentModerationWarning": "Repeated content policy rejections detected. Please revise your prompt before retrying.",
|
||||
"response.ProviderImageContentModerationWarning": "Repeated image safety rejections detected. Similar prompts may temporarily pause image generation.",
|
||||
"response.QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
|
||||
"response.QuotaLimitReachedCloud": "The model service is currently under heavy load. Please try again later or switch to another model.",
|
||||
"response.ServerAgentRuntimeError": "Sorry, the Agent service is currently unavailable. Please try again later or contact us via email for support.",
|
||||
"response.StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
|
||||
"response.SubscriptionKeyMismatch": "We apologize for the inconvenience. Due to a temporary system malfunction, your current subscription usage is inactive. Please click the button below to restore your subscription, or contact us via email for support.",
|
||||
"response.SubscriptionPlanLimit": "Your subscription points have been exhausted, and you cannot use this feature. Please upgrade to a higher plan or configure a custom model API to continue using it.",
|
||||
"response.SubscriptionPlanLimitUltimate": "Your subscription points have been exhausted, and you cannot use this feature. Please top up credits or configure a custom model API to continue using it.",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"home.uploadEntries.folder.title": "Upload Folder",
|
||||
"home.uploadEntries.library.title": "Create New Library",
|
||||
"home.uploadEntries.newPage.title": "New Page",
|
||||
"library.hierarchy.empty.desc": "Add files or create a folder to get started",
|
||||
"library.hierarchy.empty.title": "Nothing here yet",
|
||||
"library.list.confirmRemoveLibrary": "You are about to delete this library. The files within it will not be deleted but moved to All Files. This action cannot be undone, so please proceed with caution.",
|
||||
"library.list.empty": "Click <1>+</1> to create a new library",
|
||||
"library.new": "New Library",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"generation.actions.seedCopied": "Seed Copied to Clipboard",
|
||||
"generation.actions.seedCopyFailed": "Failed to Copy Seed",
|
||||
"generation.metadata.count": "{{count}} Images",
|
||||
"generation.status.failed": "Generation Failed",
|
||||
"generation.status.failed": "Generation hit a problem. Adjust the prompt and try again",
|
||||
"generation.status.generating": "Generating...",
|
||||
"notSupportGuide.desc": "The current deployment mode does not support AI image generation. Switch to the <1>server database deployment mode</1>, or use <3>LobeHub Cloud</3>.",
|
||||
"notSupportGuide.features.fileIntegration.desc": "Deep integration with the file management system; generated images are automatically saved to the file system for unified management and organization.",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"features.agentSelfIteration.title": "Agent Self-iteration",
|
||||
"features.assistantMessageGroup.desc": "Group agent messages and their tool call results together for display",
|
||||
"features.assistantMessageGroup.title": "Agent Message Grouping",
|
||||
"features.executionDeviceSwitcher.desc": "Surface the execution-device switcher in the heterogeneous agent toolbar so you can route runs to this device, a cloud sandbox, or a bound remote device.",
|
||||
"features.executionDeviceSwitcher.title": "Execution Device Switcher",
|
||||
"features.gatewayMode.desc": "Execute agent tasks on the server via Gateway WebSocket instead of running locally. Enables faster execution and reduces client resource usage.",
|
||||
"features.gatewayMode.title": "Server-Side Agent Execution (Gateway)",
|
||||
"features.groupChat.desc": "Enable multi-agent group chat coordination.",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"AccountDeactivated": "Your account has been deactivated or suspended. This may be due to policy, security, or account review reasons. Please contact the provider support for assistance.",
|
||||
"AgentRuntimeError": "Lobe language model runtime execution error. Please troubleshoot or retry based on the following information.",
|
||||
"CapabilityNotSupported": "Sorry, this model does not support the requested capability (such as vision input or tool calling). Please switch to a model that supports it.",
|
||||
"ComfyUIBizError": "An error occurred while requesting the ComfyUI service. Please troubleshoot using the information below or try again.",
|
||||
"ComfyUIEmptyResult": "No image was generated by ComfyUI. Please check the model configuration or try again.",
|
||||
"ComfyUIModelError": "Failed to load the ComfyUI model. Please ensure the model file exists.",
|
||||
"ComfyUIServiceUnavailable": "Failed to connect to the ComfyUI service. Please ensure it is running properly and the service URL is correctly configured.",
|
||||
"ComfyUIUploadFailed": "Failed to upload image to ComfyUI. Please check the server connection or try again.",
|
||||
"ComfyUIWorkflowError": "ComfyUI workflow execution failed. Please verify the workflow configuration.",
|
||||
"ConnectionCheckFailed": "The request returned empty. Please check if the API proxy address does not end with `/v1`.",
|
||||
"ContentModeration": "Sorry, the content was rejected by the upstream safety filter. Please revise your prompt and try again.",
|
||||
"ExceededContextWindow": "The current request content exceeds the length that the model can handle. Please reduce the amount of content and try again.",
|
||||
"InsufficientQuota": "Sorry, the quota for this key has been reached. Please check if your account balance is sufficient or try again after increasing the key's quota.",
|
||||
"InvalidBedrockCredentials": "Bedrock authentication failed. Please check the AccessKeyId/SecretAccessKey and retry.",
|
||||
"InvalidComfyUIArgs": "Invalid ComfyUI configuration. Please check the settings and try again.",
|
||||
"InvalidGithubToken": "The GitHub Personal Access Token is incorrect or empty. Please check your GitHub Personal Access Token and try again.",
|
||||
"InvalidOllamaArgs": "Invalid Ollama configuration, please check Ollama configuration and try again",
|
||||
"InvalidProviderAPIKey": "{{provider}} API Key is incorrect or empty, please check your {{provider}} API Key and try again",
|
||||
"InvalidRequestFormat": "Sorry, the upstream provider rejected the request as malformed. Please check the input or try a different model.",
|
||||
"InvalidVertexCredentials": "Vertex authentication failed. Please check your credentials and try again.",
|
||||
"LocationNotSupportError": "We're sorry, your current location does not support this model service. This may be due to regional restrictions or the service not being available. Please confirm if the current location supports using this service, or try using a different location.",
|
||||
"ModelNotFound": "Sorry, the requested model could not be found. It may not exist or you may not have the necessary access permissions. Please try again after changing the API Key or adjusting your access permissions.",
|
||||
"NoAvailableChannel": "Sorry, the proxy or router has no available channel for the requested model. Please switch the channel/key configuration or try again later.",
|
||||
"OllamaBizError": "Error requesting Ollama service, please troubleshoot or retry based on the following information",
|
||||
"OllamaServiceUnavailable": "Ollama service is unavailable. Please check if Ollama is running properly or if the cross-origin configuration of Ollama is set correctly.",
|
||||
"OperationInactivityTimeout": "The agent operation was idle for too long and was terminated. Please retry the request.",
|
||||
"PermissionDenied": "Sorry, you do not have permission to access this service. Please check if your key has the necessary access rights.",
|
||||
"ProviderBizError": "Error requesting {{provider}} service, please troubleshoot or retry based on the following information",
|
||||
"ProviderNetworkError": "Connection to the provider timed out or was dropped. Please check your network and try again.",
|
||||
"ProviderServiceUnavailable": "The provider is temporarily overloaded or unavailable. Please try again shortly.",
|
||||
"QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
|
||||
"RateLimitExceeded": "Sorry, the token usage or request count has reached the rate limit for this key. Please try again later or increase the key's quota.",
|
||||
"StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
|
||||
"UserConfigError": "Provider configuration is invalid (incorrect base URL, missing environment variable, virtual-key restriction, etc.). Please review the provider settings."
|
||||
}
|
||||
@@ -400,6 +400,7 @@
|
||||
"deepseek-ai/DeepSeek-V3.2.description": "DeepSeek-V3.2 is a model that combines high computational efficiency with excellent reasoning and Agent performance. Its approach is based on three major technological breakthroughs: DeepSeek Sparse Attention (DSA), an efficient attention mechanism that significantly reduces computational complexity while maintaining model performance, and is specifically optimized for long-context scenarios; a scalable reinforcement learning framework, through which the model's performance can rival GPT-5, and its high-compute version can rival Gemini-3.0-Pro in reasoning capabilities; and a large-scale Agent task synthesis pipeline, designed to integrate reasoning capabilities into tool-using scenarios, thereby improving instruction-following and generalization abilities in complex interactive environments. The model achieved gold medal results in the 2025 International Mathematical Olympiad (IMO) and International Informatics Olympiad (IOI).",
|
||||
"deepseek-ai/DeepSeek-V3.description": "DeepSeek-V3 is a 671B-parameter MoE model using MLA and DeepSeekMoE with loss-free load balancing for efficient training and inference. Pretrained on 14.8T high-quality tokens with SFT and RL, it outperforms other open models and approaches leading closed models.",
|
||||
"deepseek-ai/DeepSeek-V4-Flash.description": "DeepSeek-V4-Flash is a preview version of the MoE language model in the DeepSeek-V4 series. The total parameter size is 284B, the activation parameter size is 13B, and it supports 1M tokens ultra-long context.The model uses a hybrid attention architecture that combines CSA and HCA, and introduces mHC and Muon Optimizer to improve long-context reasoning efficiency, training stability, and overall performance.",
|
||||
"deepseek-ai/DeepSeek-V4-Pro.description": "DeepSeek-V4-Pro is the flagship MoE language model in the DeepSeek-V4 series, with 1.6T total parameters and 49B active parameters, natively supporting an ultra-long context of 1 million tokens. The model adopts an innovative hybrid attention architecture combining Compressed Sparse Attention (CSA) and Highly Compressed Attention (HCA), requiring only 27% of DeepSeek-V3.2 per-token inference FLOPs and 10% KV cache at 1M context. It also introduces Manifold-Constrained Hyper Connections (mHC) to enhance inter-layer signal propagation stability, and employs the Muon optimizer to accelerate convergence. DeepSeek-V4-Pro is pretrained on over 32T high-quality diverse tokens, with post-training using a two-stage paradigm of independent domain expert cultivation plus online policy distillation for unified integration. Its maximum reasoning intensity mode DeepSeek-V4-Pro-Max achieves top performance on coding benchmarks and significantly narrows the gap with leading closed-source models on reasoning and agentic tasks, making it one of the strongest open-source models today, supporting Non-think, Think High, and Think Max reasoning intensity modes.",
|
||||
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat (67B) is an innovative model offering deep language understanding and interaction.",
|
||||
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 is a next-gen reasoning model with stronger complex reasoning and chain-of-thought for deep analysis tasks.",
|
||||
"deepseek-ai/deepseek-v3.2.description": "DeepSeek V3.2 is a next-gen reasoning model with stronger complex reasoning and chain-of-thought capabilities.",
|
||||
|
||||
@@ -72,10 +72,9 @@
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia": "Analyze visual media",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} media",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "Analyze visual media: <question>{{question}}</question>",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "Call sub-agent",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.completed": "Sub-agent dispatched: ",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.loading": "Dispatching sub-agent: ",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "Call sub-agents",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "Call SubAgent",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "Call SubAgents",
|
||||
"builtins.lobe-agent.apiName.callSubAgents.more": "{{count}} in total",
|
||||
"builtins.lobe-agent.apiName.clearTodos": "Clear todos",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeAll": "all",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeCompleted": "completed",
|
||||
@@ -87,6 +86,8 @@
|
||||
"builtins.lobe-agent.apiName.updatePlan.completed": "Completed",
|
||||
"builtins.lobe-agent.apiName.updatePlan.modified": "Modified",
|
||||
"builtins.lobe-agent.apiName.updateTodos": "Update todos",
|
||||
"builtins.lobe-agent.subAgent.stats.tokens": "{{count}} tokens",
|
||||
"builtins.lobe-agent.subAgent.stats.tools": "{{count}} tools",
|
||||
"builtins.lobe-agent.title": "Lobe Agent",
|
||||
"builtins.lobe-claude-code.agent.instruction": "Instruction",
|
||||
"builtins.lobe-claude-code.agent.result": "Result",
|
||||
|
||||
@@ -503,6 +503,8 @@
|
||||
"plugin.settings.tooltip": "Skill Configuration",
|
||||
"plugin.store": "Skill Store",
|
||||
"publishToCommunity": "Publish to Community",
|
||||
"serviceModel.contextLimit.placeholder": "Context limit",
|
||||
"serviceModel.memoryModels.title": "Memory Models",
|
||||
"serviceModel.modelAssignments.title": "Model Assignments",
|
||||
"serviceModel.optionalFeatures.title": "Optional Features",
|
||||
"settingAgent.avatar.sizeExceeded": "Image size exceeds 1MB limit, please choose a smaller image",
|
||||
@@ -549,6 +551,9 @@
|
||||
"settingChat.enableAutoScrollOnStreaming.desc": "Override global setting for this assistant",
|
||||
"settingChat.enableAutoScrollOnStreaming.title": "Auto-scroll During AI Response",
|
||||
"settingChat.enableCompressHistory.title": "Enable Automatic Summary of Chat History",
|
||||
"settingChat.enableFollowUpChips.desc": "After each reply, show one-click follow-up reply chips below the message. Requires the global Follow-up model to be configured.",
|
||||
"settingChat.enableFollowUpChips.notConfiguredHint": "Configure the global Follow-up model first to enable this.",
|
||||
"settingChat.enableFollowUpChips.title": "Follow-up Suggestions",
|
||||
"settingChat.enableHistoryCount.alias": "Unlimited",
|
||||
"settingChat.enableHistoryCount.limited": "Include only {{number}} conversation messages",
|
||||
"settingChat.enableHistoryCount.setlimited": "Set limited history messages",
|
||||
@@ -840,6 +845,9 @@
|
||||
"systemAgent.customPrompt.desc": "Once filled out, the system agent will use the custom prompt when generating content",
|
||||
"systemAgent.customPrompt.placeholder": "Please enter custom prompt",
|
||||
"systemAgent.customPrompt.title": "Custom Prompt",
|
||||
"systemAgent.followUpAction.label": "Follow-up Suggestions Model",
|
||||
"systemAgent.followUpAction.modelDesc": "Model used to suggest one-click follow-up replies under each assistant message",
|
||||
"systemAgent.followUpAction.title": "Follow-up Suggestions",
|
||||
"systemAgent.generationTopic.label": "Model",
|
||||
"systemAgent.generationTopic.modelDesc": "Model used to name AI image topics",
|
||||
"systemAgent.generationTopic.title": "AI Image Topic Naming",
|
||||
@@ -850,6 +858,9 @@
|
||||
"systemAgent.inputCompletion.label": "Model",
|
||||
"systemAgent.inputCompletion.modelDesc": "Suggests text while you type. When enabled, this model generates the suggestions.",
|
||||
"systemAgent.inputCompletion.title": "Input Suggestions",
|
||||
"systemAgent.memoryAnalysisAgentConfig.label": "Model",
|
||||
"systemAgent.memoryAnalysisAgentConfig.modelDesc": "Model used to decide whether conversations contain memory and extract identities, preferences, contexts, activities, and experiences.",
|
||||
"systemAgent.memoryAnalysisAgentConfig.title": "Memory Analysis",
|
||||
"systemAgent.promptRewrite.label": "Model",
|
||||
"systemAgent.promptRewrite.modelDesc": "Improves prompts before generation. When enabled, this model rewrites the prompt.",
|
||||
"systemAgent.promptRewrite.title": "Prompt Rewriting",
|
||||
@@ -863,6 +874,12 @@
|
||||
"systemAgent.translation.label": "Model",
|
||||
"systemAgent.translation.modelDesc": "Model used to translate messages",
|
||||
"systemAgent.translation.title": "Message Translation",
|
||||
"systemAgent.userMemoryEmbedding.label": "Model",
|
||||
"systemAgent.userMemoryEmbedding.modelDesc": "Model used to embed memory content for retrieval. The context limit caps each embedding input.",
|
||||
"systemAgent.userMemoryEmbedding.title": "Memory Embedding",
|
||||
"systemAgent.userMemoryPersonaWriter.label": "Model",
|
||||
"systemAgent.userMemoryPersonaWriter.modelDesc": "Model used to write persona-oriented memory summaries.",
|
||||
"systemAgent.userMemoryPersonaWriter.title": "Memory Persona Writer",
|
||||
"tab.about": "About",
|
||||
"tab.addAgentSkill": "Add Agent Skill",
|
||||
"tab.addCustomMcp": "Add Custom MCP Skill",
|
||||
|
||||
@@ -51,6 +51,77 @@
|
||||
"inPopup.focus": "Focus Popup Window",
|
||||
"inPopup.title": "Open in Popup Window",
|
||||
"loadMore": "Load More",
|
||||
"management.actions.newChat": "New chat",
|
||||
"management.actions.select": "Select",
|
||||
"management.actionsMenu.archiveStale.confirm": "Archive {{count}} topics that have been inactive for over 3 months? They will be marked as completed.",
|
||||
"management.actionsMenu.archiveStale.confirmOk": "Archive",
|
||||
"management.actionsMenu.archiveStale.done": "Archived {{count}} stale topics.",
|
||||
"management.actionsMenu.archiveStale.label": "Archive topics inactive for 3+ months",
|
||||
"management.actionsMenu.archiveStale.noneFound": "No stale topics found.",
|
||||
"management.actionsMenu.archiveStale.title": "Archive stale topics?",
|
||||
"management.actionsMenu.autoSummarize.comingSoon": "Auto-summarization is coming soon — track on the roadmap.",
|
||||
"management.actionsMenu.autoSummarize.label": "Auto-generate summaries for topics without one",
|
||||
"management.actionsMenu.title": "More actions",
|
||||
"management.bulk.archive": "Archive",
|
||||
"management.bulk.cancel": "Cancel",
|
||||
"management.bulk.delete": "Delete",
|
||||
"management.bulk.deleteConfirm": "You are about to delete {{count}} topics. This action cannot be undone.",
|
||||
"management.bulk.deleteTitle": "Delete topics?",
|
||||
"management.bulk.favorite": "Favorite",
|
||||
"management.bulk.selectedCount_one": "{{count}} selected",
|
||||
"management.bulk.selectedCount_other": "{{count}} selected",
|
||||
"management.card.noPreview": "No preview available",
|
||||
"management.columns.project": "Project",
|
||||
"management.columns.status": "Status",
|
||||
"management.columns.title": "Title",
|
||||
"management.columns.trigger": "Trigger",
|
||||
"management.columns.updated": "Updated",
|
||||
"management.empty.filtered.action": "Clear filters",
|
||||
"management.empty.filtered.desc": "Try adjusting filters or clearing them to see more topics.",
|
||||
"management.empty.filtered.title": "No topics match these filters",
|
||||
"management.empty.noTopics.action": "Start new chat",
|
||||
"management.empty.noTopics.desc": "Start a conversation with this agent to create your first topic.",
|
||||
"management.empty.noTopics.title": "No topics yet",
|
||||
"management.filters.project.empty": "No projects",
|
||||
"management.filters.project.label": "Project",
|
||||
"management.filters.status.active": "Active",
|
||||
"management.filters.status.all": "All",
|
||||
"management.filters.status.archived": "Archived",
|
||||
"management.filters.status.completed": "Completed",
|
||||
"management.filters.status.favorite": "Favorites",
|
||||
"management.filters.status.running": "Running",
|
||||
"management.filters.time.all": "All time",
|
||||
"management.filters.time.label": "Time",
|
||||
"management.filters.time.month": "Past month",
|
||||
"management.filters.time.today": "Today",
|
||||
"management.filters.time.week": "Past week",
|
||||
"management.filters.trigger.api": "API",
|
||||
"management.filters.trigger.chat": "Chat",
|
||||
"management.filters.trigger.eval": "Eval",
|
||||
"management.filters.trigger.label": "Trigger",
|
||||
"management.filters.trigger.task": "Task",
|
||||
"management.group.byProject": "By project",
|
||||
"management.group.byTime": "By time",
|
||||
"management.group.label": "Group",
|
||||
"management.group.noProject": "No project",
|
||||
"management.group.none": "None",
|
||||
"management.loadingMore": "Loading more topics…",
|
||||
"management.searchPlaceholder": "Search this agent's topics…",
|
||||
"management.sidebarEntry": "Topics",
|
||||
"management.sort.createdAt": "Created time",
|
||||
"management.sort.label": "Sort",
|
||||
"management.sort.title": "Title",
|
||||
"management.sort.updatedAt": "Updated time",
|
||||
"management.status.active": "Active",
|
||||
"management.status.archived": "Archived",
|
||||
"management.status.completed": "Completed",
|
||||
"management.status.failed": "Failed",
|
||||
"management.status.paused": "Paused",
|
||||
"management.status.running": "Running",
|
||||
"management.status.waitingForHuman": "Awaiting input",
|
||||
"management.title": "Topics",
|
||||
"management.view.card": "Card",
|
||||
"management.view.list": "List",
|
||||
"newTopic": "New Topic",
|
||||
"renameModal.description": "Keep it short and easy to recognize.",
|
||||
"renameModal.title": "Rename Topic",
|
||||
|
||||
@@ -100,6 +100,35 @@
|
||||
"channel.groupPolicyOpenHint": "在所有群组、频道、子话题中响应",
|
||||
"channel.historyLimit": "历史消息条数",
|
||||
"channel.historyLimitHint": "读取频道历史消息时默认获取的消息数量",
|
||||
"channel.imessage.applicationIdHint": "云端渠道和桌面端桥接共同使用的稳定标识。",
|
||||
"channel.imessage.applicationIdPlaceholder": "例如 home-mac-mini",
|
||||
"channel.imessage.blueBubblesPassword": "BlueBubbles 密码",
|
||||
"channel.imessage.blueBubblesPasswordHint": "仅保存在 LobeHub Desktop 本地,用于访问本机 BlueBubbles Server。",
|
||||
"channel.imessage.blueBubblesServerUrl": "BlueBubbles Server URL",
|
||||
"channel.imessage.blueBubblesServerUrlHint": "当前桌面端可以访问到的本机 BlueBubbles Server 地址。",
|
||||
"channel.imessage.bridgeEnabled": "启用桥接",
|
||||
"channel.imessage.bridgeEnabledHint": "启用后,LobeHub Desktop 会接收本机 BlueBubbles webhook 并转发给 LobeHub。",
|
||||
"channel.imessage.bridgeMissingApplicationId": "请先填写 Application ID。",
|
||||
"channel.imessage.bridgeMissingPassword": "请先填写 BlueBubbles 密码。",
|
||||
"channel.imessage.bridgeMissingServerUrl": "请先填写 BlueBubbles Server URL。",
|
||||
"channel.imessage.bridgeMissingWebhookSecret": "请先填写 Webhook Secret。",
|
||||
"channel.imessage.bridgePasswordSavedPlaceholder": "留空则沿用已保存的密码",
|
||||
"channel.imessage.bridgeRefresh": "刷新",
|
||||
"channel.imessage.bridgeRefreshFailed": "刷新 iMessage Desktop 桥接失败",
|
||||
"channel.imessage.bridgeRunning": "运行中",
|
||||
"channel.imessage.bridgeSave": "保存桥接",
|
||||
"channel.imessage.bridgeSaveFailed": "保存 iMessage Desktop 桥接失败",
|
||||
"channel.imessage.bridgeSaved": "iMessage Desktop 桥接已保存",
|
||||
"channel.imessage.bridgeStopped": "已停止",
|
||||
"channel.imessage.bridgeTest": "测试 BlueBubbles",
|
||||
"channel.imessage.bridgeTestFailed": "BlueBubbles 测试失败",
|
||||
"channel.imessage.bridgeTestSuccess": "BlueBubbles 连接测试通过",
|
||||
"channel.imessage.description": "通过 LobeHub Desktop 本地 BlueBubbles 桥接将助手连接到 iMessage。",
|
||||
"channel.imessage.desktopBridge": "桌面端桥接",
|
||||
"channel.imessage.desktopDeviceId": "桌面端设备 ID",
|
||||
"channel.imessage.desktopDeviceIdHint": "运行本地 BlueBubbles 桥接的 LobeHub Desktop 设备,可在桌面端 Gateway 设置中找到。",
|
||||
"channel.imessage.webhookSecret": "Webhook Secret",
|
||||
"channel.imessage.webhookSecretHint": "LobeHub Desktop 与云端 webhook 之间使用的共享密钥,需要和桌面端桥接配置保持一致。",
|
||||
"channel.importConfig": "导入平台配置",
|
||||
"channel.importFailed": "配置导入失败",
|
||||
"channel.importInvalidFormat": "配置文件格式无效",
|
||||
@@ -176,6 +205,7 @@
|
||||
"channel.userIdHint": "供 AI 工具主动联系你(如提醒、通知),并自动加入全局白名单",
|
||||
"channel.userIdHint.discord": "在 Discord 设置 → 高级中开启开发者模式,然后右键你的头像 → 复制用户 ID。",
|
||||
"channel.userIdHint.feishu": "在飞书 / Lark 开放平台打开你的应用 → 权限管理,查看你的 Open ID。",
|
||||
"channel.userIdHint.imessage": "使用 BlueBubbles 中显示的 iMessage handle,通常是邮箱或 E.164 手机号。",
|
||||
"channel.userIdHint.line": "打开 LINE Developers Console → 你的 channel → Basic settings 选项卡,复制 \"Your user ID\"(以 U 开头共 33 位)。",
|
||||
"channel.userIdHint.qq": "你的 QQ 号,在 QQ 资料页可见。",
|
||||
"channel.userIdHint.slack": "打开 Slack 个人资料 → ⋮ 更多 → 复制 Member ID(以 U 开头)。",
|
||||
|
||||
+20
-7
@@ -28,8 +28,8 @@
|
||||
"agentSignal.receipts.memory.detail": "已保存以供未来回复使用",
|
||||
"agentSignal.receipts.memory.title": "记忆已保存",
|
||||
"agentSignal.receipts.recentActivity": "最近活动",
|
||||
"agentSignal.receipts.skill.detail": "改进了此助手处理类似请求的方式",
|
||||
"agentSignal.receipts.skill.title": "技能已更新",
|
||||
"agentSignal.receipts.skill.detail": "已自主优化处理相似请求的方式",
|
||||
"agentSignal.receipts.skill.title": "已自动习得新技能",
|
||||
"agents": "助理",
|
||||
"artifact.generating": "生成中",
|
||||
"artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开",
|
||||
@@ -208,6 +208,17 @@
|
||||
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
|
||||
"heteroAgent.cloudRepo.notSet": "未选择仓库",
|
||||
"heteroAgent.cloudRepo.sectionTitle": "代码仓库",
|
||||
"heteroAgent.executionTarget.infoTooltip": "选择「远程设备」后可在网页中驱动该机器;「本机」仅在桌面端内运行 agent。",
|
||||
"heteroAgent.executionTarget.loading": "正在加载设备…",
|
||||
"heteroAgent.executionTarget.local": "本机",
|
||||
"heteroAgent.executionTarget.localDesc": "在当前桌面端以本地进程运行",
|
||||
"heteroAgent.executionTarget.noDevices": "暂无远程设备。在另一台机器上安装桌面端或执行 `lh connect` 接入。",
|
||||
"heteroAgent.executionTarget.offline": "离线",
|
||||
"heteroAgent.executionTarget.online": "在线",
|
||||
"heteroAgent.executionTarget.sandbox": "云端沙箱",
|
||||
"heteroAgent.executionTarget.sandboxDesc": "在临时云端沙箱中运行",
|
||||
"heteroAgent.executionTarget.title": "执行设备",
|
||||
"heteroAgent.executionTarget.unknownDevice": "未知设备",
|
||||
"heteroAgent.fullAccess.label": "完全访问权限",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code 在本地运行,对工作目录拥有完全的读写权限。当前暂不支持切换权限模式。",
|
||||
"heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。",
|
||||
@@ -367,8 +378,10 @@
|
||||
"platformAgent.create.comingSoon": "即将推出",
|
||||
"platformAgent.create.create": "创建 Agent",
|
||||
"platformAgent.create.creating": "创建中...",
|
||||
"platformAgent.create.desc.amp": "连接你某台设备上的 Amp Agent",
|
||||
"platformAgent.create.desc.hermes": "连接你某台设备上的 Hermes Agent",
|
||||
"platformAgent.create.desc.openclaw": "连接你某台设备上的 OpenClaw Agent",
|
||||
"platformAgent.create.desc.opencode": "连接你某台设备上的 OpenCode Agent",
|
||||
"platformAgent.create.descriptionPlaceholder": "简短描述(可选)",
|
||||
"platformAgent.create.downloadDesktop": "下载桌面端",
|
||||
"platformAgent.create.fetchingProfile": "正在读取配置...",
|
||||
@@ -750,9 +763,9 @@
|
||||
"taskSchedule.weekdays.thu": "四",
|
||||
"taskSchedule.weekdays.tue": "二",
|
||||
"taskSchedule.weekdays.wed": "三",
|
||||
"thread.closeSubagentThread": "收起 SubAgent 对话",
|
||||
"thread.closeSubagentThread": "隐藏详情",
|
||||
"thread.divider": "子话题",
|
||||
"thread.openSubagentThread": "查看完整 SubAgent 对话",
|
||||
"thread.openSubagentThread": "查看详情",
|
||||
"thread.subagentReadOnlyHint": "SubAgent 对话仅可查看,由父智能体驱动执行",
|
||||
"thread.threadMessageCount": "{{messageCount}} 条消息",
|
||||
"thread.title": "子话题",
|
||||
@@ -805,7 +818,7 @@
|
||||
"tool.intervention.viewParameters": "查看参数 ({{count}})",
|
||||
"toolAuth.authorize": "授权",
|
||||
"toolAuth.authorizing": "授权中…",
|
||||
"toolAuth.hint": "未授权或未配置时,相关技能无法使用。这可能导致助理能力受限或报错",
|
||||
"toolAuth.hint": "技能未授权或未配置时,相关技能无法使用,可能导致助理能力受限或报错",
|
||||
"toolAuth.signIn": "登录",
|
||||
"toolAuth.title": "为助理完成技能授权",
|
||||
"topic.checkOpenNewTopic": "要开启新话题吗?",
|
||||
@@ -862,8 +875,8 @@
|
||||
"workflow.toolDisplayName.addPreferenceMemory": "保存了记忆",
|
||||
"workflow.toolDisplayName.calculate": "完成了计算",
|
||||
"workflow.toolDisplayName.callAgent": "调用了助理",
|
||||
"workflow.toolDisplayName.callSubAgent": "派发了子代理",
|
||||
"workflow.toolDisplayName.callSubAgents": "派发了多个子代理",
|
||||
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
|
||||
"workflow.toolDisplayName.callSubAgents": "Call SubAgents",
|
||||
"workflow.toolDisplayName.clearTodos": "清空了待办",
|
||||
"workflow.toolDisplayName.copyDocument": "复制了文档",
|
||||
"workflow.toolDisplayName.crawlMultiPages": "抓取了多个页面",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
"messengerBanner.title": "在你喜爱的聊天应用中,与 Lobe AI 畅聊",
|
||||
"more": "更多",
|
||||
"navPanel.agent": "助理",
|
||||
"navPanel.bottomDivider": "下方条目锚定到底部",
|
||||
"navPanel.customizeSidebar": "自定义侧边栏",
|
||||
"navPanel.displayItems": "显示条目",
|
||||
"navPanel.hidden": "已隐藏",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"home.uploadEntries.folder.title": "上传文件夹",
|
||||
"home.uploadEntries.library.title": "新建资源库",
|
||||
"home.uploadEntries.newPage.title": "新建文稿",
|
||||
"library.hierarchy.empty.desc": "上传文件或新建文件夹开始整理",
|
||||
"library.hierarchy.empty.title": "这里还没有内容",
|
||||
"library.list.confirmRemoveLibrary": "将删除该资源库(其中的文件不会删除,会移入「全部文件」)。删除后不可恢复,建议确认无误再继续",
|
||||
"library.list.empty": "点击 <1>+</1> 创建第一个资源库",
|
||||
"library.new": "新建资源库",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"generation.actions.seedCopied": "种子已复制到剪贴板",
|
||||
"generation.actions.seedCopyFailed": "复制遇到了问题。你可以再试一次",
|
||||
"generation.metadata.count": "{{count}} 张图片",
|
||||
"generation.status.failed": "生成遇到了问题。你可以重试,或调整描述后再试",
|
||||
"generation.status.failed": "生成遇到了问题,建议调整描述后重试",
|
||||
"generation.status.generating": "生成中…",
|
||||
"notSupportGuide.desc": "当前部署模式不支持 AI 图像生成功能。请切换到<1>服务端数据库部署模式</1>,或直接使用 <3>LobeHub Cloud</3>",
|
||||
"notSupportGuide.features.fileIntegration.desc": "与文件管理深度整合。生成的图片自动保存到文件系统,统一管理和组织",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"features.agentSelfIteration.title": "Agent 自我迭代",
|
||||
"features.assistantMessageGroup.desc": "将代理消息及其工具调用结果组合在一起显示",
|
||||
"features.assistantMessageGroup.title": "代理消息分组",
|
||||
"features.executionDeviceSwitcher.desc": "在异构 Agent 工具栏中展示「执行设备」切换器,可将运行任务路由到本机、云端沙箱或已绑定的远程设备。",
|
||||
"features.executionDeviceSwitcher.title": "执行设备切换器",
|
||||
"features.gatewayMode.desc": "通过 Gateway 在服务端执行 Agent 任务。可实现关闭浏览器后仍然执行 agent。",
|
||||
"features.gatewayMode.title": "服务端代理执行(Gateway)",
|
||||
"features.groupChat.desc": "启用多代理协同群聊功能。",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"deepseek-ai/DeepSeek-V3.2.description": "DeepSeek-V3.2 是一款结合高计算效率与卓越推理和 Agent 性能的模型。其方法基于三项主要技术突破:DeepSeek 稀疏注意力(DSA),一种高效的注意力机制,在显著降低计算复杂度的同时保持模型性能,特别针对长上下文场景进行了优化;可扩展的强化学习框架,使模型性能可媲美 GPT-5,其高计算版本在推理能力上可媲美 Gemini-3.0-Pro;以及一个大规模 Agent 任务合成管道,旨在将推理能力集成到工具使用场景中,从而提升复杂交互环境中的指令遵循和泛化能力。该模型在 2025 年国际数学奥林匹克(IMO)和国际信息学奥林匹克(IOI)中获得金牌成绩。",
|
||||
"deepseek-ai/DeepSeek-V3.description": "DeepSeek-V3 是一个拥有 671B 参数的 MoE 模型,采用 MLA 和 DeepSeekMoE 架构,具备无损负载均衡,实现高效训练与推理。在 14.8T 高质量数据上预训练,并结合 SFT 与 RL,性能超越其他开源模型,接近领先闭源模型。",
|
||||
"deepseek-ai/DeepSeek-V4-Flash.description": "DeepSeek-V4-Flash 是 DeepSeek-V4 系列中 MoE 语言模型的预览版本。总参数规模为 2840 亿,激活参数规模为 130 亿,支持 1M 令牌超长上下文。该模型采用结合 CSA 和 HCA 的混合注意力架构,并引入 mHC 和 Muon 优化器,以提高长上下文推理效率、训练稳定性和整体性能。",
|
||||
"deepseek-ai/DeepSeek-V4-Pro.description": "DeepSeek-V4-Pro 是 DeepSeek-V4 系列中的旗舰 MoE 语言模型,拥有 1.6T 总参数、49B 激活参数,原生支持 100 万 tokens 的超长上下文。该模型采用创新的混合注意力架构,结合压缩稀疏注意力(CSA)与高度压缩注意力(HCA),在 1M 上下文下仅需 DeepSeek-V3.2 的 27% 单 token 推理 FLOPs 和 10% KV 缓存。模型还引入流形约束超连接(mHC)增强层间信号传播稳定性,并采用 Muon 优化器加速收敛。DeepSeek-V4-Pro 在超过 32T 高质量多样化 tokens 上预训练,后训练采用「领域专家独立培养 + 在线策略蒸馏统一整合」的两阶段范式。其最大推理强度模式 DeepSeek-V4-Pro-Max 在编程基准上取得顶尖表现,并在推理与 Agentic 任务上大幅缩小与领先闭源模型的差距,是目前最强的开源模型之一,支持 Non-think、Think High、Think Max 三种推理强度模式",
|
||||
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat(67B)是一款创新模型,具备深度语言理解与交互能力。",
|
||||
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 是下一代推理模型,具备更强的复杂推理与链式思维能力,适用于深度分析任务。",
|
||||
"deepseek-ai/deepseek-v3.2.description": "DeepSeek V3.2是下一代推理模型,具备更强的复杂推理和链式思维能力。",
|
||||
|
||||
@@ -72,10 +72,9 @@
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia": "分析视觉媒体",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} 个媒体",
|
||||
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "分析视觉媒体:<question>{{question}}</question>",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "调用子代理",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.completed": "已派发子代理:",
|
||||
"builtins.lobe-agent.apiName.callSubAgent.loading": "正在派发子代理:",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "调用多个子代理",
|
||||
"builtins.lobe-agent.apiName.callSubAgent": "Call SubAgent",
|
||||
"builtins.lobe-agent.apiName.callSubAgents": "Call SubAgents",
|
||||
"builtins.lobe-agent.apiName.callSubAgents.more": "等 {{count}} 个",
|
||||
"builtins.lobe-agent.apiName.clearTodos": "清除待办",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeAll": "全部",
|
||||
"builtins.lobe-agent.apiName.clearTodos.modeCompleted": "已完成",
|
||||
@@ -87,6 +86,8 @@
|
||||
"builtins.lobe-agent.apiName.updatePlan.completed": "已完成",
|
||||
"builtins.lobe-agent.apiName.updatePlan.modified": "已修改",
|
||||
"builtins.lobe-agent.apiName.updateTodos": "更新待办",
|
||||
"builtins.lobe-agent.subAgent.stats.tokens": "{{count}} tokens",
|
||||
"builtins.lobe-agent.subAgent.stats.tools": "{{count}} 个工具",
|
||||
"builtins.lobe-agent.title": "Lobe Agent",
|
||||
"builtins.lobe-claude-code.agent.instruction": "指令",
|
||||
"builtins.lobe-claude-code.agent.result": "结果",
|
||||
|
||||
@@ -503,6 +503,8 @@
|
||||
"plugin.settings.tooltip": "技能配置",
|
||||
"plugin.store": "技能商店",
|
||||
"publishToCommunity": "发布到社区",
|
||||
"serviceModel.contextLimit.placeholder": "上下文限制",
|
||||
"serviceModel.memoryModels.title": "记忆模型",
|
||||
"serviceModel.modelAssignments.title": "模型分配",
|
||||
"serviceModel.optionalFeatures.title": "可选功能",
|
||||
"settingAgent.avatar.sizeExceeded": "图片大小超过 1MB 限制,请选择更小的图片",
|
||||
@@ -549,6 +551,9 @@
|
||||
"settingChat.enableAutoScrollOnStreaming.desc": "覆盖此助手的全局设置",
|
||||
"settingChat.enableAutoScrollOnStreaming.title": "AI 回复时自动滚动",
|
||||
"settingChat.enableCompressHistory.title": "开启历史消息自动总结",
|
||||
"settingChat.enableFollowUpChips.desc": "每次回复后,在消息下方展示一键跟进的快捷气泡。需先配置全局跟进建议模型。",
|
||||
"settingChat.enableFollowUpChips.notConfiguredHint": "请先配置全局跟进建议模型后启用。",
|
||||
"settingChat.enableFollowUpChips.title": "跟进建议",
|
||||
"settingChat.enableHistoryCount.alias": "不限制",
|
||||
"settingChat.enableHistoryCount.limited": "只包含 {{number}} 条会话消息",
|
||||
"settingChat.enableHistoryCount.setlimited": "使用历史消息数",
|
||||
@@ -840,6 +845,9 @@
|
||||
"systemAgent.customPrompt.desc": "填写后,系统助理将在生成内容时使用自定义提示",
|
||||
"systemAgent.customPrompt.placeholder": "请输入自定义提示词",
|
||||
"systemAgent.customPrompt.title": "自定义提示词",
|
||||
"systemAgent.followUpAction.label": "跟进建议模型",
|
||||
"systemAgent.followUpAction.modelDesc": "用于在每条助手回复下生成一键跟进建议的模型",
|
||||
"systemAgent.followUpAction.title": "跟进建议",
|
||||
"systemAgent.generationTopic.label": "模型",
|
||||
"systemAgent.generationTopic.modelDesc": "用于自动命名 AI 图片话题的模型",
|
||||
"systemAgent.generationTopic.title": "AI 图片话题命名",
|
||||
@@ -850,6 +858,9 @@
|
||||
"systemAgent.inputCompletion.label": "模型",
|
||||
"systemAgent.inputCompletion.modelDesc": "输入时生成文本建议。开启后,由该模型生成建议。",
|
||||
"systemAgent.inputCompletion.title": "输入建议",
|
||||
"systemAgent.memoryAnalysisAgentConfig.label": "模型",
|
||||
"systemAgent.memoryAnalysisAgentConfig.modelDesc": "用于判断对话是否包含记忆,并提取身份、偏好、上下文、活动和经历。",
|
||||
"systemAgent.memoryAnalysisAgentConfig.title": "记忆分析",
|
||||
"systemAgent.promptRewrite.label": "模型",
|
||||
"systemAgent.promptRewrite.modelDesc": "生成前优化提示词。开启后,由该模型改写提示词。",
|
||||
"systemAgent.promptRewrite.title": "提示词改写",
|
||||
@@ -863,6 +874,12 @@
|
||||
"systemAgent.translation.label": "模型",
|
||||
"systemAgent.translation.modelDesc": "用于翻译消息内容的模型",
|
||||
"systemAgent.translation.title": "消息内容翻译",
|
||||
"systemAgent.userMemoryEmbedding.label": "模型",
|
||||
"systemAgent.userMemoryEmbedding.modelDesc": "用于为记忆内容生成向量以支持检索。上下文限制会约束每次向量化输入。",
|
||||
"systemAgent.userMemoryEmbedding.title": "记忆向量化",
|
||||
"systemAgent.userMemoryPersonaWriter.label": "模型",
|
||||
"systemAgent.userMemoryPersonaWriter.modelDesc": "用于生成面向画像的记忆摘要。",
|
||||
"systemAgent.userMemoryPersonaWriter.title": "记忆画像写入",
|
||||
"tab.about": "关于",
|
||||
"tab.addAgentSkill": "添加 Agent 技能",
|
||||
"tab.addCustomMcp": "添加自定义 MCP 技能",
|
||||
|
||||
@@ -51,6 +51,77 @@
|
||||
"inPopup.focus": "聚焦独立窗口",
|
||||
"inPopup.title": "已在独立窗口中打开",
|
||||
"loadMore": "更多",
|
||||
"management.actions.newChat": "新对话",
|
||||
"management.actions.select": "选择",
|
||||
"management.actionsMenu.archiveStale.confirm": "将 {{count}} 个超过 3 个月未活动的话题归档(标记为已完成)?",
|
||||
"management.actionsMenu.archiveStale.confirmOk": "归档",
|
||||
"management.actionsMenu.archiveStale.done": "已归档 {{count}} 个话题。",
|
||||
"management.actionsMenu.archiveStale.label": "归档 3 个月未活动的话题",
|
||||
"management.actionsMenu.archiveStale.noneFound": "没有需要归档的话题。",
|
||||
"management.actionsMenu.archiveStale.title": "归档过时话题?",
|
||||
"management.actionsMenu.autoSummarize.comingSoon": "自动生成摘要功能即将上线,敬请期待。",
|
||||
"management.actionsMenu.autoSummarize.label": "为缺少摘要的话题自动生成",
|
||||
"management.actionsMenu.title": "更多操作",
|
||||
"management.bulk.archive": "归档",
|
||||
"management.bulk.cancel": "取消",
|
||||
"management.bulk.delete": "删除",
|
||||
"management.bulk.deleteConfirm": "即将删除 {{count}} 个话题,此操作无法撤销。",
|
||||
"management.bulk.deleteTitle": "删除话题?",
|
||||
"management.bulk.favorite": "收藏",
|
||||
"management.bulk.selectedCount_one": "已选 {{count}} 项",
|
||||
"management.bulk.selectedCount_other": "已选 {{count}} 项",
|
||||
"management.card.noPreview": "暂无预览内容",
|
||||
"management.columns.project": "项目",
|
||||
"management.columns.status": "状态",
|
||||
"management.columns.title": "标题",
|
||||
"management.columns.trigger": "来源",
|
||||
"management.columns.updated": "更新时间",
|
||||
"management.empty.filtered.action": "清空筛选",
|
||||
"management.empty.filtered.desc": "试试调整或清空筛选条件,看更多话题。",
|
||||
"management.empty.filtered.title": "没有符合条件的话题",
|
||||
"management.empty.noTopics.action": "开始新对话",
|
||||
"management.empty.noTopics.desc": "和这个助手聊聊,创建第一个话题。",
|
||||
"management.empty.noTopics.title": "还没有话题",
|
||||
"management.filters.project.empty": "暂无项目",
|
||||
"management.filters.project.label": "项目",
|
||||
"management.filters.status.active": "活跃",
|
||||
"management.filters.status.all": "全部",
|
||||
"management.filters.status.archived": "已归档",
|
||||
"management.filters.status.completed": "已完成",
|
||||
"management.filters.status.favorite": "已收藏",
|
||||
"management.filters.status.running": "运行中",
|
||||
"management.filters.time.all": "全部时间",
|
||||
"management.filters.time.label": "时间",
|
||||
"management.filters.time.month": "最近一月",
|
||||
"management.filters.time.today": "今天",
|
||||
"management.filters.time.week": "最近一周",
|
||||
"management.filters.trigger.api": "API",
|
||||
"management.filters.trigger.chat": "对话",
|
||||
"management.filters.trigger.eval": "评测",
|
||||
"management.filters.trigger.label": "来源",
|
||||
"management.filters.trigger.task": "任务",
|
||||
"management.group.byProject": "按项目",
|
||||
"management.group.byTime": "按时间",
|
||||
"management.group.label": "分组",
|
||||
"management.group.noProject": "无项目",
|
||||
"management.group.none": "不分组",
|
||||
"management.loadingMore": "正在加载更多话题…",
|
||||
"management.searchPlaceholder": "在当前助手的话题中搜索…",
|
||||
"management.sidebarEntry": "话题",
|
||||
"management.sort.createdAt": "按创建时间",
|
||||
"management.sort.label": "排序",
|
||||
"management.sort.title": "按标题",
|
||||
"management.sort.updatedAt": "按更新时间",
|
||||
"management.status.active": "活跃",
|
||||
"management.status.archived": "已归档",
|
||||
"management.status.completed": "已完成",
|
||||
"management.status.failed": "已失败",
|
||||
"management.status.paused": "已暂停",
|
||||
"management.status.running": "运行中",
|
||||
"management.status.waitingForHuman": "等待响应",
|
||||
"management.title": "话题",
|
||||
"management.view.card": "卡片",
|
||||
"management.view.list": "列表",
|
||||
"newTopic": "新话题",
|
||||
"renameModal.description": "保持简短且易于识别。",
|
||||
"renameModal.title": "重命名话题",
|
||||
|
||||
+6
-4
@@ -245,6 +245,7 @@
|
||||
"@lobechat/business-model-bank": "workspace:*",
|
||||
"@lobechat/business-model-runtime": "workspace:*",
|
||||
"@lobechat/chat-adapter-feishu": "workspace:*",
|
||||
"@lobechat/chat-adapter-imessage": "workspace:*",
|
||||
"@lobechat/chat-adapter-line": "workspace:*",
|
||||
"@lobechat/chat-adapter-qq": "workspace:*",
|
||||
"@lobechat/chat-adapter-wechat": "workspace:*",
|
||||
@@ -280,11 +281,11 @@
|
||||
"@lobehub/analytics": "^1.6.2",
|
||||
"@lobehub/charts": "^5.0.0",
|
||||
"@lobehub/desktop-ipc-typings": "workspace:*",
|
||||
"@lobehub/editor": "^4.9.3",
|
||||
"@lobehub/editor": "^4.12.0",
|
||||
"@lobehub/icons": "^5.0.0",
|
||||
"@lobehub/market-sdk": "0.33.3",
|
||||
"@lobehub/tts": "^5.1.2",
|
||||
"@lobehub/ui": "^5.14.1",
|
||||
"@lobehub/ui": "^5.15.1",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@napi-rs/canvas": "^0.1.88",
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
@@ -536,7 +537,7 @@
|
||||
"stylelint": "^16.12.0",
|
||||
"tsx": "^4.21.0",
|
||||
"type-fest": "^5.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"vite": "8.0.14",
|
||||
@@ -567,7 +568,8 @@
|
||||
"pdfjs-dist": "5.4.530",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"stylelint-config-clean-order": "7.0.0"
|
||||
"stylelint-config-clean-order": "7.0.0",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@upstash/qstash": "patches/@upstash__qstash.patch"
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ToolsCalling,
|
||||
Usage,
|
||||
} from '../types';
|
||||
import { isBlockedStatus } from '../utils/status';
|
||||
|
||||
/**
|
||||
* Simplified Agent Runtime - The "Engine" that executes instructions from an "Agent" (Brain).
|
||||
@@ -197,7 +198,7 @@ export class AgentRuntime {
|
||||
}
|
||||
|
||||
// Stop execution if blocked
|
||||
if (currentState.status === 'waiting_for_human' || currentState.status === 'interrupted') {
|
||||
if (isBlockedStatus(currentState.status)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -214,12 +215,10 @@ export class AgentRuntime {
|
||||
return {
|
||||
events: allEvents,
|
||||
newState: currentState,
|
||||
// When execution is blocked (waiting for human or interrupted),
|
||||
// clear nextContext so the outer loop stops instead of continuing
|
||||
nextContext:
|
||||
currentState.status === 'waiting_for_human' || currentState.status === 'interrupted'
|
||||
? undefined
|
||||
: finalNextContext,
|
||||
// When execution is blocked (waiting for human, waiting for an async
|
||||
// tool result, or interrupted), clear nextContext so the outer loop
|
||||
// stops instead of continuing
|
||||
nextContext: isBlockedStatus(currentState.status) ? undefined : finalNextContext,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorState = structuredClone(state);
|
||||
|
||||
@@ -113,7 +113,14 @@ export interface AgentState {
|
||||
*/
|
||||
securityBlacklist?: SecurityBlacklistConfig;
|
||||
// --- State Machine ---
|
||||
status: 'idle' | 'running' | 'waiting_for_human' | 'done' | 'error' | 'interrupted';
|
||||
status:
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'waiting_for_human'
|
||||
| 'waiting_for_async_tool'
|
||||
| 'done'
|
||||
| 'error'
|
||||
| 'interrupted';
|
||||
|
||||
// --- Execution Tracking ---
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './messageSelectors';
|
||||
export * from './status';
|
||||
export * from './stepContextComputer';
|
||||
export * from './tokenCounter';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AgentState } from '../types/state';
|
||||
import { isBlockedStatus, isParkedStatus } from './status';
|
||||
|
||||
const ALL_STATUSES: AgentState['status'][] = [
|
||||
'idle',
|
||||
'running',
|
||||
'waiting_for_human',
|
||||
'waiting_for_async_tool',
|
||||
'done',
|
||||
'error',
|
||||
'interrupted',
|
||||
];
|
||||
|
||||
describe('isParkedStatus', () => {
|
||||
it('is true only for the non-terminal resumable pauses', () => {
|
||||
expect(isParkedStatus('waiting_for_human')).toBe(true);
|
||||
expect(isParkedStatus('waiting_for_async_tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for running, terminal, and interrupted', () => {
|
||||
const nonParked = ALL_STATUSES.filter(
|
||||
(s) => s !== 'waiting_for_human' && s !== 'waiting_for_async_tool',
|
||||
);
|
||||
for (const status of nonParked) expect(isParkedStatus(status)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBlockedStatus', () => {
|
||||
it('is true for parked statuses and user interrupt', () => {
|
||||
expect(isBlockedStatus('waiting_for_human')).toBe(true);
|
||||
expect(isBlockedStatus('waiting_for_async_tool')).toBe(true);
|
||||
expect(isBlockedStatus('interrupted')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for idle, running, and terminal statuses', () => {
|
||||
expect(isBlockedStatus('idle')).toBe(false);
|
||||
expect(isBlockedStatus('running')).toBe(false);
|
||||
expect(isBlockedStatus('done')).toBe(false);
|
||||
expect(isBlockedStatus('error')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AgentState } from '../types/state';
|
||||
|
||||
/**
|
||||
* Parked statuses are non-terminal, resumable pauses: the operation is still
|
||||
* alive but waiting on something out-of-band — human approval
|
||||
* (`waiting_for_human`) or an async tool / sub-agent result
|
||||
* (`waiting_for_async_tool`). They are deliberately distinct from `interrupted`
|
||||
* (user cancel) and the terminal `done` / `error`, so the completion lifecycle
|
||||
* never stamps `completedAt` and the scheduler keeps treating them as active.
|
||||
*/
|
||||
export const isParkedStatus = (status: AgentState['status']): boolean =>
|
||||
status === 'waiting_for_human' || status === 'waiting_for_async_tool';
|
||||
|
||||
/**
|
||||
* Blocked statuses halt the step loop — a parked pause or a user interrupt.
|
||||
* `done` / `error` terminate through their own handling.
|
||||
*/
|
||||
export const isBlockedStatus = (status: AgentState['status']): boolean =>
|
||||
isParkedStatus(status) || status === 'interrupted';
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BuiltinAgentDefinition } from '../../types';
|
||||
import { BUILTIN_AGENT_SLUGS } from '../../types';
|
||||
|
||||
const SELF_ITERATION_TOOL_IDENTIFIER = 'agent-signal-self-iteration';
|
||||
|
||||
/**
|
||||
* Self-Iteration Agent - shared execAgent target for nightly review, post-turn
|
||||
* reflection, and declared feedback intents.
|
||||
*
|
||||
* All three flows share the same tool surface (`agent-signal-self-iteration`);
|
||||
* the mode-specific guidance is supplied per-call by the caller's prompt builder,
|
||||
* so the agent itself stays neutral.
|
||||
*/
|
||||
export const SELF_ITERATION: BuiltinAgentDefinition = {
|
||||
runtime: {
|
||||
plugins: [SELF_ITERATION_TOOL_IDENTIFIER],
|
||||
systemRole:
|
||||
'You are the self-iteration agent. Follow the mode-specific instructions in the user prompt and apply safe resource operations using the provided self-iteration tools. Be concise and evidence-driven.',
|
||||
},
|
||||
slug: BUILTIN_AGENT_SLUGS.selfIteration,
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { GROUP_AGENT_BUILDER } from './agents/group-agent-builder';
|
||||
import { GROUP_SUPERVISOR } from './agents/group-supervisor';
|
||||
import { INBOX } from './agents/inbox';
|
||||
import { PAGE_AGENT } from './agents/page-agent';
|
||||
import { SELF_ITERATION } from './agents/self-iteration';
|
||||
import { TASK_AGENT } from './agents/task-agent';
|
||||
import { WEB_ONBOARDING } from './agents/web-onboarding';
|
||||
import type { BuiltinAgentDefinition, BuiltinAgentSlug, RuntimeContext } from './types';
|
||||
@@ -16,6 +17,7 @@ export { GROUP_AGENT_BUILDER } from './agents/group-agent-builder';
|
||||
export { GROUP_SUPERVISOR } from './agents/group-supervisor';
|
||||
export { INBOX } from './agents/inbox';
|
||||
export { PAGE_AGENT } from './agents/page-agent';
|
||||
export { SELF_ITERATION } from './agents/self-iteration';
|
||||
export { TASK_AGENT } from './agents/task-agent';
|
||||
export { WEB_ONBOARDING } from './agents/web-onboarding';
|
||||
|
||||
@@ -28,10 +30,20 @@ export const BUILTIN_AGENTS: Record<BuiltinAgentSlug, BuiltinAgentDefinition> =
|
||||
[BUILTIN_AGENT_SLUGS.groupSupervisor]: GROUP_SUPERVISOR,
|
||||
[BUILTIN_AGENT_SLUGS.inbox]: INBOX,
|
||||
[BUILTIN_AGENT_SLUGS.pageAgent]: PAGE_AGENT,
|
||||
[BUILTIN_AGENT_SLUGS.selfIteration]: SELF_ITERATION,
|
||||
[BUILTIN_AGENT_SLUGS.taskAgent]: TASK_AGENT,
|
||||
[BUILTIN_AGENT_SLUGS.webOnboarding]: WEB_ONBOARDING,
|
||||
};
|
||||
|
||||
/**
|
||||
* Slugs that belong to the self-iteration family.
|
||||
* Used by AgentSignal to skip re-triggering signal events
|
||||
* for builtin background runs (suppressSignal behaviour).
|
||||
*/
|
||||
export const SELF_ITERATION_AGENT_SLUGS = new Set<BuiltinAgentSlug>([
|
||||
BUILTIN_AGENT_SLUGS.selfIteration,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get persist config for a builtin agent (for DB operations)
|
||||
* @param slug - The builtin agent slug
|
||||
|
||||
@@ -11,6 +11,7 @@ export const BUILTIN_AGENT_SLUGS = {
|
||||
groupSupervisor: 'group-supervisor',
|
||||
inbox: 'inbox',
|
||||
pageAgent: 'page-agent',
|
||||
selfIteration: 'self-iteration',
|
||||
taskAgent: 'task-agent',
|
||||
webOnboarding: 'web-onboarding',
|
||||
} as const;
|
||||
|
||||
@@ -1,53 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { cx } from 'antd-style';
|
||||
import { GroupBotIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { CallSubAgentParams, CallSubAgentState } from '../../../types';
|
||||
import { SubAgentStats } from '../../components/SubAgentStats';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-shrink: 1;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
padding-block: 2px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
icon: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
label: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
root: css`
|
||||
gap: 6px;
|
||||
`,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Collapsed row for lobe-agent's `callSubAgent`. Mirrors the Claude Code Agent
|
||||
* tool: leading bot icon + "Call SubAgent" label + the description in a chip.
|
||||
* Once the run finishes, the persisted state feeds a compact stats tail
|
||||
* (tool count · model · tokens).
|
||||
*/
|
||||
export const CallSubAgentInspector = memo<
|
||||
BuiltinInspectorProps<CallSubAgentParams, CallSubAgentState>
|
||||
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
|
||||
>(({ args, partialArgs, pluginState, isArgumentsStreaming, isLoading }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const description = args?.description || partialArgs?.description;
|
||||
|
||||
if (isArgumentsStreaming) {
|
||||
if (!description)
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-agent.apiName.callSubAgent')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-agent.apiName.callSubAgent.loading')}</span>
|
||||
<span className={highlightTextStyles.primary}>{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (description) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
|
||||
<span>
|
||||
{isLoading
|
||||
? t('builtins.lobe-agent.apiName.callSubAgent.loading')
|
||||
: t('builtins.lobe-agent.apiName.callSubAgent.completed')}
|
||||
</span>
|
||||
<span className={highlightTextStyles.primary}>{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const description = (args?.description || partialArgs?.description)?.trim();
|
||||
const isShiny = isArgumentsStreaming || isLoading;
|
||||
|
||||
return (
|
||||
<div className={inspectorTextStyles.root}>
|
||||
<span>{t('builtins.lobe-agent.apiName.callSubAgent')}</span>
|
||||
<div
|
||||
className={cx(inspectorTextStyles.root, styles.root, isShiny && shinyTextStyles.shinyText)}
|
||||
>
|
||||
<GroupBotIcon className={styles.icon} size={14} />
|
||||
<span className={styles.label}>{t('builtins.lobe-agent.apiName.callSubAgent')}</span>
|
||||
{description && <span className={styles.chip}>{description}</span>}
|
||||
{!isShiny && pluginState && (
|
||||
<SubAgentStats
|
||||
model={pluginState.model}
|
||||
totalTokens={pluginState.totalTokens}
|
||||
totalToolCalls={pluginState.totalToolCalls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,69 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { GroupBotIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { ListTodo } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, shinyTextStyles } from '@/styles';
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { CallSubAgentsParams, CallSubAgentsState } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
count: css`
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: 4px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
description: css`
|
||||
chip: css`
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-shrink: 1;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
padding-block: 2px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
root: css`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
icon: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
title: css`
|
||||
label: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
more: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
root: css`
|
||||
gap: 6px;
|
||||
`,
|
||||
}));
|
||||
|
||||
/** Show every description when there are at most this many; otherwise collapse. */
|
||||
const MAX_VISIBLE = 2;
|
||||
|
||||
/**
|
||||
* Collapsed row for lobe-agent's `callSubAgents`. Leading bot icon + "Call
|
||||
* SubAgents" label, then each sub-agent description as a chip when there are
|
||||
* few (<= 2). Beyond that, only the first is shown followed by a "{{count}} in
|
||||
* total" tail to keep the row compact.
|
||||
*/
|
||||
export const CallSubAgentsInspector = memo<
|
||||
BuiltinInspectorProps<CallSubAgentsParams, CallSubAgentsState>
|
||||
>(({ args, partialArgs, isArgumentsStreaming }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const tasks = args?.tasks || partialArgs?.tasks || [];
|
||||
const count = tasks.length;
|
||||
const firstTask = tasks[0];
|
||||
const descriptions = tasks.map((task) => task?.description?.trim()).filter(Boolean) as string[];
|
||||
const count = descriptions.length;
|
||||
|
||||
if (isArgumentsStreaming && count === 0) {
|
||||
return (
|
||||
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-agent.apiName.callSubAgents')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isShiny = isArgumentsStreaming;
|
||||
const visible = count > MAX_VISIBLE ? descriptions.slice(0, 1) : descriptions;
|
||||
const showMore = count > MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<span className={cx(styles.title, isArgumentsStreaming && shinyTextStyles.shinyText)}>
|
||||
{t('builtins.lobe-agent.apiName.callSubAgents')}:
|
||||
</span>
|
||||
{firstTask?.description && (
|
||||
<span className={cx(styles.description, highlightTextStyles.primary)}>
|
||||
{firstTask.description}
|
||||
<div
|
||||
className={cx(inspectorTextStyles.root, styles.root, isShiny && shinyTextStyles.shinyText)}
|
||||
>
|
||||
<GroupBotIcon className={styles.icon} size={14} />
|
||||
<span className={styles.label}>{t('builtins.lobe-agent.apiName.callSubAgents')}</span>
|
||||
{visible.map((description, index) => (
|
||||
<span className={styles.chip} key={index}>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
{count > 1 && (
|
||||
<span className={styles.count}>
|
||||
<Icon icon={ListTodo} size={14} /> {count}
|
||||
))}
|
||||
{showMore && (
|
||||
<span className={styles.more}>
|
||||
{t('builtins.lobe-agent.apiName.callSubAgents.more', { count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,64 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Block } from '@lobehub/ui';
|
||||
import { Button, Flexbox, Markdown, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { ListTree } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { portalThreadSelectors, threadSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import type { CallSubAgentParams, CallSubAgentState } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
instruction: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
|
||||
container: css`
|
||||
padding-block: 4px;
|
||||
`,
|
||||
label: css`
|
||||
padding-inline-start: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
taskContent: css`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
min-width: 0;
|
||||
labelRow: css`
|
||||
margin-block-end: 4px;
|
||||
`,
|
||||
taskItem: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
|
||||
padding-block: 10px;
|
||||
openThread: css`
|
||||
height: 22px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
`,
|
||||
promptBox: css`
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
title: css`
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: ${cssVar.colorText};
|
||||
resultBox: css`
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
export const CallSubAgentRender = memo<BuiltinRenderProps<CallSubAgentParams, CallSubAgentState>>(
|
||||
({ pluginState }) => {
|
||||
const { task } = pluginState || {};
|
||||
/**
|
||||
* Render for lobe-agent's `callSubAgent` tool.
|
||||
*
|
||||
* A sub-agent runs in an isolated Thread via the current runtime, so this view
|
||||
* shows the instruction sent to it plus its closing summary (the tool result),
|
||||
* and exposes a toggle to open / collapse that Thread in the portal. The Thread
|
||||
* is located by the `threadId` persisted in tool state; while the run is still
|
||||
* starting the lookup can return `undefined`, so the button is hidden rather
|
||||
* than rendered as a dead no-op.
|
||||
*/
|
||||
export const CallSubAgentRender = memo<
|
||||
BuiltinRenderProps<CallSubAgentParams, CallSubAgentState, string>
|
||||
>(({ args, content, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const { t: tChat } = useTranslation('chat');
|
||||
const prompt = args?.instruction?.trim();
|
||||
const result = typeof content === 'string' ? content.trim() : '';
|
||||
const threadId = pluginState?.threadId;
|
||||
|
||||
if (!task) return null;
|
||||
const subagentThread = useChatStore((s) =>
|
||||
threadId
|
||||
? (threadSelectors.currentTopicThreads(s) ?? []).find((thread) => thread.id === threadId)
|
||||
: undefined,
|
||||
);
|
||||
const openThreadInPortal = useChatStore((s) => s.openThreadInPortal);
|
||||
const closeThreadPortal = useChatStore((s) => s.closeThreadPortal);
|
||||
const portalThreadId = useChatStore(portalThreadSelectors.portalThreadId);
|
||||
const isOpenInPortal = !!subagentThread && portalThreadId === subagentThread.id;
|
||||
|
||||
return (
|
||||
<Block variant={'outlined'} width="100%">
|
||||
<div className={styles.taskItem}>
|
||||
<div className={styles.taskContent}>
|
||||
{task.description && <div className={styles.title}>{task.description}</div>}
|
||||
{task.instruction && <div className={styles.instruction}>{task.instruction}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Block>
|
||||
);
|
||||
},
|
||||
);
|
||||
const handleToggleThread = useCallback(() => {
|
||||
if (!subagentThread) return;
|
||||
if (isOpenInPortal) {
|
||||
closeThreadPortal();
|
||||
} else {
|
||||
openThreadInPortal(subagentThread.id, subagentThread.sourceMessageId);
|
||||
}
|
||||
}, [subagentThread, isOpenInPortal, openThreadInPortal, closeThreadPortal]);
|
||||
|
||||
if (!prompt && !result && !subagentThread) return null;
|
||||
|
||||
const showResultSection = !!result || !!subagentThread;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={12}>
|
||||
{prompt && (
|
||||
<Flexbox>
|
||||
<Text className={styles.label} style={{ marginBlockEnd: 4 }}>
|
||||
{t('builtins.lobe-claude-code.agent.instruction')}
|
||||
</Text>
|
||||
<Flexbox className={styles.promptBox}>
|
||||
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
|
||||
{prompt}
|
||||
</Markdown>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
{showResultSection && (
|
||||
<Flexbox>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.labelRow}
|
||||
justify={'space-between'}
|
||||
>
|
||||
<Text className={styles.label}>{t('builtins.lobe-claude-code.agent.result')}</Text>
|
||||
{subagentThread && (
|
||||
<Button
|
||||
className={styles.openThread}
|
||||
icon={ListTree}
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
onClick={handleToggleThread}
|
||||
>
|
||||
{isOpenInPortal
|
||||
? tChat('thread.closeSubagentThread')
|
||||
: tChat('thread.openSubagentThread')}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
{result && (
|
||||
<Flexbox className={styles.resultBox}>
|
||||
<Markdown style={{ maxHeight: 320, overflow: 'auto' }} variant={'chat'}>
|
||||
{result}
|
||||
</Markdown>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
CallSubAgentRender.displayName = 'CallSubAgentRender';
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Block, Text } from '@lobehub/ui';
|
||||
import { Block, Button, Flexbox } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { ListTree } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { CallSubAgentsParams, CallSubAgentsState } from '../../../types';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { portalThreadSelectors, threadSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import type { CallSubAgentsParams, CallSubAgentsState, SubAgentRunStats } from '../../../types';
|
||||
import { SubAgentStats } from '../../components/SubAgentStats';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
index: css`
|
||||
@@ -13,13 +19,18 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
`,
|
||||
|
||||
taskItem: css`
|
||||
openThread: css`
|
||||
height: 22px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
`,
|
||||
row: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding-block: 12px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border-block-end: 1px dashed ${cssVar.colorBorderSecondary};
|
||||
|
||||
@@ -27,33 +38,93 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
border-block-end: none;
|
||||
}
|
||||
`,
|
||||
title: css`
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface SubAgentRowProps extends SubAgentRunStats {
|
||||
description: string;
|
||||
index: number;
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
const SubAgentRow = memo<SubAgentRowProps>(
|
||||
({ description, index, threadId, model, totalToolCalls, totalTokens }) => {
|
||||
const { t: tChat } = useTranslation('chat');
|
||||
|
||||
const subagentThread = useChatStore((s) =>
|
||||
threadId
|
||||
? (threadSelectors.currentTopicThreads(s) ?? []).find((thread) => thread.id === threadId)
|
||||
: undefined,
|
||||
);
|
||||
const openThreadInPortal = useChatStore((s) => s.openThreadInPortal);
|
||||
const closeThreadPortal = useChatStore((s) => s.closeThreadPortal);
|
||||
const portalThreadId = useChatStore(portalThreadSelectors.portalThreadId);
|
||||
const isOpenInPortal = !!subagentThread && portalThreadId === subagentThread.id;
|
||||
|
||||
const handleToggleThread = useCallback(() => {
|
||||
if (!subagentThread) return;
|
||||
if (isOpenInPortal) {
|
||||
closeThreadPortal();
|
||||
} else {
|
||||
openThreadInPortal(subagentThread.id, subagentThread.sourceMessageId);
|
||||
}
|
||||
}, [subagentThread, isOpenInPortal, openThreadInPortal, closeThreadPortal]);
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0 }}>
|
||||
<span className={styles.index}>{index + 1}.</span>
|
||||
<span className={styles.title}>{description}</span>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align={'center'} gap={12} style={{ flexShrink: 0 }}>
|
||||
<SubAgentStats model={model} totalTokens={totalTokens} totalToolCalls={totalToolCalls} />
|
||||
{subagentThread && (
|
||||
<Button
|
||||
className={styles.openThread}
|
||||
icon={ListTree}
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
onClick={handleToggleThread}
|
||||
>
|
||||
{isOpenInPortal
|
||||
? tChat('thread.closeSubagentThread')
|
||||
: tChat('thread.openSubagentThread')}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SubAgentRow.displayName = 'CallSubAgentsRow';
|
||||
|
||||
export const CallSubAgentsRender = memo<
|
||||
BuiltinRenderProps<CallSubAgentsParams, CallSubAgentsState>
|
||||
>(({ pluginState }) => {
|
||||
const { tasks } = pluginState || {};
|
||||
const subAgents = pluginState?.subAgents;
|
||||
|
||||
if (!tasks || tasks.length === 0) return null;
|
||||
if (!subAgents || subAgents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Block variant={'outlined'} width="100%">
|
||||
{tasks.map((task, index) => (
|
||||
<div className={styles.taskItem} key={index}>
|
||||
<div className={styles.index}>{index + 1}.</div>
|
||||
<div>
|
||||
{task.description && (
|
||||
<Text as={'h4'} fontSize={14} weight={500}>
|
||||
{task.description}
|
||||
</Text>
|
||||
)}
|
||||
{task.instruction && (
|
||||
<Text as={'p'} ellipsis={{ rows: 2 }} fontSize={12} type={'secondary'}>
|
||||
{task.instruction}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{subAgents.map((subAgent, index) => (
|
||||
<SubAgentRow
|
||||
description={subAgent.description}
|
||||
index={index}
|
||||
key={subAgent.threadId || index}
|
||||
model={subAgent.model}
|
||||
threadId={subAgent.threadId}
|
||||
totalTokens={subAgent.totalTokens}
|
||||
totalToolCalls={subAgent.totalToolCalls}
|
||||
/>
|
||||
))}
|
||||
</Block>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SubAgentRunStats } from '../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
root: css`
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
const formatTokens = (n: number): string => {
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`;
|
||||
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact one-line sub-agent run stats: tool count · model · token count.
|
||||
* Renders nothing when no stat is available (e.g. while the run is still in
|
||||
* flight, before the tool result state is persisted).
|
||||
*/
|
||||
export const SubAgentStats = memo<SubAgentRunStats>(({ model, totalToolCalls, totalTokens }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const items = [
|
||||
model || null,
|
||||
typeof totalToolCalls === 'number' && totalToolCalls > 0
|
||||
? t('builtins.lobe-agent.subAgent.stats.tools', { count: totalToolCalls })
|
||||
: null,
|
||||
typeof totalTokens === 'number' && totalTokens > 0
|
||||
? t('builtins.lobe-agent.subAgent.stats.tokens', { count: formatTokens(totalTokens) })
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return <span className={styles.root}>{items.join(' · ')}</span>;
|
||||
});
|
||||
|
||||
SubAgentStats.displayName = 'SubAgentStats';
|
||||
|
||||
export default SubAgentStats;
|
||||
@@ -359,28 +359,41 @@ class LobeAgentExecutor extends BaseExecutor<typeof LobeAgentApiName> {
|
||||
|
||||
// ==================== Sub-Agent ====================
|
||||
//
|
||||
// The executor only constructs the state payload that bridges the tool call
|
||||
// to the agent-runtime instruction layer. The actual sub-agent dispatch is
|
||||
// handled by `createAgentExecutors.ts` which reads `state.type` to emit the
|
||||
// matching `exec_sub_agent` / `exec_client_sub_agent(s)` instruction.
|
||||
// A sub-agent call is a normal tool call: the executor runs the sub-agent in
|
||||
// an isolated Thread via `ctx.subAgent` (the current runtime, injected by the
|
||||
// client) and returns the sub-agent's final output as the tool result. The
|
||||
// Thread id is persisted in state so the Render can open it in the portal.
|
||||
|
||||
callSubAgent = async (
|
||||
params: CallSubAgentParams,
|
||||
ctx: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
const { description, instruction, inheritMessages, timeout, runInClient } = params;
|
||||
const { description, instruction, inheritMessages, timeout } = params;
|
||||
|
||||
if (!description || !instruction) {
|
||||
return { content: 'Sub-agent description and instruction are required.', success: false };
|
||||
}
|
||||
|
||||
const task = { description, inheritMessages, instruction, runInClient, timeout };
|
||||
const stateType = runInClient ? 'execClientSubAgent' : 'execSubAgent';
|
||||
if (!ctx.subAgent) {
|
||||
return { content: 'Sub-agent execution is not available in this runtime.', success: false };
|
||||
}
|
||||
|
||||
const { result, threadId, success, error, model, totalToolCalls, totalTokens } =
|
||||
await ctx.subAgent.run({
|
||||
description,
|
||||
inheritMessages,
|
||||
instruction,
|
||||
timeout,
|
||||
toolMessageId: ctx.messageId,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
return { content: error ?? 'Sub-agent execution failed.', success: false };
|
||||
}
|
||||
|
||||
return {
|
||||
content: `🚀 Dispatched sub-agent for ${runInClient ? 'client-side' : ''} execution:\n- ${description}`,
|
||||
state: { parentMessageId: ctx.messageId ?? '', task, type: stateType },
|
||||
stop: true,
|
||||
content: result,
|
||||
state: { model, threadId, totalToolCalls, totalTokens },
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
@@ -395,16 +408,38 @@ class LobeAgentExecutor extends BaseExecutor<typeof LobeAgentApiName> {
|
||||
return { content: 'No sub-agents provided to dispatch.', success: false };
|
||||
}
|
||||
|
||||
const taskCount = tasks.length;
|
||||
const taskList = tasks.map((t, i) => `${i + 1}. ${t.description}`).join('\n');
|
||||
const hasClientTasks = tasks.some((t) => t.runInClient);
|
||||
const stateType = hasClientTasks ? 'execClientSubAgents' : 'execSubAgents';
|
||||
const executionMode = hasClientTasks ? 'client-side' : '';
|
||||
if (!ctx.subAgent) {
|
||||
return { content: 'Sub-agent execution is not available in this runtime.', success: false };
|
||||
}
|
||||
|
||||
const subAgent = ctx.subAgent;
|
||||
const results = await Promise.all(
|
||||
tasks.map((task) =>
|
||||
subAgent.run({
|
||||
description: task.description,
|
||||
inheritMessages: task.inheritMessages,
|
||||
instruction: task.instruction,
|
||||
timeout: task.timeout,
|
||||
toolMessageId: ctx.messageId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const content = results
|
||||
.map((r, i) => `${i + 1}. ${tasks[i].description}\n${r.success ? r.result : `❌ ${r.error}`}`)
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: `🚀 Dispatched ${taskCount} sub-agent${taskCount > 1 ? 's' : ''} for ${executionMode} execution:\n${taskList}`,
|
||||
state: { parentMessageId: ctx.messageId ?? '', tasks, type: stateType },
|
||||
stop: true,
|
||||
content,
|
||||
state: {
|
||||
subAgents: results.map((r, i) => ({
|
||||
description: tasks[i].description,
|
||||
model: r.model,
|
||||
threadId: r.threadId,
|
||||
totalToolCalls: r.totalToolCalls,
|
||||
totalTokens: r.totalTokens,
|
||||
})),
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -82,38 +82,30 @@ export interface CallSubAgentsParams {
|
||||
tasks: SubAgentTask[];
|
||||
}
|
||||
|
||||
/** Execution stats reported back by a finished sub-agent run. */
|
||||
export interface SubAgentRunStats {
|
||||
/** Model the sub-agent ran on */
|
||||
model?: string;
|
||||
/** Total tokens consumed by the sub-agent run */
|
||||
totalTokens?: number;
|
||||
/** Number of tool calls the sub-agent made */
|
||||
totalToolCalls?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* State returned after dispatching a server-side sub-agent.
|
||||
* State persisted on the callSubAgent tool message.
|
||||
*
|
||||
* The `type` value is the wire-level discriminator the `agent-runtime`
|
||||
* layer (`GeneralChatAgent.tool_result`) inspects to emit the matching
|
||||
* `exec_sub_agent` / `exec_client_sub_agent` instruction.
|
||||
* The sub-agent runs in an isolated Thread via the current runtime; the Render
|
||||
* uses `threadId` to open that Thread in the portal, and the stats feed the
|
||||
* Inspector row.
|
||||
*/
|
||||
export interface CallSubAgentState {
|
||||
parentMessageId: string;
|
||||
task: SubAgentTask;
|
||||
type: 'execSubAgent';
|
||||
export interface CallSubAgentState extends SubAgentRunStats {
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
/** State returned after dispatching multiple server-side sub-agents. */
|
||||
/** State persisted on the callSubAgents tool message (one entry per sub-agent). */
|
||||
export interface CallSubAgentsState {
|
||||
parentMessageId: string;
|
||||
tasks: SubAgentTask[];
|
||||
type: 'execSubAgents';
|
||||
}
|
||||
|
||||
/** State returned after dispatching a desktop-only client-side sub-agent. */
|
||||
export interface CallClientSubAgentState {
|
||||
parentMessageId: string;
|
||||
task: SubAgentTask;
|
||||
type: 'execClientSubAgent';
|
||||
}
|
||||
|
||||
/** State returned after dispatching multiple desktop-only client-side sub-agents. */
|
||||
export interface CallClientSubAgentsState {
|
||||
parentMessageId: string;
|
||||
tasks: SubAgentTask[];
|
||||
type: 'execClientSubAgents';
|
||||
subAgents: ({ description: string; threadId: string } & SubAgentRunStats)[];
|
||||
}
|
||||
|
||||
// ==================== Todo Item ====================
|
||||
|
||||
@@ -185,9 +185,9 @@ export class LocalSystemExecutionRuntime extends ComputerRuntime {
|
||||
case 'getCommandOutput': {
|
||||
return {
|
||||
result: {
|
||||
exitCode: raw.exit_code,
|
||||
error: raw.error,
|
||||
newOutput: raw.output,
|
||||
running: raw.running,
|
||||
success: raw.success,
|
||||
},
|
||||
success: raw.success,
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('localSystemExecutor.getCommandOutput — filter forwarding', () => {
|
||||
};
|
||||
const spy = vi.spyOn(runtime, 'getCommandOutput').mockResolvedValue({
|
||||
content: '',
|
||||
state: { newOutput: '', running: false, success: true },
|
||||
state: { exitCode: 0, newOutput: '', success: true },
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -161,6 +161,33 @@ describe('localSystemExecutor.getCommandOutput — filter forwarding', () => {
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves `exitCode` state from getCommandOutput', async () => {
|
||||
const runtime = (localSystemExecutor as any).runtime as {
|
||||
getCommandOutput: (args: any) => Promise<unknown>;
|
||||
};
|
||||
const spy = vi.spyOn(runtime, 'getCommandOutput');
|
||||
|
||||
spy.mockResolvedValueOnce({
|
||||
content: '',
|
||||
state: { exitCode: undefined, newOutput: '', success: true },
|
||||
success: true,
|
||||
});
|
||||
|
||||
const runningResult = await localSystemExecutor.getCommandOutput({ shell_id: 'sh-running' });
|
||||
expect(runningResult.state).toMatchObject({ exitCode: undefined });
|
||||
|
||||
spy.mockResolvedValueOnce({
|
||||
content: '',
|
||||
state: { exitCode: 0, newOutput: 'done', success: true },
|
||||
success: true,
|
||||
});
|
||||
|
||||
const doneResult = await localSystemExecutor.getCommandOutput({ shell_id: 'sh-done' });
|
||||
expect(doneResult.state).toMatchObject({ exitCode: 0 });
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('localSystemExecutor.runCommand — background field normalization', () => {
|
||||
|
||||
@@ -263,9 +263,9 @@ export const LocalSystemManifest: BuiltinToolManifest = {
|
||||
},
|
||||
},
|
||||
{
|
||||
defaultTimeoutMs: 120_000,
|
||||
defaultTimeoutMs: 30_000,
|
||||
description:
|
||||
'Execute a shell command and return its output. Supports both synchronous and background execution with timeout control.',
|
||||
'Start a terminal session to execute a shell command and return console output collected during the wait window. If the command exits during that window, the result includes `exit_code`; if it is still running, the result includes `shell_id` for later output retrieval or termination.',
|
||||
humanIntervention: 'required',
|
||||
name: LocalSystemApiName.runCommand,
|
||||
parameters: {
|
||||
@@ -286,13 +286,9 @@ export const LocalSystemManifest: BuiltinToolManifest = {
|
||||
type: 'object',
|
||||
},
|
||||
run_in_background: {
|
||||
description: 'Set to true to run command in background and return shell_id',
|
||||
type: 'boolean',
|
||||
},
|
||||
timeout: {
|
||||
description:
|
||||
'Timeout in milliseconds for this command. Default 120000ms. Server clamps to [1000, 800000]; raise this for long-running tasks (builds, large searches) instead of letting them hit the default and fail.',
|
||||
type: 'number',
|
||||
'Set to true to return immediately after starting the terminal session. The result will include a `shell_id` for later observation or termination.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['description', 'command'],
|
||||
|
||||
@@ -28,9 +28,9 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
5. **moveFiles**: Moves multiple files or directories. Also handles renames — pass the original directory with the new filename in \`newPath\`.
|
||||
|
||||
**Shell Commands:**
|
||||
6. **runCommand**: Execute shell commands with timeout control. Supports both synchronous and background execution. When providing a description, always use the same language as the user's input.
|
||||
7. **getCommandOutput**: Retrieve output from running background commands. Returns only new output since last check.
|
||||
8. **killCommand**: Terminate a running background shell command by its ID.
|
||||
6. **runCommand**: Start a terminal session to execute shell commands and return console output collected during the wait window. When providing a description, always use the same language as the user's input.
|
||||
7. **getCommandOutput**: Retrieve output from an existing terminal session. Returns only new output since last check.
|
||||
8. **killCommand**: Terminate a running terminal session by its ID.
|
||||
|
||||
**Search & Find:**
|
||||
9. **searchFiles**: Searches for files based on keywords and other criteria using native search. Use this tool to find files if the user is unsure about the exact path.
|
||||
@@ -85,14 +85,17 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
- For executing shell commands: Use 'runCommand'. Provide the following parameters:
|
||||
- 'command': The shell command to execute.
|
||||
- 'description' (Optional but recommended): A clear, concise description of what the command does (5-10 words, in active voice). **IMPORTANT: Always use the same language as the user's input.** If the user speaks Chinese, write the description in Chinese; if English, use English, etc.
|
||||
- 'run_in_background' (Optional): Set to true to run in background and get a shell_id for later checking output.
|
||||
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 800000ms).
|
||||
- 'run_in_background' (Optional): Set to true to return immediately after starting the terminal session. The result includes a 'shell_id' for later observation or termination.
|
||||
The command runs in cmd.exe on Windows or /bin/sh on macOS/Linux.
|
||||
- For retrieving output from background commands: Use 'getCommandOutput'. Provide:
|
||||
- 'shell_id': The ID returned from runCommand when run_in_background was true.
|
||||
- Result semantics:
|
||||
- 'success' indicates whether the tool call itself succeeded.
|
||||
- 'shell_id' identifies the terminal session for later observation/termination.
|
||||
- 'exit_code' is only present after the command has exited. If it is absent, the command is still running.
|
||||
- For retrieving output from terminal sessions: Use 'getCommandOutput'. Provide:
|
||||
- 'shell_id': The ID returned from runCommand.
|
||||
- 'filter' (Optional): A regex pattern to filter output lines.
|
||||
Returns only new output since the last check.
|
||||
- For killing background commands: Use 'killCommand' with 'shell_id'.
|
||||
- For killing running terminal sessions: Use 'killCommand' with 'shell_id'.
|
||||
- For searching content in files: Use 'grepContent'. Provide:
|
||||
- 'pattern': The regex pattern to search for.
|
||||
- 'scope' (Optional): Directory to search in. Defaults to the working directory if omitted.
|
||||
@@ -118,7 +121,6 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
- Be cautious with commands that have side effects (e.g., rm, sudo, format).
|
||||
- Always describe what a command will do before running it, especially for non-trivial operations.
|
||||
- Always provide a clear 'description' parameter in the user's language to help them understand what the command does.
|
||||
- Use appropriate timeouts to prevent commands from running indefinitely.
|
||||
- When editing files:
|
||||
- Always read the file first to verify its current content.
|
||||
- Ensure old_string exactly matches the text to be replaced to avoid unintended changes.
|
||||
|
||||
@@ -17,9 +17,9 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
5. **moveFiles**: Moves multiple files or directories. Also handles renames — pass the original directory with the new filename in \`newPath\`.
|
||||
|
||||
**Shell Commands:**
|
||||
6. **runCommand**: Execute shell commands with timeout control. Supports both synchronous and background execution. When providing a description, always use the same language as the user's input.
|
||||
7. **getCommandOutput**: Retrieve output from running background commands. Returns only new output since last check.
|
||||
8. **killCommand**: Terminate a running background shell command by its ID.
|
||||
6. **runCommand**: Start a terminal session to execute shell commands and return console output collected during the wait window. When providing a description, always use the same language as the user's input.
|
||||
7. **getCommandOutput**: Retrieve output from an existing terminal session. Returns only new output since last check.
|
||||
8. **killCommand**: Terminate a running terminal session by its ID.
|
||||
|
||||
**Search & Find:**
|
||||
9. **searchFiles**: Searches for files based on keywords and other criteria using native search. Use this tool to find files if the user is unsure about the exact path.
|
||||
@@ -74,14 +74,17 @@ You have access to a set of tools to interact with the user's local file system:
|
||||
- For executing shell commands: Use 'runCommand'. Provide the following parameters:
|
||||
- 'command': The shell command to execute.
|
||||
- 'description' (Optional but recommended): A clear, concise description of what the command does (5-10 words, in active voice). **IMPORTANT: Always use the same language as the user's input.** If the user speaks Chinese, write the description in Chinese; if English, use English, etc.
|
||||
- 'run_in_background' (Optional): Set to true to run in background and get a shell_id for later checking output.
|
||||
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 800000ms).
|
||||
- 'run_in_background' (Optional): Set to true to return immediately after starting the terminal session. The result includes a 'shell_id' for later observation or termination.
|
||||
The command runs in cmd.exe on Windows or /bin/sh on macOS/Linux.
|
||||
- For retrieving output from background commands: Use 'getCommandOutput'. Provide:
|
||||
- 'shell_id': The ID returned from runCommand when run_in_background was true.
|
||||
- Result semantics:
|
||||
- 'success' indicates whether the tool call itself succeeded.
|
||||
- 'shell_id' identifies the terminal session for later observation/termination.
|
||||
- 'exit_code' is only present after the command has exited. If it is absent, the command is still running.
|
||||
- For retrieving output from terminal sessions: Use 'getCommandOutput'. Provide:
|
||||
- 'shell_id': The ID returned from runCommand.
|
||||
- 'filter' (Optional): A regex pattern to filter output lines.
|
||||
Returns only new output since the last check.
|
||||
- For killing background commands: Use 'killCommand' with 'shell_id'.
|
||||
- For killing running terminal sessions: Use 'killCommand' with 'shell_id'.
|
||||
- For searching content in files: Use 'grepContent'. Provide:
|
||||
- 'pattern': The regex pattern to search for.
|
||||
- 'scope' (Optional): Directory to search in. Defaults to the working directory if omitted.
|
||||
|
||||
@@ -7,6 +7,7 @@ export const MessageToolIdentifier = 'lobe-message';
|
||||
export const MessagePlatform = {
|
||||
discord: 'discord',
|
||||
feishu: 'feishu',
|
||||
imessage: 'imessage',
|
||||
lark: 'lark',
|
||||
qq: 'qq',
|
||||
slack: 'slack',
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import type { InitDocumentArgs } from '@lobechat/editor-runtime';
|
||||
import type { BuiltinStreamingProps } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { FileText, Hash, ListTree } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import StreamingMarkdown from '@/components/StreamingMarkdown';
|
||||
|
||||
import { AnimatedNumber } from '../../components/AnimatedNumber';
|
||||
|
||||
const MAX_PREVIEW_CHARS = 4000;
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 8px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
header: css`
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
icon: css`
|
||||
color: ${cssVar.colorPrimary};
|
||||
`,
|
||||
meta: css`
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
preview: css`
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
`,
|
||||
title: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
}));
|
||||
|
||||
const extractTitle = (markdown: string) => {
|
||||
const titleLine = markdown
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.startsWith('# ') && line.slice(2).trim().length > 0);
|
||||
|
||||
return titleLine?.slice(2).trim();
|
||||
};
|
||||
|
||||
export const InitPageStreaming = memo<BuiltinStreamingProps<InitDocumentArgs>>(({ args }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const markdown = args?.markdown || '';
|
||||
|
||||
const { chars, lines, preview, title } = useMemo(() => {
|
||||
const preview =
|
||||
markdown.length > MAX_PREVIEW_CHARS
|
||||
? `${markdown.slice(0, MAX_PREVIEW_CHARS)}\n\n...`
|
||||
: markdown;
|
||||
|
||||
return {
|
||||
chars: markdown.length,
|
||||
lines: markdown ? markdown.split('\n').length : 0,
|
||||
preview,
|
||||
title: extractTitle(markdown),
|
||||
};
|
||||
}, [markdown]);
|
||||
|
||||
if (!markdown) return null;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||
<FileText className={styles.icon} size={16} />
|
||||
<Flexbox flex={1} gap={2}>
|
||||
<div className={styles.title}>
|
||||
{title || t('builtins.lobe-page-agent.apiName.initPage.creating')}
|
||||
</div>
|
||||
<Flexbox horizontal align={'center'} className={styles.meta} gap={10}>
|
||||
<Text as={'span'} color={cssVar.colorTextDescription} fontSize={12}>
|
||||
<Icon icon={ListTree} size={12} /> <AnimatedNumber value={lines} />
|
||||
{t('builtins.lobe-page-agent.apiName.initPage.lines')}
|
||||
</Text>
|
||||
<Text as={'span'} color={cssVar.colorTextDescription} fontSize={12}>
|
||||
<Icon icon={Hash} size={12} /> <AnimatedNumber value={chars} />
|
||||
{t('builtins.lobe-page-agent.apiName.initPage.chars')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<div className={styles.preview}>
|
||||
<StreamingMarkdown>{preview}</StreamingMarkdown>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
InitPageStreaming.displayName = 'PageAgentInitPageStreaming';
|
||||
|
||||
export default InitPageStreaming;
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { BuiltinStreaming } from '@lobechat/types';
|
||||
|
||||
import { DocumentApiName } from '../../types';
|
||||
import { InitPageStreaming } from './InitPage';
|
||||
|
||||
/**
|
||||
* Page Agent Streaming Components Registry
|
||||
*
|
||||
* Streaming components are used to render tool calls while arguments
|
||||
* are still being generated, allowing real-time feedback to users.
|
||||
*/
|
||||
export const PageAgentStreamings: Record<string, BuiltinStreaming> = {};
|
||||
export const PageAgentStreamings: Record<string, BuiltinStreaming> = {
|
||||
[DocumentApiName.initPage]: InitPageStreaming as BuiltinStreaming,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AGENT_SKILLS_IDENTIFIER_PREFIX } from '@lobechat/const';
|
||||
import { type BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { type TFunction } from 'i18next';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -12,33 +11,34 @@ import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { ActivateSkillParams, ActivateSkillSource, ActivateSkillState } from '../../../types';
|
||||
|
||||
type SkillLabelKey =
|
||||
| 'builtins.lobe-skills.apiName.activateAgentSkill'
|
||||
| 'builtins.lobe-skills.apiName.activateProjectSkill'
|
||||
| 'builtins.lobe-skills.apiName.activateSkill';
|
||||
|
||||
/**
|
||||
* Resolve the inspector label. State-side `source` is the authority once the
|
||||
* Resolve the inspector label key. State-side `source` is the authority once the
|
||||
* tool result has streamed in; while args are still streaming we only have the
|
||||
* raw `name` to go on, so detect agent skills via the identifier prefix as a
|
||||
* best-effort fallback. Project skills can't be inferred from the bare name
|
||||
* (no prefix), so they show "Activate Skill" until the result lands.
|
||||
*
|
||||
* `t` is invoked with literal keys per branch so i18next's typed-key map can
|
||||
* still validate the call site.
|
||||
*/
|
||||
const resolveLabel = (
|
||||
t: TFunction<'plugin'>,
|
||||
const resolveLabelKey = (
|
||||
source: ActivateSkillSource | undefined,
|
||||
rawName: string | undefined,
|
||||
): string => {
|
||||
): SkillLabelKey => {
|
||||
const effective: ActivateSkillSource =
|
||||
source ?? (rawName?.startsWith(AGENT_SKILLS_IDENTIFIER_PREFIX) ? 'agent' : 'builtin');
|
||||
|
||||
switch (effective) {
|
||||
case 'agent': {
|
||||
return t('builtins.lobe-skills.apiName.activateAgentSkill');
|
||||
return 'builtins.lobe-skills.apiName.activateAgentSkill';
|
||||
}
|
||||
case 'project': {
|
||||
return t('builtins.lobe-skills.apiName.activateProjectSkill');
|
||||
return 'builtins.lobe-skills.apiName.activateProjectSkill';
|
||||
}
|
||||
default: {
|
||||
return t('builtins.lobe-skills.apiName.activateSkill');
|
||||
return 'builtins.lobe-skills.apiName.activateSkill';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -84,7 +84,7 @@ export const RunSkillInspector = memo<
|
||||
|
||||
const name = args?.name || partialArgs?.name;
|
||||
const displayName = pluginState?.title || pluginState?.name || name;
|
||||
const label = resolveLabel(t, pluginState?.source, name);
|
||||
const label = t(resolveLabelKey(pluginState?.source, name));
|
||||
|
||||
if (isArgumentsStreaming) {
|
||||
if (!displayName)
|
||||
|
||||
@@ -66,6 +66,7 @@ import type { BuiltinInspector } from '@lobechat/types';
|
||||
import { CodexInspectors } from './codex';
|
||||
import { GithubIdentifier, GithubInspectors } from './github';
|
||||
import { LinearIdentifier, LinearInspectors } from './linear';
|
||||
import { TwitterIdentifier, TwitterInspectors } from './twitter';
|
||||
|
||||
/**
|
||||
* Builtin tools inspector registry
|
||||
@@ -113,6 +114,7 @@ const BuiltinToolInspectors: Record<string, Record<string, BuiltinInspector>> =
|
||||
},
|
||||
[GithubIdentifier]: GithubInspectors,
|
||||
[LinearIdentifier]: LinearInspectors,
|
||||
[TwitterIdentifier]: TwitterInspectors,
|
||||
};
|
||||
|
||||
export interface BuiltinInspectorRegistryEntry {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@lobechat/builtin-tool-local-system/client';
|
||||
import { MemoryManifest, MemoryStreamings } from '@lobechat/builtin-tool-memory/client';
|
||||
import { MessageManifest, MessageStreamings } from '@lobechat/builtin-tool-message/client';
|
||||
import { PageAgentManifest, PageAgentStreamings } from '@lobechat/builtin-tool-page-agent/client';
|
||||
import { type BuiltinStreaming } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
@@ -64,6 +65,7 @@ const BuiltinToolStreamings: Record<string, Record<string, BuiltinStreaming>> =
|
||||
[LocalSystemManifest.identifier]: LocalSystemStreamings as Record<string, BuiltinStreaming>,
|
||||
[MemoryManifest.identifier]: MemoryStreamings as Record<string, BuiltinStreaming>,
|
||||
[MessageManifest.identifier]: MessageStreamings as Record<string, BuiltinStreaming>,
|
||||
[PageAgentManifest.identifier]: PageAgentStreamings as Record<string, BuiltinStreaming>,
|
||||
};
|
||||
|
||||
export interface BuiltinStreamingRegistryEntry {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user