mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 22:26:05 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff61f4b3fa | |||
| 192111840c | |||
| 837a3daa58 | |||
| 5f6f053039 | |||
| 775be47513 | |||
| 2f265a9307 |
@@ -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,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,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,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;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ prd
|
||||
# Recordings
|
||||
.records/
|
||||
|
||||
# Agent-gateway probe captures (local debugging dumps)
|
||||
.agent-gateway/
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
temp/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -411,16 +411,12 @@
|
||||
"rag.userQuery.actions.regenerate": "Regenerate Query",
|
||||
"regenerate": "Regenerate",
|
||||
"roleAndArchive": "Agent Profile & History",
|
||||
"runtimeEnv.device.empty": "No devices available. Connect to gateway from the desktop app first.",
|
||||
"runtimeEnv.mode.cloud": "Cloud Sandbox",
|
||||
"runtimeEnv.mode.cloudDesc": "Run in a secure cloud sandbox",
|
||||
"runtimeEnv.mode.local": "Local",
|
||||
"runtimeEnv.mode.localDesc": "Access local files and commands",
|
||||
"runtimeEnv.mode.none": "Off",
|
||||
"runtimeEnv.mode.noneDesc": "Disable runtime environment",
|
||||
"runtimeEnv.mode.sandbox": "Sandbox",
|
||||
"runtimeEnv.mode.sandboxDesc": "Run in an isolated cloud sandbox",
|
||||
"runtimeEnv.section.device": "Device",
|
||||
"runtimeEnv.selectMode": "Select Runtime Environment",
|
||||
"runtimeEnv.title": "Runtime Environment",
|
||||
"search.grounding.imageSearchQueries": "Image Search Keywords",
|
||||
|
||||
@@ -411,16 +411,12 @@
|
||||
"rag.userQuery.actions.regenerate": "重新生成 Query",
|
||||
"regenerate": "重新生成",
|
||||
"roleAndArchive": "助理档案与记录",
|
||||
"runtimeEnv.device.empty": "暂无可用设备,请先在桌面端连接到网关",
|
||||
"runtimeEnv.mode.cloud": "云端沙箱",
|
||||
"runtimeEnv.mode.cloudDesc": "在安全的云端沙箱中运行",
|
||||
"runtimeEnv.mode.local": "本地",
|
||||
"runtimeEnv.mode.localDesc": "访问本地文件和命令",
|
||||
"runtimeEnv.mode.none": "关闭",
|
||||
"runtimeEnv.mode.noneDesc": "禁用运行时环境",
|
||||
"runtimeEnv.mode.sandbox": "沙箱",
|
||||
"runtimeEnv.mode.sandboxDesc": "在隔离的云端沙箱中运行",
|
||||
"runtimeEnv.section.device": "设备",
|
||||
"runtimeEnv.selectMode": "选择运行环境",
|
||||
"runtimeEnv.title": "运行环境",
|
||||
"search.grounding.imageSearchQueries": "图片搜索关键词",
|
||||
|
||||
@@ -34,6 +34,9 @@ export const DEFAULT_AGENT_CHAT_CONFIG: LobeAgentChatConfig = {
|
||||
reasoningBudgetToken: 1024,
|
||||
searchFCModel: DEFAULT_AGENT_SEARCH_FC_MODEL,
|
||||
searchMode: 'auto',
|
||||
selfIteration: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_AGENT_CONFIG: LobeAgentConfig = {
|
||||
|
||||
@@ -9,12 +9,11 @@ export type AgentMode = 'auto' | 'plan' | 'ask' | 'implement';
|
||||
|
||||
/**
|
||||
* Runtime environment mode
|
||||
* - local: Run on a specific device (desktop only, requires deviceId)
|
||||
* - sandbox: Run in isolated cloud sandbox
|
||||
* - cloud: @deprecated Use 'sandbox' instead, kept for backward compatibility
|
||||
* - local: Access local files and commands (desktop only)
|
||||
* - cloud: Run in cloud sandbox
|
||||
* - none: No runtime environment
|
||||
*/
|
||||
export type RuntimeEnvMode = 'cloud' | 'local' | 'none' | 'sandbox';
|
||||
export type RuntimeEnvMode = 'cloud' | 'local' | 'none';
|
||||
|
||||
export type RuntimePlatform = 'desktop' | 'web';
|
||||
|
||||
@@ -22,11 +21,6 @@ export type RuntimePlatform = 'desktop' | 'web';
|
||||
* Runtime environment configuration
|
||||
*/
|
||||
export interface RuntimeEnvConfig {
|
||||
/**
|
||||
* Device ID when runtimeMode is 'local' (desktop only).
|
||||
* Identifies which bound device to run on.
|
||||
*/
|
||||
deviceId?: string;
|
||||
/**
|
||||
* Runtime environment mode per platform
|
||||
*/
|
||||
|
||||
@@ -170,10 +170,9 @@ export interface LobeAgentChatConfig extends AgentMemoryChatConfig, AgentSelfIte
|
||||
/**
|
||||
* Zod schema for RuntimeEnvConfig
|
||||
*/
|
||||
const runtimeEnvModeEnum = z.enum(['local', 'cloud', 'none', 'sandbox']);
|
||||
const runtimeEnvModeEnum = z.enum(['local', 'cloud', 'none']);
|
||||
|
||||
export const RuntimeEnvConfigSchema = z.object({
|
||||
deviceId: z.string().optional(),
|
||||
runtimeMode: z.record(z.string(), runtimeEnvModeEnum).optional(),
|
||||
workingDirectory: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
|
||||
|
||||
import { type Store } from './action';
|
||||
import { selectors } from './selectors';
|
||||
|
||||
describe('AgentSetting selectors', () => {
|
||||
describe('currentChatConfig', () => {
|
||||
it('should include disabled self iteration by default', () => {
|
||||
const state = {
|
||||
config: DEFAULT_AGENT_CONFIG,
|
||||
} as Store;
|
||||
|
||||
expect(selectors.currentChatConfig(state).selfIteration).toEqual({ enabled: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device';
|
||||
import { Flexbox, Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { LaptopIcon, MonitorIcon, ServerIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
deviceName: css`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
deviceOption: css`
|
||||
cursor: pointer;
|
||||
|
||||
width: 100%;
|
||||
padding-block: 8px;
|
||||
padding-inline: 8px;
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
deviceOptionActive: css`
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
deviceOptionDesc: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
deviceOptionIcon: css`
|
||||
flex-shrink: 0;
|
||||
border: 1px solid ${cssVar.colorFillTertiary};
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
background: ${cssVar.colorBgElevated};
|
||||
`,
|
||||
sectionTitle: css`
|
||||
padding-block: 6px 2px;
|
||||
padding-inline: 8px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`,
|
||||
}));
|
||||
|
||||
const PLATFORM_ICONS: Record<string, typeof LaptopIcon> = {
|
||||
darwin: LaptopIcon,
|
||||
linux: MonitorIcon,
|
||||
win32: MonitorIcon,
|
||||
};
|
||||
|
||||
interface DeviceSelectorProps {
|
||||
activeDeviceId?: string;
|
||||
devices: DeviceAttachment[];
|
||||
onSelect: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
export const DeviceSelector = memo<DeviceSelectorProps>(
|
||||
({ activeDeviceId, devices, onSelect }) => {
|
||||
return (
|
||||
<>
|
||||
{devices.map((device) => {
|
||||
const IconComp = PLATFORM_ICONS[device.platform] || ServerIcon;
|
||||
const isActive = activeDeviceId === device.deviceId;
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'flex-start'}
|
||||
className={cx(styles.deviceOption, isActive && styles.deviceOptionActive)}
|
||||
gap={12}
|
||||
key={device.deviceId}
|
||||
onClick={() => onSelect(device.deviceId)}
|
||||
>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={styles.deviceOptionIcon}
|
||||
height={32}
|
||||
justify={'center'}
|
||||
width={32}
|
||||
>
|
||||
<Icon icon={IconComp} size={16} />
|
||||
</Flexbox>
|
||||
<Flexbox flex={1}>
|
||||
<div className={styles.deviceName}>{device.hostname}</div>
|
||||
<div className={styles.deviceOptionDesc}>{device.platform}</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DeviceSelector.displayName = 'DeviceSelector';
|
||||
|
||||
/** Section header for device/sandbox/none groups */
|
||||
export const SectionHeader = memo<{ label: string }>(({ label }) => (
|
||||
<div className={styles.sectionTitle}>{label}</div>
|
||||
));
|
||||
|
||||
SectionHeader.displayName = 'SectionHeader';
|
||||
@@ -4,7 +4,6 @@ import { Github } from '@lobehub/icons';
|
||||
import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import {
|
||||
BoxIcon,
|
||||
ChevronDownIcon,
|
||||
CloudIcon,
|
||||
FolderIcon,
|
||||
@@ -13,10 +12,9 @@ import {
|
||||
MonitorOffIcon,
|
||||
SquircleDashed,
|
||||
} from 'lucide-react';
|
||||
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { memo, type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { deviceService } from '@/services/device';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
@@ -28,7 +26,6 @@ import { useUpdateAgentConfig } from '../hooks/useUpdateAgentConfig';
|
||||
import { useChatInputStore } from '../store';
|
||||
import ApprovalMode from './ApprovalMode';
|
||||
import CloudRepoSwitcher from './CloudRepoSwitcher';
|
||||
import { DeviceSelector, SectionHeader } from './DeviceSelector';
|
||||
import GitStatus from './GitStatus';
|
||||
import ModeSelector from './ModeSelector';
|
||||
import { useRepoType } from './useRepoType';
|
||||
@@ -38,7 +35,6 @@ const MODE_ICONS: Record<RuntimeEnvMode, typeof LaptopIcon> = {
|
||||
cloud: CloudIcon,
|
||||
local: LaptopIcon,
|
||||
none: MonitorOffIcon,
|
||||
sandbox: BoxIcon,
|
||||
};
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
@@ -67,11 +63,6 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
divider: css`
|
||||
height: 1px;
|
||||
margin-block: 4px;
|
||||
background: ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
modeDesc: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
@@ -116,21 +107,16 @@ const RuntimeConfig = memo(() => {
|
||||
const { updateAgentChatConfig } = useUpdateAgentConfig();
|
||||
const [dirPopoverOpen, setDirPopoverOpen] = useState(false);
|
||||
const [modePopoverOpen, setModePopoverOpen] = useState(false);
|
||||
const [devices, setDevices] = useState<Awaited<ReturnType<typeof deviceService.listDevices>>>([]);
|
||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||
const showContextWindow = useChatInputStore((s) =>
|
||||
s.rightActions.flat().includes('contextWindow'),
|
||||
);
|
||||
|
||||
const [isLoading, runtimeMode, isHeterogeneous, enableAgentMode, deviceId] = useAgentStore(
|
||||
(s) => [
|
||||
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
|
||||
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
|
||||
agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false,
|
||||
agentByIdSelectors.getAgentEnableModeById(agentId)(s),
|
||||
chatConfigByIdSelectors.getDeviceIdById(agentId)(s),
|
||||
],
|
||||
);
|
||||
const [isLoading, runtimeMode, isHeterogeneous, enableAgentMode] = useAgentStore((s) => [
|
||||
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
|
||||
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
|
||||
agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false,
|
||||
agentByIdSelectors.getAgentEnableModeById(agentId)(s),
|
||||
]);
|
||||
|
||||
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
const agentWorkingDirectory = useAgentStore((s) =>
|
||||
@@ -140,17 +126,6 @@ const RuntimeConfig = memo(() => {
|
||||
|
||||
const repoType = useRepoType(effectiveWorkingDirectory);
|
||||
|
||||
// Fetch device list when popover opens (desktop only)
|
||||
useEffect(() => {
|
||||
if (modePopoverOpen && isDesktop) {
|
||||
setDevicesLoading(true);
|
||||
deviceService.listDevices().then((list) => {
|
||||
setDevices(list);
|
||||
setDevicesLoading(false);
|
||||
});
|
||||
}
|
||||
}, [modePopoverOpen]);
|
||||
|
||||
const dirIconNode = useMemo((): ReactNode => {
|
||||
if (!effectiveWorkingDirectory) return <Icon icon={SquircleDashed} size={14} />;
|
||||
if (repoType === 'github') return <Github size={14} />;
|
||||
@@ -159,43 +134,18 @@ const RuntimeConfig = memo(() => {
|
||||
}, [effectiveWorkingDirectory, repoType]);
|
||||
|
||||
const switchMode = useCallback(
|
||||
async (mode: RuntimeEnvMode, opts?: { deviceId?: string }) => {
|
||||
if (mode === runtimeMode && opts?.deviceId === deviceId) return;
|
||||
async (mode: RuntimeEnvMode) => {
|
||||
if (mode === runtimeMode) return;
|
||||
|
||||
const platform = isDesktop ? 'desktop' : 'web';
|
||||
|
||||
await updateAgentChatConfig({
|
||||
runtimeEnv: {
|
||||
deviceId: opts?.deviceId,
|
||||
runtimeMode: { [platform]: mode },
|
||||
},
|
||||
runtimeEnv: { runtimeMode: { [platform]: mode } },
|
||||
});
|
||||
},
|
||||
[runtimeMode, deviceId, updateAgentChatConfig],
|
||||
[runtimeMode, updateAgentChatConfig],
|
||||
);
|
||||
|
||||
// Compute the display label for the mode button
|
||||
const activeDevice = useMemo(
|
||||
() => (deviceId ? devices.find((d) => d.deviceId === deviceId) : undefined),
|
||||
[deviceId, devices],
|
||||
);
|
||||
|
||||
const ModeIcon = MODE_ICONS[runtimeMode] || LaptopIcon;
|
||||
|
||||
const modeLabel = useMemo(() => {
|
||||
// When running on a specific device, show device hostname
|
||||
if (runtimeMode === 'local' && activeDevice) {
|
||||
return activeDevice.hostname;
|
||||
}
|
||||
return t(`runtimeEnv.mode.${runtimeMode}`);
|
||||
}, [runtimeMode, activeDevice, t]);
|
||||
|
||||
const displayName = effectiveWorkingDirectory
|
||||
? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory
|
||||
: tPlugin('localSystem.workingDirectory.notSet');
|
||||
|
||||
const hasDevices = devices.length > 0;
|
||||
|
||||
// Skeleton placeholder to prevent layout jump during loading
|
||||
if (!agentId || isLoading) {
|
||||
return (
|
||||
@@ -206,93 +156,66 @@ const RuntimeConfig = memo(() => {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Popover Content ───
|
||||
const ModeIcon = MODE_ICONS[runtimeMode];
|
||||
const modeLabel = t(`runtimeEnv.mode.${runtimeMode}`);
|
||||
|
||||
const displayName = effectiveWorkingDirectory
|
||||
? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory
|
||||
: tPlugin('localSystem.workingDirectory.notSet');
|
||||
|
||||
const modes: { desc: string; icon: typeof LaptopIcon; label: string; mode: RuntimeEnvMode }[] = [
|
||||
// Local mode is desktop-only
|
||||
...(isDesktop
|
||||
? [
|
||||
{
|
||||
desc: t('runtimeEnv.mode.localDesc'),
|
||||
icon: LaptopIcon,
|
||||
label: t('runtimeEnv.mode.local'),
|
||||
mode: 'local' as RuntimeEnvMode,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
desc: t('runtimeEnv.mode.cloudDesc'),
|
||||
icon: CloudIcon,
|
||||
label: t('runtimeEnv.mode.cloud'),
|
||||
mode: 'cloud',
|
||||
},
|
||||
{
|
||||
desc: t('runtimeEnv.mode.noneDesc'),
|
||||
icon: MonitorOffIcon,
|
||||
label: t('runtimeEnv.mode.none'),
|
||||
mode: 'none',
|
||||
},
|
||||
];
|
||||
|
||||
const modeContent = (
|
||||
<Flexbox gap={4} style={{ minWidth: 280 }}>
|
||||
{/* ── Device section (desktop only) ── */}
|
||||
{isDesktop && (
|
||||
<>
|
||||
<SectionHeader label={t('runtimeEnv.section.device')} />
|
||||
{devicesLoading ? (
|
||||
<Flexbox paddingBlock={12} paddingInline={8}>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size="small"
|
||||
style={{ height: 16, marginBottom: 4, width: '60%' }}
|
||||
/>
|
||||
<Skeleton.Button active size="small" style={{ height: 12, width: '40%' }} />
|
||||
</Flexbox>
|
||||
) : hasDevices ? (
|
||||
<DeviceSelector
|
||||
activeDeviceId={deviceId}
|
||||
devices={devices}
|
||||
onSelect={(id) => switchMode('local', { deviceId: id })}
|
||||
/>
|
||||
) : (
|
||||
<Flexbox
|
||||
className={styles.modeOptionDesc}
|
||||
paddingBlock={8}
|
||||
paddingInline={8}
|
||||
>
|
||||
{t('runtimeEnv.device.empty')}
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
<div className={styles.divider} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Sandbox ── */}
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'flex-start'}
|
||||
gap={12}
|
||||
className={cx(
|
||||
styles.modeOption,
|
||||
(runtimeMode === 'sandbox' || runtimeMode === 'cloud') && styles.modeOptionActive,
|
||||
)}
|
||||
onClick={() => switchMode('sandbox')}
|
||||
>
|
||||
{modes.map(({ mode, icon, label, desc }) => (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={styles.modeOptionIcon}
|
||||
flex={'none'}
|
||||
height={32}
|
||||
justify={'center'}
|
||||
width={32}
|
||||
horizontal
|
||||
align={'flex-start'}
|
||||
className={cx(styles.modeOption, runtimeMode === mode && styles.modeOptionActive)}
|
||||
gap={12}
|
||||
key={mode}
|
||||
onClick={() => switchMode(mode)}
|
||||
>
|
||||
<Icon icon={BoxIcon} />
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={styles.modeOptionIcon}
|
||||
flex={'none'}
|
||||
height={32}
|
||||
justify={'center'}
|
||||
width={32}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
</Flexbox>
|
||||
<Flexbox flex={1}>
|
||||
<div className={styles.modeOptionTitle}>{label}</div>
|
||||
<div className={styles.modeOptionDesc}>{desc}</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<Flexbox flex={1}>
|
||||
<div className={styles.modeOptionTitle}>{t('runtimeEnv.mode.sandbox')}</div>
|
||||
<div className={styles.modeOptionDesc}>{t('runtimeEnv.mode.sandboxDesc')}</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{/* ── Disabled ── */}
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'flex-start'}
|
||||
className={cx(styles.modeOption, runtimeMode === 'none' && styles.modeOptionActive)}
|
||||
gap={12}
|
||||
onClick={() => switchMode('none')}
|
||||
>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={styles.modeOptionIcon}
|
||||
flex={'none'}
|
||||
height={32}
|
||||
justify={'center'}
|
||||
width={32}
|
||||
>
|
||||
<Icon icon={MonitorOffIcon} />
|
||||
</Flexbox>
|
||||
<Flexbox flex={1}>
|
||||
<div className={styles.modeOptionTitle}>{t('runtimeEnv.mode.none')}</div>
|
||||
<div className={styles.modeOptionDesc}>{t('runtimeEnv.mode.noneDesc')}</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useFetchAgentDocuments } from '@/hooks/useFetchAgentDocuments';
|
||||
import { useFetchTopicMemories } from '@/hooks/useFetchMemoryForTopic';
|
||||
import { useFetchNotebookDocuments } from '@/hooks/useFetchNotebookDocuments';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { operationSelectors } from '@/store/chat/selectors';
|
||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { settingsSelectors } from '@/store/user/selectors';
|
||||
@@ -84,8 +85,14 @@ const ChatList = memo<ChatListProps>(
|
||||
s.useFetchMessages,
|
||||
]);
|
||||
const activeAgentId = useChatStore((s) => s.activeAgentId);
|
||||
// Suppress SWR focus revalidate while the current topic is streaming —
|
||||
// the server-pushed UIChatMessage[] snapshot at step boundaries is the
|
||||
// source of truth during that window. A focus refetch could hit DB
|
||||
// mid-fan-out and clobber the in-memory streamed state with a stale
|
||||
// assistant placeholder.
|
||||
const isStreaming = useChatStore(operationSelectors.isAgentRuntimeRunningByContext(context));
|
||||
const { enableAgentSelfIteration } = useServerConfigStore(featureFlagsSelectors);
|
||||
useFetchMessages(context, skipFetch);
|
||||
useFetchMessages(context, { revalidateOnFocus: !isStreaming, skipFetch });
|
||||
const displayMessages = useConversationStore(dataSelectors.displayMessages);
|
||||
const displayMessageIds = useConversationStore(dataSelectors.displayMessageIds);
|
||||
const latestMessageId = displayMessageIds.at(-1);
|
||||
|
||||
+19
-1
@@ -9,7 +9,9 @@ import ContentBlocksScroll from './ContentBlocksScroll';
|
||||
import type { RenderableAssistantContentBlock } from './types';
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
Flexbox: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Flexbox: ({ children, gap }: { children?: ReactNode; gap?: number }) => (
|
||||
<div data-gap={gap}>{children}</div>
|
||||
),
|
||||
ScrollArea: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
@@ -62,4 +64,20 @@ describe('ContentBlocksScroll', () => {
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a consistent gap between workflow blocks', () => {
|
||||
const { container } = render(
|
||||
<ContentBlocksScroll
|
||||
assistantId="assistant-1"
|
||||
blocks={[
|
||||
{ content: 'first workflow block', id: 'block-1' },
|
||||
{ content: 'second workflow block', id: 'block-2' },
|
||||
]}
|
||||
scroll={false}
|
||||
variant="workflow"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-gap="8"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ const ContentBlocksScroll = memo<ContentBlocksScrollProps>((props) => {
|
||||
}, [assistantIdFromProps, blocksFromProps, messagesList]);
|
||||
|
||||
const list = (
|
||||
<Flexbox>
|
||||
<Flexbox gap={variant === 'workflow' ? 8 : undefined}>
|
||||
{blocks.map((block) => (
|
||||
<ContentBlock
|
||||
key={block.renderKey ?? block.id}
|
||||
|
||||
@@ -76,7 +76,7 @@ const ClientTaskItem = memo<ClientTaskItemProps>(({ item }) => {
|
||||
);
|
||||
|
||||
// Fetch thread messages (skip when executing - messages come from real-time updates)
|
||||
useFetchMessages(threadContext, isProcessing);
|
||||
useFetchMessages(threadContext, { skipFetch: isProcessing });
|
||||
|
||||
// Get thread messages from store using selector
|
||||
const threadMessages = useChatStore((s) =>
|
||||
|
||||
@@ -59,7 +59,7 @@ export const useClientTaskStats = ({
|
||||
);
|
||||
|
||||
// Fetch thread messages (skip when disabled or no threadId)
|
||||
useFetchMessages(threadContext, !enabled || !threadId);
|
||||
useFetchMessages(threadContext, { skipFetch: !enabled || !threadId });
|
||||
|
||||
// Get thread messages from store using selector
|
||||
const threadMessages = useChatStore((s) =>
|
||||
|
||||
@@ -54,7 +54,7 @@ const ClientTaskDetail = memo<ClientTaskDetailProps>(
|
||||
);
|
||||
|
||||
// Fetch thread messages (skip when executing - messages come from real-time updates)
|
||||
useFetchMessages(threadContext, isExecuting);
|
||||
useFetchMessages(threadContext, { skipFetch: isExecuting });
|
||||
|
||||
// Get thread messages from store using selector
|
||||
const threadMessages = useChatStore((s) =>
|
||||
|
||||
@@ -61,7 +61,7 @@ const ClientTaskItem = memo<ClientTaskItemProps>(({ item }) => {
|
||||
);
|
||||
|
||||
// Fetch thread messages (skip when executing - messages come from real-time updates)
|
||||
useFetchMessages(threadContext, isProcessing);
|
||||
useFetchMessages(threadContext, { skipFetch: isProcessing });
|
||||
|
||||
// Get thread messages from store using selector
|
||||
const threadMessages = useChatStore((s) =>
|
||||
|
||||
@@ -59,7 +59,7 @@ export const useClientTaskStats = ({
|
||||
);
|
||||
|
||||
// Fetch thread messages (skip when disabled or no threadId)
|
||||
useFetchMessages(threadContext, !enabled || !threadId);
|
||||
useFetchMessages(threadContext, { skipFetch: !enabled || !threadId });
|
||||
|
||||
// Get thread messages from store using selector
|
||||
const threadMessages = useChatStore((s) =>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { HETEROGENEOUS_TYPE_LABELS } from '@lobechat/heterogeneous-agents';
|
||||
import {
|
||||
HETEROGENEOUS_TYPE_LABELS,
|
||||
isRemoteHeterogeneousType,
|
||||
} from '@lobechat/heterogeneous-agents';
|
||||
import { type ModelPerformance, type ModelUsage } from '@lobechat/types';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { Center, Flexbox } from '@lobehub/ui';
|
||||
@@ -33,7 +36,14 @@ const Usage = memo<UsageProps>(({ model, usage, performance, provider }) => {
|
||||
|
||||
if (!isDev && onboardingAgentId && conversationAgentId === onboardingAgentId) return null;
|
||||
|
||||
const heteroName = provider ? HETEROGENEOUS_TYPE_LABELS[provider] : undefined;
|
||||
// Only remote platform agents (openclaw, hermes) replace the model name with
|
||||
// the brand label — they don't expose a real model id. Local CLI agents
|
||||
// (claude-code, codex) report their actual model on `turn_metadata` and
|
||||
// should keep showing it.
|
||||
const heteroName =
|
||||
provider && isRemoteHeterogeneousType(provider)
|
||||
? HETEROGENEOUS_TYPE_LABELS[provider]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
|
||||
@@ -6,6 +6,8 @@ import { type StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import { useClientDataSWRWithSync } from '@/libs/swr';
|
||||
import { messageService } from '@/services/message';
|
||||
import { getChatStoreState } from '@/store/chat';
|
||||
import { operationSelectors } from '@/store/chat/selectors';
|
||||
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
|
||||
|
||||
import { type Store as ConversationStore } from '../../action';
|
||||
@@ -67,14 +69,17 @@ export interface DataAction {
|
||||
switchMessageBranch: (messageId: string, branchIndex: number) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetch messages for this conversation using SWR
|
||||
* Fetch messages for this conversation using SWR.
|
||||
*
|
||||
* @param context - Conversation context with sessionId and topicId
|
||||
* @param skipFetch - When true, SWR key is null and no fetch occurs
|
||||
* @param options.skipFetch - When true, SWR key is null and no fetch occurs
|
||||
* @param options.revalidateOnFocus - Override SWR's default focus revalidate.
|
||||
* Pass `false` while a streaming flow owns the in-memory message state so
|
||||
* a focus refetch doesn't clobber it with a stale DB snapshot.
|
||||
*/
|
||||
useFetchMessages: (
|
||||
context: ConversationContext,
|
||||
skipFetch?: boolean,
|
||||
options?: { revalidateOnFocus?: boolean; skipFetch?: boolean },
|
||||
) => SWRResponse<UIChatMessage[]>;
|
||||
}
|
||||
|
||||
@@ -184,7 +189,8 @@ export const dataSlice: StateCreator<
|
||||
await state.updateMessageMetadata(message.parentId, { activeBranchIndex: branchIndex });
|
||||
},
|
||||
|
||||
useFetchMessages: (context, skipFetch) => {
|
||||
useFetchMessages: (context, options) => {
|
||||
const { skipFetch, revalidateOnFocus } = options ?? {};
|
||||
// When skipFetch is true, SWR key is null - no fetch occurs
|
||||
// This is used when external messages are provided (e.g., creating new thread)
|
||||
// Also skip fetch when topicId is null (new conversation state) - there's no server data,
|
||||
@@ -206,10 +212,27 @@ export const dataSlice: StateCreator<
|
||||
|
||||
() => messageService.getMessages(context),
|
||||
{
|
||||
...(revalidateOnFocus !== undefined && { revalidateOnFocus }),
|
||||
onData: (data) => {
|
||||
if (!data) return;
|
||||
if (!context.topicId) return;
|
||||
|
||||
// Defense-in-depth gate (LOBE-9501): drop any SWR onData while the
|
||||
// topic is streaming. DB fan-out for chunk writes is async and lags
|
||||
// the WS push by anywhere from 100ms to several seconds; an SWR
|
||||
// refetch that lands inside that window returns the assistant row
|
||||
// as the LOADING_FLAT placeholder (cLen=3) and would collapse the
|
||||
// in-memory streamed content. SWR's own cache still receives the
|
||||
// value, so once streaming ends a normal revalidate writes through.
|
||||
//
|
||||
// This is the catch-all backstop sitting BELOW the SoT consumption
|
||||
// in gatewayEventHandler — `mergeFetchedMessagesWithLocalState`'s
|
||||
// updatedAt tie-breaker handles most cases on its own, but the
|
||||
// updatedAt comparison degenerates when server's pushed snapshot
|
||||
// carries a DB updatedAt equal to a later stale fetch's row.
|
||||
if (operationSelectors.isAgentRuntimeRunningByContext(context)(getChatStoreState()))
|
||||
return;
|
||||
|
||||
const prevDbMessages = get().dbMessages;
|
||||
const mergedMessages = mergeFetchedMessagesWithLocalState(data, prevDbMessages);
|
||||
const storeContextKey = messageMapKey(get().context);
|
||||
|
||||
@@ -64,7 +64,7 @@ const ShareDataProvider = memo<PropsWithChildren<ShareDataProviderProps>>(
|
||||
}, [activeAgentId, activeGroupId, activeThreadId, activeTopicId, context]);
|
||||
|
||||
const shouldSkipFetch = !resolvedContext.agentId || !resolvedContext.topicId;
|
||||
const { isLoading } = useFetchMessages(resolvedContext, shouldSkipFetch);
|
||||
const { isLoading } = useFetchMessages(resolvedContext, { skipFetch: shouldSkipFetch });
|
||||
|
||||
const messageKey = useMemo(() => {
|
||||
if (!resolvedContext.agentId) return undefined;
|
||||
|
||||
@@ -18,6 +18,8 @@ import { LOBE_THEME_NEUTRAL_COLOR, LOBE_THEME_PRIMARY_COLOR } from '@/const/them
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { useIsDark } from '@/hooks/useIsDark';
|
||||
import { getUILocaleAndResources } from '@/libs/getUILocaleAndResources';
|
||||
import type { UILocaleResources } from '@/libs/getUILocaleAndResources.utils';
|
||||
import { resolveUILocale } from '@/libs/getUILocaleAndResources.utils';
|
||||
import Image from '@/libs/next/Image';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
@@ -115,20 +117,22 @@ const AppTheme = memo<AppThemeProps>(
|
||||
[messageTop],
|
||||
);
|
||||
|
||||
const [uiResources, setUIResources] = useState<any>(null);
|
||||
const uiLocale = useMemo(() => {
|
||||
if (language.startsWith('zh')) return 'zh-CN';
|
||||
if (language.startsWith('en')) return 'en-US';
|
||||
return 'en-US';
|
||||
}, [language]);
|
||||
const [uiResources, setUIResources] = useState<UILocaleResources>();
|
||||
const [uiLocale, setUILocale] = useState(() => resolveUILocale(language).uiLocale);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
getUILocaleAndResources(language).then(({ resources }) => {
|
||||
if (mounted) {
|
||||
setUIResources(resources);
|
||||
}
|
||||
});
|
||||
setUILocale(resolveUILocale(language).uiLocale);
|
||||
getUILocaleAndResources(language)
|
||||
.then(({ locale, resources }) => {
|
||||
if (mounted) {
|
||||
setUILocale(locale);
|
||||
setUIResources(resources);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load UI locale resources:', error);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import { en, zhCn } from '@lobehub/ui/es/i18n/resources/index';
|
||||
|
||||
import { normalizeLocale } from '@/locales/resources';
|
||||
|
||||
type UILocaleResources = Record<string, Record<string, string>>;
|
||||
import type { UILocaleResourceInput, UILocaleResources } from './getUILocaleAndResources.utils';
|
||||
import {
|
||||
mergeUILocaleResources,
|
||||
normalizeUILocaleResources,
|
||||
resolveUILocale,
|
||||
} from './getUILocaleAndResources.utils';
|
||||
|
||||
// eager: true — UI locale fully inlined at build time
|
||||
const uiLocaleModules = import.meta.glob<{ default: UILocaleResources }>('/locales/*/ui.json', {
|
||||
const uiLocaleModules = import.meta.glob<{ default: UILocaleResourceInput }>('/locales/*/ui.json', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const getUILocale = (locale: string): string => {
|
||||
if (locale.startsWith('zh')) return 'zh-CN';
|
||||
if (locale.startsWith('en')) return 'en-US';
|
||||
return locale;
|
||||
};
|
||||
|
||||
const loadBusinessResources = (locale: string): UILocaleResources | null => {
|
||||
const key = `/locales/${locale}/ui.json`;
|
||||
const mod = uiLocaleModules[key];
|
||||
return mod ? (mod.default as UILocaleResources) : null;
|
||||
const resources = mod?.default as UILocaleResourceInput | null | undefined;
|
||||
|
||||
return resources ? normalizeUILocaleResources(resources) : null;
|
||||
};
|
||||
|
||||
const loadLobeUIBuiltinResources = (locale: string): UILocaleResources | null => {
|
||||
@@ -29,15 +28,14 @@ const loadLobeUIBuiltinResources = (locale: string): UILocaleResources | null =>
|
||||
export const getUILocaleAndResources = async (
|
||||
locale: string | 'auto',
|
||||
): Promise<{ locale: string; resources: UILocaleResources }> => {
|
||||
const effectiveLocale = locale === 'auto' ? 'en-US' : locale;
|
||||
const normalizedLocale = normalizeLocale(effectiveLocale);
|
||||
const uiLocale = getUILocale(normalizedLocale);
|
||||
const { normalizedLocale, uiLocale } = resolveUILocale(locale);
|
||||
|
||||
const resources =
|
||||
loadBusinessResources(normalizedLocale) ??
|
||||
loadLobeUIBuiltinResources(normalizedLocale) ??
|
||||
loadBusinessResources('en-US') ??
|
||||
loadLobeUIBuiltinResources('en-US');
|
||||
mergeUILocaleResources(
|
||||
loadLobeUIBuiltinResources(normalizedLocale),
|
||||
loadBusinessResources(normalizedLocale),
|
||||
) ??
|
||||
mergeUILocaleResources(loadLobeUIBuiltinResources('en-US'), loadBusinessResources('en-US'));
|
||||
|
||||
if (!resources)
|
||||
throw new Error(
|
||||
|
||||
@@ -2,6 +2,11 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getUILocaleAndResources } from './getUILocaleAndResources';
|
||||
|
||||
const translateFromUILocaleResources = (
|
||||
resources: Record<string, Record<string, string>>,
|
||||
key: string,
|
||||
) => Object.assign({}, ...Object.values(resources))[key];
|
||||
|
||||
describe('getUILocaleAndResources', () => {
|
||||
it('should return zh-CN locale and zhCn resources for zh-CN', async () => {
|
||||
const result = await getUILocaleAndResources('zh-CN');
|
||||
@@ -9,6 +14,30 @@ describe('getUILocaleAndResources', () => {
|
||||
expect(result.resources).toBeDefined();
|
||||
});
|
||||
|
||||
it('should normalize business ui.json into a @lobehub/ui consumable resource map', async () => {
|
||||
const result = await getUILocaleAndResources('zh-CN');
|
||||
|
||||
expect(translateFromUILocaleResources(result.resources, 'form.submit')).toBe('提交');
|
||||
});
|
||||
|
||||
it('should merge built-in resources with partial business ui.json resources', async () => {
|
||||
const result = await getUILocaleAndResources('zh-CN');
|
||||
|
||||
expect(translateFromUILocaleResources(result.resources, 'image.copy')).toBe('复制');
|
||||
expect(translateFromUILocaleResources(result.resources, 'hotkey.clear')).toBe('清除绑定');
|
||||
expect(translateFromUILocaleResources(result.resources, 'form.submit')).toBe('提交');
|
||||
});
|
||||
|
||||
it('should merge en built-in fallback resources for non-en/zh partial business ui.json resources', async () => {
|
||||
const result = await getUILocaleAndResources('de-DE');
|
||||
|
||||
expect(result.locale).toBe('de-DE');
|
||||
expect(translateFromUILocaleResources(result.resources, 'image.copy')).toBe('Copy');
|
||||
expect(translateFromUILocaleResources(result.resources, 'hotkey.clear')).toBe('Clear binding');
|
||||
expect(translateFromUILocaleResources(result.resources, 'common.empty')).toBe('(empty)');
|
||||
expect(translateFromUILocaleResources(result.resources, 'form.submit')).toBe('Absenden');
|
||||
});
|
||||
|
||||
it('should return zh-CN locale and zhCn resources for zh-TW', async () => {
|
||||
const result = await getUILocaleAndResources('zh-TW');
|
||||
expect(result.locale).toBe('zh-CN');
|
||||
@@ -27,10 +56,18 @@ describe('getUILocaleAndResources', () => {
|
||||
expect(result.resources).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return en-US locale and en resources for auto', async () => {
|
||||
const result = await getUILocaleAndResources('auto');
|
||||
expect(result.locale).toBe('en-US');
|
||||
expect(result.resources).toBeDefined();
|
||||
it('should resolve auto from the current document language', async () => {
|
||||
const previousLang = document.documentElement.lang;
|
||||
document.documentElement.lang = 'zh-CN';
|
||||
|
||||
try {
|
||||
const result = await getUILocaleAndResources('auto');
|
||||
|
||||
expect(result.locale).toBe('zh-CN');
|
||||
expect(translateFromUILocaleResources(result.resources, 'form.submit')).toBe('提交');
|
||||
} finally {
|
||||
document.documentElement.lang = previousLang;
|
||||
}
|
||||
});
|
||||
|
||||
it('should return ar locale and custom resources for ar', async () => {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { normalizeLocale } from '@/locales/resources';
|
||||
|
||||
type UILocaleResources = Record<string, Record<string, string>>;
|
||||
|
||||
const getUILocale = (locale: string): string => {
|
||||
if (locale.startsWith('zh')) return 'zh-CN';
|
||||
if (locale.startsWith('en')) return 'en-US';
|
||||
return locale;
|
||||
};
|
||||
import type { UILocaleResourceInput, UILocaleResources } from './getUILocaleAndResources.utils';
|
||||
import {
|
||||
mergeUILocaleResources,
|
||||
normalizeUILocaleResources,
|
||||
resolveUILocale,
|
||||
} from './getUILocaleAndResources.utils';
|
||||
|
||||
const loadBusinessResources = async (locale: string): Promise<UILocaleResources | null> => {
|
||||
try {
|
||||
const resourcesModule = await import(`@/../locales/${locale}/ui.json`);
|
||||
return resourcesModule.default as UILocaleResources;
|
||||
const resources = resourcesModule.default as UILocaleResourceInput | null;
|
||||
|
||||
return resources ? normalizeUILocaleResources(resources) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -31,19 +30,17 @@ const loadLobeUIBuiltinResources = async (locale: string): Promise<UILocaleResou
|
||||
export const getUILocaleAndResources = async (
|
||||
locale: string | 'auto',
|
||||
): Promise<{ locale: string; resources: UILocaleResources }> => {
|
||||
const effectiveLocale = locale === 'auto' ? 'en-US' : locale;
|
||||
const normalizedLocale = normalizeLocale(effectiveLocale);
|
||||
const uiLocale = getUILocale(normalizedLocale);
|
||||
const { normalizedLocale, uiLocale } = resolveUILocale(locale);
|
||||
|
||||
// Priority:
|
||||
// 1) business-defined ui.json
|
||||
// 2) @lobehub/ui built-in resources (en/zh)
|
||||
// 3) fallback to default en
|
||||
const resources =
|
||||
(await loadBusinessResources(normalizedLocale)) ??
|
||||
(await loadLobeUIBuiltinResources(normalizedLocale)) ??
|
||||
(await loadBusinessResources('en-US')) ??
|
||||
(await loadLobeUIBuiltinResources('en-US'));
|
||||
mergeUILocaleResources(
|
||||
await loadLobeUIBuiltinResources(normalizedLocale),
|
||||
await loadBusinessResources(normalizedLocale),
|
||||
) ??
|
||||
mergeUILocaleResources(
|
||||
await loadLobeUIBuiltinResources('en-US'),
|
||||
await loadBusinessResources('en-US'),
|
||||
);
|
||||
|
||||
if (!resources)
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { DEFAULT_LANG } from '@/const/locale';
|
||||
import { normalizeLocale } from '@/locales/resources';
|
||||
|
||||
export type UILocaleResourceBundle = Record<string, string>;
|
||||
export type UILocaleResources = Record<string, UILocaleResourceBundle>;
|
||||
export type UILocaleResourceInput = UILocaleResourceBundle | UILocaleResources;
|
||||
|
||||
const getDocumentLocale = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
return document.documentElement.lang || undefined;
|
||||
};
|
||||
|
||||
const getNavigatorLocale = () => {
|
||||
if (typeof navigator === 'undefined') return;
|
||||
|
||||
return navigator.language || undefined;
|
||||
};
|
||||
|
||||
const getUILocale = (locale: string): string => {
|
||||
if (locale.startsWith('zh')) return 'zh-CN';
|
||||
if (locale.startsWith('en')) return 'en-US';
|
||||
return locale;
|
||||
};
|
||||
|
||||
const isFlatUILocaleResources = (
|
||||
resources: UILocaleResourceInput,
|
||||
): resources is UILocaleResourceBundle =>
|
||||
Object.values(resources).every((value) => typeof value === 'string');
|
||||
|
||||
const flattenUILocaleResources = (resources: UILocaleResourceInput): UILocaleResourceBundle =>
|
||||
isFlatUILocaleResources(resources) ? resources : Object.assign({}, ...Object.values(resources));
|
||||
|
||||
export const normalizeUILocaleResources = (
|
||||
resources: UILocaleResourceInput,
|
||||
): UILocaleResources => ({
|
||||
app: flattenUILocaleResources(resources),
|
||||
});
|
||||
|
||||
export const mergeUILocaleResources = (
|
||||
...resourcesList: (UILocaleResourceInput | null)[]
|
||||
): UILocaleResources | null => {
|
||||
const mergedResources = Object.assign(
|
||||
{},
|
||||
...resourcesList.filter(Boolean).map((resources) => flattenUILocaleResources(resources!)),
|
||||
);
|
||||
|
||||
return Object.keys(mergedResources).length > 0 ? { app: mergedResources } : null;
|
||||
};
|
||||
|
||||
export const resolveUILocale = (locale: string | 'auto') => {
|
||||
const effectiveLocale =
|
||||
locale === 'auto' ? (getDocumentLocale() ?? getNavigatorLocale() ?? DEFAULT_LANG) : locale;
|
||||
const normalizedLocale = normalizeLocale(effectiveLocale);
|
||||
|
||||
return {
|
||||
normalizedLocale,
|
||||
uiLocale: getUILocale(normalizedLocale),
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,11 @@
|
||||
import { normalizeLocale } from '@/locales/resources';
|
||||
import type { UILocaleResourceInput, UILocaleResources } from './getUILocaleAndResources.utils';
|
||||
import {
|
||||
mergeUILocaleResources,
|
||||
normalizeUILocaleResources,
|
||||
resolveUILocale,
|
||||
} from './getUILocaleAndResources.utils';
|
||||
|
||||
type UILocaleResources = Record<string, Record<string, string>>;
|
||||
|
||||
const uiLocaleLoaders = import.meta.glob<{ default: UILocaleResources }>('/locales/*/ui.json');
|
||||
|
||||
const getUILocale = (locale: string): string => {
|
||||
if (locale.startsWith('zh')) return 'zh-CN';
|
||||
if (locale.startsWith('en')) return 'en-US';
|
||||
return locale;
|
||||
};
|
||||
const uiLocaleLoaders = import.meta.glob<{ default: UILocaleResourceInput }>('/locales/*/ui.json');
|
||||
|
||||
const loadBusinessResources = async (locale: string): Promise<UILocaleResources | null> => {
|
||||
const key = `/locales/${locale}/ui.json`;
|
||||
@@ -16,7 +13,9 @@ const loadBusinessResources = async (locale: string): Promise<UILocaleResources
|
||||
if (!loader) return null;
|
||||
try {
|
||||
const mod = await loader();
|
||||
return mod.default as UILocaleResources;
|
||||
const resources = mod.default as UILocaleResourceInput | null;
|
||||
|
||||
return resources ? normalizeUILocaleResources(resources) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -36,15 +35,17 @@ const loadLobeUIBuiltinResources = async (locale: string): Promise<UILocaleResou
|
||||
export const getUILocaleAndResources = async (
|
||||
locale: string | 'auto',
|
||||
): Promise<{ locale: string; resources: UILocaleResources }> => {
|
||||
const effectiveLocale = locale === 'auto' ? 'en-US' : locale;
|
||||
const normalizedLocale = normalizeLocale(effectiveLocale);
|
||||
const uiLocale = getUILocale(normalizedLocale);
|
||||
const { normalizedLocale, uiLocale } = resolveUILocale(locale);
|
||||
|
||||
const resources =
|
||||
(await loadBusinessResources(normalizedLocale)) ??
|
||||
(await loadLobeUIBuiltinResources(normalizedLocale)) ??
|
||||
(await loadBusinessResources('en-US')) ??
|
||||
(await loadLobeUIBuiltinResources('en-US'));
|
||||
mergeUILocaleResources(
|
||||
await loadLobeUIBuiltinResources(normalizedLocale),
|
||||
await loadBusinessResources(normalizedLocale),
|
||||
) ??
|
||||
mergeUILocaleResources(
|
||||
await loadLobeUIBuiltinResources('en-US'),
|
||||
await loadBusinessResources('en-US'),
|
||||
);
|
||||
|
||||
if (!resources)
|
||||
throw new Error(
|
||||
|
||||
@@ -15,6 +15,8 @@ const lobeHubOnlineModelLocales = {
|
||||
'grok-4.20-beta-0309-non-reasoning.description': 'A non-reasoning variant for simple use cases',
|
||||
'MiniMax-M2.1-Lightning.description':
|
||||
'Powerful multilingual programming capabilities with faster and more efficient inference.',
|
||||
'qwen3.7-max.description':
|
||||
"Qwen3.7-Max is Alibaba Cloud's flagship agent-era model for complex coding, reasoning, office automation, and long-horizon autonomous workflows.",
|
||||
'seedream-5-0-260128.description':
|
||||
'ByteDance-Seedream-5.0-lite by BytePlus features web-retrieval-augmented generation for real-time information, enhanced complex prompt interpretation, and improved reference consistency for professional visual creation.',
|
||||
'fal-ai/bytedance/seedream/v4.5.description':
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatSettingsTabs } from '@/store/global/initialState';
|
||||
|
||||
import Content from './Content';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agentState: {
|
||||
activeAgentId: 'inbox-agent',
|
||||
config: {},
|
||||
isInbox: true,
|
||||
meta: {},
|
||||
optimisticUpdateAgentConfig: vi.fn(),
|
||||
optimisticUpdateAgentMeta: vi.fn(),
|
||||
},
|
||||
serverState: {
|
||||
featureFlags: {
|
||||
enableAgentSelfIteration: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
Avatar: () => <div data-testid="avatar" />,
|
||||
Block: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
Flexbox: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
Icon: () => <span />,
|
||||
Text: ({ children }: PropsWithChildren) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Menu', () => ({
|
||||
default: ({
|
||||
items = [],
|
||||
onClick,
|
||||
selectedKeys = [],
|
||||
}: {
|
||||
items?: { key?: string; label?: ReactNode }[];
|
||||
onClick?: ({ key }: { key: string }) => void;
|
||||
selectedKeys?: string[];
|
||||
}) => (
|
||||
<div data-selected={selectedKeys.join(',')} data-testid="agent-settings-menu">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => item.key && onClick?.({ key: item.key })}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/AgentSetting', () => ({
|
||||
AgentSettings: ({ tab }: { tab: ChatSettingsTabs }) => (
|
||||
<div data-tab={tab} data-testid="agent-settings-content" />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent', () => {
|
||||
const useAgentStore = (selector: (state: typeof mocks.agentState) => unknown) =>
|
||||
selector(mocks.agentState);
|
||||
useAgentStore.getState = () => mocks.agentState;
|
||||
|
||||
return { useAgentStore };
|
||||
});
|
||||
|
||||
vi.mock('@/store/agent/selectors', () => ({
|
||||
agentSelectors: {
|
||||
currentAgentConfig: (state: typeof mocks.agentState) => state.config,
|
||||
currentAgentMeta: (state: typeof mocks.agentState) => state.meta,
|
||||
},
|
||||
builtinAgentSelectors: {
|
||||
isInboxAgent: (state: typeof mocks.agentState) => state.isInbox,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/serverConfig', () => ({
|
||||
featureFlagsSelectors: (state: typeof mocks.serverState) => state.featureFlags,
|
||||
useServerConfigStore: (selector: (state: typeof mocks.serverState) => unknown) =>
|
||||
selector(mocks.serverState),
|
||||
}));
|
||||
|
||||
vi.mock('antd-style', () => ({
|
||||
useTheme: () => ({
|
||||
colorBgLayout: '#fff',
|
||||
colorBorderSecondary: '#eee',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AgentSettings Content', () => {
|
||||
beforeEach(() => {
|
||||
mocks.agentState.isInbox = true;
|
||||
mocks.serverState.featureFlags.enableAgentSelfIteration = true;
|
||||
});
|
||||
|
||||
it('should select self iteration when inbox hides opening settings', () => {
|
||||
render(<Content />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'agentTab.opening' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'agentTab.selfIteration' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('agent-settings-menu')).toHaveAttribute(
|
||||
'data-selected',
|
||||
ChatSettingsTabs.SelfIteration,
|
||||
);
|
||||
expect(screen.getByTestId('agent-settings-content')).toHaveAttribute(
|
||||
'data-tab',
|
||||
ChatSettingsTabs.SelfIteration,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { type ItemType } from 'antd/es/menu/interface';
|
||||
import { useTheme } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ActivityIcon, MessageSquareHeartIcon } from 'lucide-react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
@@ -29,6 +29,21 @@ const Content = memo(() => {
|
||||
const { enableAgentSelfIteration } = useServerConfigStore(featureFlagsSelectors);
|
||||
const [tab, setTab] = useState(ChatSettingsTabs.Opening);
|
||||
|
||||
const availableTabs = useMemo(
|
||||
() =>
|
||||
[
|
||||
!isInbox ? ChatSettingsTabs.Opening : null,
|
||||
enableAgentSelfIteration ? ChatSettingsTabs.SelfIteration : null,
|
||||
].filter(Boolean) as ChatSettingsTabs[],
|
||||
[isInbox, enableAgentSelfIteration],
|
||||
);
|
||||
|
||||
const activeTab = availableTabs.includes(tab) ? tab : availableTabs[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab && activeTab !== tab) setTab(activeTab);
|
||||
}, [activeTab, tab]);
|
||||
|
||||
const updateAgentConfig = async (config: any) => {
|
||||
if (!agentId) return;
|
||||
await useAgentStore.getState().optimisticUpdateAgentConfig(agentId, config);
|
||||
@@ -41,23 +56,30 @@ const Content = memo(() => {
|
||||
|
||||
const menuItems: ItemType[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
!isInbox
|
||||
? {
|
||||
icon: <Icon icon={MessageSquareHeartIcon} />,
|
||||
key: ChatSettingsTabs.Opening,
|
||||
label: t('agentTab.opening'),
|
||||
availableTabs
|
||||
.map((tab) => {
|
||||
switch (tab) {
|
||||
case ChatSettingsTabs.Opening: {
|
||||
return {
|
||||
icon: <Icon icon={MessageSquareHeartIcon} />,
|
||||
key: ChatSettingsTabs.Opening,
|
||||
label: t('agentTab.opening'),
|
||||
};
|
||||
}
|
||||
: null,
|
||||
enableAgentSelfIteration
|
||||
? {
|
||||
icon: <Icon icon={ActivityIcon} />,
|
||||
key: ChatSettingsTabs.SelfIteration,
|
||||
label: t('agentTab.selfIteration'),
|
||||
case ChatSettingsTabs.SelfIteration: {
|
||||
return {
|
||||
icon: <Icon icon={ActivityIcon} />,
|
||||
key: ChatSettingsTabs.SelfIteration,
|
||||
label: t('agentTab.selfIteration'),
|
||||
};
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean) as ItemType[],
|
||||
[t, isInbox, enableAgentSelfIteration],
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as ItemType[],
|
||||
[availableTabs, t],
|
||||
);
|
||||
|
||||
const displayTitle = isInbox ? 'Lobe AI' : meta.title || t('defaultSession', { ns: 'common' });
|
||||
@@ -105,7 +127,7 @@ const Content = memo(() => {
|
||||
<Menu
|
||||
selectable
|
||||
items={menuItems}
|
||||
selectedKeys={[tab]}
|
||||
selectedKeys={activeTab ? [activeTab] : []}
|
||||
style={{ width: '100%' }}
|
||||
onClick={({ key }) => setTab(key as ChatSettingsTabs)}
|
||||
/>
|
||||
@@ -116,15 +138,17 @@ const Content = memo(() => {
|
||||
paddingInline={64}
|
||||
style={{ overflow: 'scroll', width: '100%' }}
|
||||
>
|
||||
<Settings
|
||||
config={config}
|
||||
id={agentId}
|
||||
loading={false}
|
||||
meta={meta}
|
||||
tab={tab}
|
||||
onConfigChange={updateAgentConfig}
|
||||
onMetaChange={updateAgentMeta}
|
||||
/>
|
||||
{activeTab && (
|
||||
<Settings
|
||||
config={config}
|
||||
id={agentId}
|
||||
loading={false}
|
||||
meta={meta}
|
||||
tab={activeTab}
|
||||
onConfigChange={updateAgentConfig}
|
||||
onMetaChange={updateAgentMeta}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
|
||||
import PublishButton from './PublishButton';
|
||||
import PublishResultModal from './PublishResultModal';
|
||||
|
||||
/**
|
||||
* Agent Publish Button Component
|
||||
*
|
||||
* Simplified version - backend now handles ownership check automatically.
|
||||
* The action type (submit vs upload) is determined by backend based on:
|
||||
* 1. Whether the identifier exists
|
||||
* 2. Whether the current user is the owner
|
||||
*/
|
||||
const AgentPublishButton = memo(() => {
|
||||
const meta = useAgentStore(agentSelectors.currentAgentMeta, isEqual);
|
||||
|
||||
const [showResultModal, setShowResultModal] = useState(false);
|
||||
const [publishedIdentifier, setPublishedIdentifier] = useState<string>();
|
||||
|
||||
const handlePublishSuccess = useCallback((identifier: string) => {
|
||||
setPublishedIdentifier(identifier);
|
||||
setShowResultModal(true);
|
||||
}, []);
|
||||
|
||||
// Determine action based on whether we have an existing marketIdentifier
|
||||
// Backend will verify ownership and decide to create new or update
|
||||
const action = meta?.marketIdentifier ? 'upload' : 'submit';
|
||||
|
||||
return (
|
||||
<>
|
||||
<PublishButton action={action} onPublishSuccess={handlePublishSuccess} />
|
||||
<PublishResultModal
|
||||
identifier={publishedIdentifier}
|
||||
open={showResultModal}
|
||||
onCancel={() => setShowResultModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AgentPublishButton;
|
||||
@@ -0,0 +1,214 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import Header from './index';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agentState: {
|
||||
activeAgentId: 'agent-1',
|
||||
canCurrentAgentPublishToCommunity: true,
|
||||
isCurrentAgentHeterogeneous: false,
|
||||
meta: {
|
||||
title: 'Test Agent',
|
||||
},
|
||||
systemRole: 'You are helpful.',
|
||||
},
|
||||
globalState: {
|
||||
isStatusInit: true,
|
||||
showAgentBuilderPanel: false,
|
||||
toggleAgentBuilderPanel: vi.fn(),
|
||||
},
|
||||
homeState: {
|
||||
removeAgent: vi.fn(),
|
||||
},
|
||||
marketAuth: {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
signIn: vi.fn(),
|
||||
},
|
||||
marketPublish: {
|
||||
checkOwnership: vi.fn(),
|
||||
isPublishing: false,
|
||||
publish: vi.fn(),
|
||||
},
|
||||
navigate: vi.fn(),
|
||||
versionReviewStatus: {
|
||||
isUnderReview: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
ActionIcon: () => <button aria-label="more" type="button" />,
|
||||
DropdownMenu: ({
|
||||
children,
|
||||
items = [],
|
||||
}: PropsWithChildren<{
|
||||
items?: Array<{ key?: string; label?: ReactNode; type?: string }>;
|
||||
}>) => (
|
||||
<div>
|
||||
{children}
|
||||
<div data-testid="agent-profile-menu">
|
||||
{items
|
||||
.filter((item) => item.type !== 'divider')
|
||||
.map((item) => (
|
||||
<button key={item.key} type="button">
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Flexbox: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
Icon: () => <span />,
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui/icons', () => ({
|
||||
ShapesUploadIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
modal: {
|
||||
confirm: vi.fn(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
Modal: {
|
||||
confirm: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
BotMessageSquareIcon: () => null,
|
||||
MoreHorizontal: () => null,
|
||||
Settings2Icon: () => null,
|
||||
Trash: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mocks.navigate,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/AntdStaticMethods', () => ({
|
||||
message: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/const/layoutTokens', () => ({
|
||||
DESKTOP_HEADER_ICON_SMALL_SIZE: 24,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/NavHeader', () => ({
|
||||
default: ({ left, right }: { left?: ReactNode; right?: ReactNode }) => (
|
||||
<header>
|
||||
{left}
|
||||
{right}
|
||||
</header>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/RightPanel/ToggleRightPanelButton', () => ({
|
||||
default: () => <button type="button">agentBuilder</button>,
|
||||
}));
|
||||
|
||||
vi.mock('@/layout/AuthProvider/MarketAuth', () => ({
|
||||
useMarketAuth: () => mocks.marketAuth,
|
||||
}));
|
||||
|
||||
vi.mock('@/layout/AuthProvider/MarketAuth/errors', () => ({
|
||||
resolveMarketAuthError: () => ({ code: 'unknown' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent', () => ({
|
||||
useAgentStore: (selector: (state: typeof mocks.agentState) => unknown) =>
|
||||
selector(mocks.agentState),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent/selectors', () => ({
|
||||
agentSelectors: {
|
||||
canCurrentAgentPublishToCommunity: (state: typeof mocks.agentState) =>
|
||||
state.canCurrentAgentPublishToCommunity,
|
||||
currentAgentMeta: (state: typeof mocks.agentState) => state.meta,
|
||||
currentAgentSystemRole: (state: typeof mocks.agentState) => state.systemRole,
|
||||
isCurrentAgentHeterogeneous: (state: typeof mocks.agentState) =>
|
||||
state.isCurrentAgentHeterogeneous,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/global', () => ({
|
||||
useGlobalStore: (selector: (state: typeof mocks.globalState) => unknown) =>
|
||||
selector(mocks.globalState),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/global/selectors', () => ({
|
||||
systemStatusSelectors: {
|
||||
isStatusInit: (state: typeof mocks.globalState) => state.isStatusInit,
|
||||
showAgentBuilderPanel: (state: typeof mocks.globalState) => state.showAgentBuilderPanel,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/home', () => ({
|
||||
useHomeStore: (selector: (state: typeof mocks.homeState) => unknown) => selector(mocks.homeState),
|
||||
}));
|
||||
|
||||
vi.mock('./AgentForkTag', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./AgentPublishButton/ForkConfirmModal', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./AgentPublishButton/PublishResultModal', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./AgentPublishButton/useMarketPublish', () => ({
|
||||
useMarketPublish: () => mocks.marketPublish,
|
||||
}));
|
||||
|
||||
vi.mock('./AgentStatusTag', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./AutoSaveHint', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./AgentVersionReviewTag', () => ({
|
||||
default: () => null,
|
||||
useVersionReviewStatus: () => mocks.versionReviewStatus,
|
||||
}));
|
||||
|
||||
describe('Agent profile Header', () => {
|
||||
beforeEach(() => {
|
||||
mocks.agentState.canCurrentAgentPublishToCommunity = true;
|
||||
mocks.agentState.isCurrentAgentHeterogeneous = false;
|
||||
});
|
||||
|
||||
it('should show the community publish action for normal agents', () => {
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'publishToCommunity' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide the community publish action for heterogeneous and platform agents', () => {
|
||||
mocks.agentState.canCurrentAgentPublishToCommunity = false;
|
||||
mocks.agentState.isCurrentAgentHeterogeneous = true;
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'publishToCommunity' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ const Header = memo(() => {
|
||||
const systemRole = useAgentStore(agentSelectors.currentAgentSystemRole);
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||
const canPublishToCommunity = useAgentStore(agentSelectors.canCurrentAgentPublishToCommunity);
|
||||
const [showAgentBuilderPanel, toggleAgentBuilderPanel, isStatusInit] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.showAgentBuilderPanel(s),
|
||||
s.toggleAgentBuilderPanel,
|
||||
@@ -148,13 +149,17 @@ const Header = memo(() => {
|
||||
onClick: () => useAgentStore.setState({ showAgentSetting: true }),
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: <Icon icon={ShapesUploadIcon} />,
|
||||
key: 'publish',
|
||||
label: t('publishToCommunity', { ns: 'setting' }),
|
||||
onClick: handlePublishClick,
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
...(canPublishToCommunity
|
||||
? [
|
||||
{
|
||||
icon: <Icon icon={ShapesUploadIcon} />,
|
||||
key: 'publish',
|
||||
label: t('publishToCommunity', { ns: 'setting' }),
|
||||
onClick: handlePublishClick,
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
]
|
||||
: []),
|
||||
{
|
||||
danger: true,
|
||||
icon: <Icon icon={Trash} />,
|
||||
@@ -163,7 +168,7 @@ const Header = memo(() => {
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
[handlePublishClick, handleDelete, t],
|
||||
[canPublishToCommunity, handlePublishClick, handleDelete, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -182,7 +187,7 @@ const Header = memo(() => {
|
||||
<DropdownMenu items={menuItems}>
|
||||
<ActionIcon
|
||||
icon={MoreHorizontal}
|
||||
loading={isPublishing || isAuthLoading}
|
||||
loading={canPublishToCommunity && (isPublishing || isAuthLoading)}
|
||||
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -201,7 +201,7 @@ export const createServerAgentToolsEngine = (
|
||||
// Always-on builtin tools
|
||||
...Object.fromEntries(alwaysOnToolIds.map((id) => [id, true])),
|
||||
// System-level rules (may override user selection for specific tools)
|
||||
[CloudSandboxManifest.identifier]: runtimeMode === 'cloud' || runtimeMode === 'sandbox',
|
||||
[CloudSandboxManifest.identifier]: runtimeMode === 'cloud',
|
||||
[KnowledgeBaseManifest.identifier]: hasEnabledKnowledgeBases,
|
||||
// Local-system: gated by `canUseDevice` (resolveDeviceAccessPolicy)
|
||||
// first — keeps external bot senders out before runtime checks even
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device';
|
||||
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
|
||||
export const deviceService = {
|
||||
/**
|
||||
* List all online devices bound to the current user.
|
||||
* Returns devices from the device-gateway via tRPC.
|
||||
*/
|
||||
listDevices: async (): Promise<DeviceAttachment[]> => {
|
||||
try {
|
||||
return await lambdaClient.device.listDevices.query();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the user has any online devices.
|
||||
*/
|
||||
getStatus: async (): Promise<{ deviceCount: number; online: boolean }> => {
|
||||
try {
|
||||
return await lambdaClient.device.status.query();
|
||||
} catch {
|
||||
return { deviceCount: 0, online: false };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -58,26 +58,17 @@ const getRuntimeEnvConfigById = (agentId: string) => (s: AgentStoreState) =>
|
||||
const isLocalSystemEnabledById = (agentId: string) => (s: AgentStoreState) =>
|
||||
getRuntimeModeById(agentId)(s) === 'local';
|
||||
|
||||
/** Get the selected device ID for the agent (desktop only) */
|
||||
const getDeviceIdById =
|
||||
(agentId: string) =>
|
||||
(s: AgentStoreState): string | undefined =>
|
||||
getChatConfigById(agentId)(s).runtimeEnv?.deviceId;
|
||||
|
||||
/**
|
||||
* Get runtime environment mode by agent ID.
|
||||
* Reads from `runtimeMode[platform]`, defaults to 'local' on desktop, 'none' on web.
|
||||
* Legacy 'cloud' values are normalized to 'sandbox' for backward compatibility.
|
||||
*/
|
||||
const getRuntimeModeById =
|
||||
(agentId: string) =>
|
||||
(s: AgentStoreState): RuntimeEnvMode => {
|
||||
const runtimeEnv = getChatConfigById(agentId)(s).runtimeEnv;
|
||||
const platform = isDesktop ? 'desktop' : 'web';
|
||||
const mode = runtimeEnv?.runtimeMode?.[platform] ?? (isDesktop ? 'local' : 'none');
|
||||
|
||||
// Legacy backward compatibility: map 'cloud' to 'sandbox'
|
||||
return mode === 'cloud' ? 'sandbox' : mode;
|
||||
return runtimeEnv?.runtimeMode?.[platform] ?? (isDesktop ? 'local' : 'none');
|
||||
};
|
||||
|
||||
const getSkillActivateModeById =
|
||||
@@ -87,7 +78,6 @@ const getSkillActivateModeById =
|
||||
|
||||
export const chatConfigByIdSelectors = {
|
||||
getChatConfigById,
|
||||
getDeviceIdById,
|
||||
getEnableHistoryCountById,
|
||||
getHistoryCountById,
|
||||
getRuntimeEnvConfigById,
|
||||
|
||||
@@ -34,10 +34,8 @@ const isMemoryToolEnabled = (s: AgentStoreState) =>
|
||||
const isLocalSystemEnabled = (s: AgentStoreState) =>
|
||||
chatConfigByIdSelectors.isLocalSystemEnabledById(s.activeAgentId || '')(s);
|
||||
|
||||
const isCloudSandboxEnabled = (s: AgentStoreState) => {
|
||||
const mode = chatConfigByIdSelectors.getRuntimeModeById(s.activeAgentId || '')(s);
|
||||
return mode === 'cloud' || mode === 'sandbox';
|
||||
};
|
||||
const isCloudSandboxEnabled = (s: AgentStoreState) =>
|
||||
chatConfigByIdSelectors.getRuntimeModeById(s.activeAgentId || '')(s) === 'cloud';
|
||||
|
||||
const skillActivateMode = (s: AgentStoreState) =>
|
||||
chatConfigByIdSelectors.getSkillActivateModeById(s.activeAgentId || '')(s);
|
||||
|
||||
@@ -360,6 +360,50 @@ describe('agentSelectors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canCurrentAgentPublishToCommunity', () => {
|
||||
it('should allow publishing normal agents', () => {
|
||||
const state = createState({
|
||||
activeAgentId: 'agent-1',
|
||||
agentMap: { 'agent-1': { id: 'agent-1' } },
|
||||
});
|
||||
|
||||
expect(agentSelectors.canCurrentAgentPublishToCommunity(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent publishing local heterogeneous agents', () => {
|
||||
const state = createState({
|
||||
activeAgentId: 'agent-1',
|
||||
agentMap: {
|
||||
'agent-1': {
|
||||
agencyConfig: {
|
||||
heterogeneousProvider: { command: 'codex', type: 'codex' },
|
||||
},
|
||||
id: 'agent-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(agentSelectors.canCurrentAgentPublishToCommunity(state)).toBe(false);
|
||||
});
|
||||
|
||||
it('should prevent publishing platform agents', () => {
|
||||
const state = createState({
|
||||
activeAgentId: 'agent-1',
|
||||
agentMap: {
|
||||
'agent-1': {
|
||||
agencyConfig: {
|
||||
boundDeviceId: 'device-1',
|
||||
heterogeneousProvider: { type: 'openclaw' },
|
||||
},
|
||||
id: 'agent-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(agentSelectors.canCurrentAgentPublishToCommunity(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentKnowledgeIds', () => {
|
||||
it('should return enabled file and knowledge base IDs', () => {
|
||||
const state = createState({
|
||||
|
||||
@@ -286,6 +286,9 @@ const isCurrentAgentExternal = (s: AgentStoreState): boolean => !currentAgentDat
|
||||
const isCurrentAgentHeterogeneous = (s: AgentStoreState): boolean =>
|
||||
!!currentAgentConfig(s)?.agencyConfig?.heterogeneousProvider;
|
||||
|
||||
const canCurrentAgentPublishToCommunity = (s: AgentStoreState): boolean =>
|
||||
!!currentAgentData(s) && !isCurrentAgentHeterogeneous(s);
|
||||
|
||||
const currentAgentHeterogeneousProviderType = (s: AgentStoreState) =>
|
||||
currentAgentConfig(s)?.agencyConfig?.heterogeneousProvider?.type;
|
||||
|
||||
@@ -293,6 +296,7 @@ const getAgentDocumentsById = (agentId: string) => (s: AgentStoreState) =>
|
||||
s.agentDocumentsMap[agentId];
|
||||
|
||||
export const agentSelectors = {
|
||||
canCurrentAgentPublishToCommunity,
|
||||
currentAgentHeterogeneousProviderType,
|
||||
currentAgentAvatar,
|
||||
currentAgentBackgroundColor,
|
||||
|
||||
@@ -378,6 +378,139 @@ describe('runAgent actions', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces messages from server-pushed uiMessages snapshot (SoT)', async () => {
|
||||
const replaceMessages = vi.fn();
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
replaceMessages,
|
||||
operations: {
|
||||
[TEST_IDS.OPERATION_ID]: {
|
||||
abortController: new AbortController(),
|
||||
context: { agentId: 'agent-1', topicId: 'topic-1' },
|
||||
id: TEST_IDS.OPERATION_ID,
|
||||
metadata: { lastEventId: '0', startTime: Date.now(), stepCount: 0 },
|
||||
status: 'running',
|
||||
type: 'groupAgentGenerate',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const context = createStreamingContext({ assistantId: TEST_IDS.ASSISTANT_MESSAGE_ID });
|
||||
const uiMessages = [{ id: 'msg_a', role: 'user' }] as any;
|
||||
|
||||
const event: StreamEvent = {
|
||||
type: 'step_start',
|
||||
timestamp: Date.now(),
|
||||
operationId: TEST_IDS.OPERATION_ID,
|
||||
data: { phase: 'tool_execution', uiMessages },
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.internal_handleAgentStreamEvent(
|
||||
TEST_IDS.OPERATION_ID,
|
||||
event,
|
||||
context,
|
||||
);
|
||||
});
|
||||
|
||||
expect(replaceMessages).toHaveBeenCalledWith(uiMessages, {
|
||||
context: { agentId: 'agent-1', topicId: 'topic-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call replaceMessages when uiMessages absent on step_start', async () => {
|
||||
const replaceMessages = vi.fn();
|
||||
act(() => {
|
||||
useChatStore.setState({ replaceMessages });
|
||||
});
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const context = createStreamingContext({ assistantId: TEST_IDS.ASSISTANT_MESSAGE_ID });
|
||||
const event: StreamEvent = {
|
||||
type: 'step_start',
|
||||
timestamp: Date.now(),
|
||||
operationId: TEST_IDS.OPERATION_ID,
|
||||
data: { phase: 'tool_execution' }, // no uiMessages
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.internal_handleAgentStreamEvent(
|
||||
TEST_IDS.OPERATION_ID,
|
||||
event,
|
||||
context,
|
||||
);
|
||||
});
|
||||
|
||||
expect(replaceMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent_runtime_end event', () => {
|
||||
it('replaces messages from terminal uiMessages snapshot (final-step SoT)', async () => {
|
||||
const replaceMessages = vi.fn();
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
replaceMessages,
|
||||
operations: {
|
||||
[TEST_IDS.OPERATION_ID]: {
|
||||
abortController: new AbortController(),
|
||||
context: { agentId: 'agent-1', topicId: 'topic-1' },
|
||||
id: TEST_IDS.OPERATION_ID,
|
||||
metadata: { lastEventId: '0', startTime: Date.now(), stepCount: 0 },
|
||||
status: 'running',
|
||||
type: 'groupAgentGenerate',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const uiMessages = [{ id: 'msg_final', role: 'assistantGroup' }] as any;
|
||||
|
||||
const event: StreamEvent = {
|
||||
type: 'agent_runtime_end',
|
||||
timestamp: Date.now(),
|
||||
operationId: TEST_IDS.OPERATION_ID,
|
||||
data: { finalState: { status: 'done' }, reason: 'done', uiMessages },
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.internal_handleAgentStreamEvent(
|
||||
TEST_IDS.OPERATION_ID,
|
||||
event,
|
||||
createStreamingContext({ assistantId: TEST_IDS.ASSISTANT_MESSAGE_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(replaceMessages).toHaveBeenCalledWith(uiMessages, {
|
||||
context: { agentId: 'agent-1', topicId: 'topic-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('step_complete event', () => {
|
||||
// The previous DB-refetch on tool_execution was the source of the
|
||||
// assistantGroup-clobber regression (LOBE-9501) — tool results are
|
||||
// now reconciled via the next step_start's uiMessages snapshot.
|
||||
it('does NOT refreshMessages on tool_execution phase', async () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const event: StreamEvent = {
|
||||
type: 'step_complete',
|
||||
timestamp: Date.now(),
|
||||
operationId: TEST_IDS.OPERATION_ID,
|
||||
data: { executionTime: 10, phase: 'tool_execution', result: { ok: true } },
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.internal_handleAgentStreamEvent(
|
||||
TEST_IDS.OPERATION_ID,
|
||||
event,
|
||||
createStreamingContext({ assistantId: TEST_IDS.ASSISTANT_MESSAGE_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.refreshMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,9 +118,19 @@ export class AgentActionImpl {
|
||||
|
||||
case 'agent_runtime_end': {
|
||||
// Agent runtime finished - this is the definitive signal that generation is complete
|
||||
const { reason, reasonDetail, finalState } = event.data || {};
|
||||
const { reason, reasonDetail, finalState, uiMessages } = event.data || {};
|
||||
log(`Agent runtime ended for ${assistantId}: reason=${reason}, detail=${reasonDetail}`);
|
||||
|
||||
// Server pushes the canonical UIChatMessage[] snapshot for the
|
||||
// topic as the Source of Truth on terminal-state. The last step
|
||||
// has no later step_start to carry a fresh snapshot, so without
|
||||
// this branch the streamed assistantGroup would only be reconciled
|
||||
// with DB once a refetch fires — losing the SoT guarantee.
|
||||
if (Array.isArray(uiMessages)) {
|
||||
log(`Replacing messages from agent_runtime_end uiMessages (${uiMessages.length} msgs)`);
|
||||
this.#get().replaceMessages(uiMessages, { context: operation.context });
|
||||
}
|
||||
|
||||
// Update operation metadata with final state
|
||||
if (finalState) {
|
||||
this.#get().updateOperationMetadata(operationId, {
|
||||
@@ -276,7 +286,19 @@ export class AgentActionImpl {
|
||||
}
|
||||
|
||||
case 'step_start': {
|
||||
const { phase, toolCall, pendingToolsCalling, requiresApproval } = event.data || {};
|
||||
const { phase, toolCall, pendingToolsCalling, requiresApproval, uiMessages } =
|
||||
event.data || {};
|
||||
|
||||
// Server attaches the canonical UIChatMessage[] snapshot to
|
||||
// step_start so the client uses the pushed payload as Source of
|
||||
// Truth instead of refetching from DB (the DB fan-out from the
|
||||
// previous step's stream chunks is async — a refetch here would
|
||||
// return a stale assistant placeholder that clobbers the
|
||||
// streamed assistantGroup).
|
||||
if (Array.isArray(uiMessages)) {
|
||||
log(`Replacing messages from step_start uiMessages (${uiMessages.length} msgs)`);
|
||||
this.#get().replaceMessages(uiMessages, { context: operation.context });
|
||||
}
|
||||
|
||||
if (phase === 'human_approval' && requiresApproval) {
|
||||
// Requires human approval
|
||||
@@ -301,8 +323,10 @@ export class AgentActionImpl {
|
||||
|
||||
if (phase === 'tool_execution' && result) {
|
||||
log(`Tool execution completed for ${assistantId} in ${executionTime}ms:`, result);
|
||||
// Refresh messages to display tool results
|
||||
await this.#get().refreshMessages();
|
||||
// Tool results are reconciled via the canonical uiMessages
|
||||
// snapshot the server pushes on the next step_start; no need
|
||||
// to refetch from DB here (the refetch was the source of the
|
||||
// assistantGroup-clobber regression that LOBE-9501 fixes).
|
||||
} else if (phase === 'execution_complete' && finalState) {
|
||||
// Agent execution complete
|
||||
log(`Agent execution completed for ${assistantId}:`, finalState);
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('createGatewayEventHandler', () => {
|
||||
});
|
||||
|
||||
describe('stream_start', () => {
|
||||
it('should associate new message with operation', async () => {
|
||||
it('should associate new message with operation and skip the DB refetch (LOBE-9501)', async () => {
|
||||
const store = createMockStore();
|
||||
const handler = createHandler(store);
|
||||
|
||||
@@ -90,7 +90,12 @@ describe('createGatewayEventHandler', () => {
|
||||
await flush();
|
||||
|
||||
expect(store.associateMessageWithOperation).toHaveBeenCalledWith('msg-step2', 'op-1');
|
||||
expect(store.replaceMessages).toHaveBeenCalled();
|
||||
// Native gateway streams carry the new assistant id directly + a SoT
|
||||
// uiMessages snapshot on the preceding step_start, so stream_start must
|
||||
// NOT trigger a DB refetch (the refetch is what clobbered the streamed
|
||||
// assistantGroup with a stale placeholder).
|
||||
expect(messageService.getMessages).not.toHaveBeenCalled();
|
||||
expect(store.replaceMessages).not.toHaveBeenCalled();
|
||||
expect(emitClientAgentSignalSourceEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
@@ -843,17 +848,14 @@ describe('createGatewayEventHandler', () => {
|
||||
});
|
||||
|
||||
describe('sequential processing', () => {
|
||||
it('should process stream_chunk only after stream_start refresh completes', async () => {
|
||||
it('should dispatch stream_chunk to the new assistant id after stream_start switches it', async () => {
|
||||
// Native gateway streams no longer await a DB fetch on stream_start
|
||||
// (LOBE-9501) — but stream_chunk must still queue behind stream_start
|
||||
// so the chunk targets the NEW assistant id (from stream_start.data),
|
||||
// not the previous one.
|
||||
const store = createMockStore();
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const { messageService } = await import('@/services/message');
|
||||
(messageService.getMessages as any).mockImplementation(async () => {
|
||||
callOrder.push('refresh_start');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
callOrder.push('refresh_end');
|
||||
return [];
|
||||
});
|
||||
store.internal_dispatchMessage.mockImplementation(() => {
|
||||
callOrder.push('dispatch');
|
||||
});
|
||||
@@ -867,10 +869,34 @@ describe('createGatewayEventHandler', () => {
|
||||
handler(makeEvent('stream_chunk', { chunkType: 'text', content: 'Hello' }));
|
||||
await flush();
|
||||
|
||||
const refreshEndIdx = callOrder.indexOf('refresh_end');
|
||||
// associate (from stream_start) precedes dispatch (from stream_chunk)
|
||||
const associateIdx = callOrder.indexOf('associate');
|
||||
const dispatchIdx = callOrder.indexOf('dispatch');
|
||||
expect(refreshEndIdx).toBeGreaterThan(-1);
|
||||
expect(dispatchIdx).toBeGreaterThan(refreshEndIdx);
|
||||
expect(associateIdx).toBeGreaterThan(-1);
|
||||
expect(dispatchIdx).toBeGreaterThan(associateIdx);
|
||||
|
||||
// Chunk targets the new id, proving the queue ordering held
|
||||
expect(store.internal_dispatchMessage).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'msg-new', value: { content: 'Hello' } }),
|
||||
{ operationId: 'op-1' },
|
||||
);
|
||||
// And no DB refetch was issued for the native stream
|
||||
expect(messageService.getMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still fetch from DB on stream_start when assistantMessage id is absent (hetero CLI)', async () => {
|
||||
// Hetero CLI adapters (Claude Code / Codex) never set
|
||||
// `assistantMessage.id` on stream_start, so the DB read is still
|
||||
// mandatory — it pulls the executor-created placeholder into
|
||||
// `dbMessagesMap` so subsequent chunks have a target.
|
||||
const store = createMockStore();
|
||||
const handler = createHandler(store);
|
||||
|
||||
handler(makeEvent('stream_start', {}));
|
||||
await flush();
|
||||
|
||||
expect(messageService.getMessages).toHaveBeenCalled();
|
||||
expect(store.replaceMessages).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -901,18 +927,22 @@ describe('createGatewayEventHandler', () => {
|
||||
// Loading stays active between steps — only tool streaming is cleared
|
||||
expect(store.internal_toggleToolCallingStreaming).toHaveBeenCalledWith('msg-1', undefined);
|
||||
|
||||
// Tool execution
|
||||
// Tool execution — tool_end still refreshes from DB to pick up the
|
||||
// server-created tool message row.
|
||||
handler(makeEvent('tool_start', { parentMessageId: 'msg-1', toolCalling: tools[0] }));
|
||||
handler(makeEvent('tool_end', { isSuccess: true }));
|
||||
await flush();
|
||||
expect(store.replaceMessages).toHaveBeenCalled();
|
||||
|
||||
// Step 2: Next LLM call with new assistant message
|
||||
// Step 2: Next LLM call with new assistant message — native stream_start
|
||||
// carries the id directly, so it must NOT trigger a DB refetch
|
||||
// (LOBE-9501). Only the association switch happens.
|
||||
vi.clearAllMocks();
|
||||
handler(makeEvent('stream_start', { assistantMessage: { id: 'msg-2' } }));
|
||||
await flush();
|
||||
expect(store.replaceMessages).toHaveBeenCalled();
|
||||
expect(store.associateMessageWithOperation).toHaveBeenCalledWith('msg-2', 'op-1');
|
||||
expect(messageService.getMessages).not.toHaveBeenCalled();
|
||||
expect(store.replaceMessages).not.toHaveBeenCalled();
|
||||
|
||||
handler(makeEvent('stream_chunk', { chunkType: 'text', content: 'Here are the results.' }));
|
||||
await flush();
|
||||
|
||||
@@ -7,7 +7,12 @@ import type {
|
||||
ToolExecuteData,
|
||||
ToolStartData,
|
||||
} from '@lobechat/agent-gateway-client';
|
||||
import type { BuiltinToolResult, ChatMessageError, ConversationContext } from '@lobechat/types';
|
||||
import type {
|
||||
BuiltinToolResult,
|
||||
ChatMessageError,
|
||||
ConversationContext,
|
||||
UIChatMessage,
|
||||
} from '@lobechat/types';
|
||||
import { AgentRuntimeErrorType } from '@lobechat/types';
|
||||
|
||||
import { messageService } from '@/services/message';
|
||||
@@ -276,24 +281,35 @@ export const createGatewayEventHandler = (
|
||||
accumulatedContent = '';
|
||||
accumulatedReasoning = '';
|
||||
|
||||
// Heterogeneous CLI adapters emit `stream_start { newStep: true }`
|
||||
// without a server-side assistant id. Pull the freshly created step
|
||||
// assistant from DB so subsequent live chunks update the RIGHT row
|
||||
// instead of appending onto the previous step's assistant.
|
||||
const messages = await fetchAndReplaceMessages(get, context).catch((error) => {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
});
|
||||
// Skip the DB read ONLY for native gateway streams — those carry
|
||||
// `assistantMessage.id` directly on stream_start AND the preceding
|
||||
// `step_start` already carried the SoT uiMessages snapshot, so
|
||||
// chunks have a valid target in `dbMessagesMap` already. Removing
|
||||
// the await here is what un-blocks the enqueue chain so live
|
||||
// chunks can land mid-stream (LOBE-9501).
|
||||
//
|
||||
// Hetero CLI adapters (Claude Code / Codex) never set
|
||||
// `assistantMessage.id` on stream_start, so the DB read stays
|
||||
// mandatory for them — it (a) pulls the executor-created
|
||||
// placeholder into `dbMessagesMap` so subsequent chunks can
|
||||
// dispatch to it, and (b) resolves the next-step assistant id for
|
||||
// the `newStep` fallback.
|
||||
if (!newAssistantMessageId) {
|
||||
const messages = await fetchAndReplaceMessages(get, context).catch((error) => {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (!newAssistantMessageId && data?.newStep) {
|
||||
const resolvedAssistantMessageId = findNextAssistantMessageId(
|
||||
messages as GatewayMessageLike[] | undefined,
|
||||
currentAssistantMessageId,
|
||||
);
|
||||
if (data?.newStep) {
|
||||
const resolvedAssistantMessageId = findNextAssistantMessageId(
|
||||
messages as GatewayMessageLike[] | undefined,
|
||||
currentAssistantMessageId,
|
||||
);
|
||||
|
||||
if (resolvedAssistantMessageId) {
|
||||
currentAssistantMessageId = resolvedAssistantMessageId;
|
||||
get().associateMessageWithOperation(currentAssistantMessageId, operationId);
|
||||
if (resolvedAssistantMessageId) {
|
||||
currentAssistantMessageId = resolvedAssistantMessageId;
|
||||
get().associateMessageWithOperation(currentAssistantMessageId, operationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,8 +422,18 @@ export const createGatewayEventHandler = (
|
||||
pendingToolsCalling?: unknown[];
|
||||
phase?: string;
|
||||
requiresApproval?: boolean;
|
||||
uiMessages?: UIChatMessage[];
|
||||
};
|
||||
|
||||
// Server attaches the canonical UIChatMessage[] snapshot at every
|
||||
// step boundary (agent-runtime #15152). Use it as Source of Truth
|
||||
// instead of issuing a DB refetch — the refetch returns a stale
|
||||
// assistant placeholder while DB fan-out is still in flight, which
|
||||
// clobbers the in-memory streamed assistantGroup (LOBE-9501).
|
||||
if (Array.isArray(data?.uiMessages)) {
|
||||
get().replaceMessages(data.uiMessages, { action: 'gateway/step_start', context });
|
||||
}
|
||||
|
||||
if (data?.phase === 'human_approval' && data.requiresApproval && data.pendingToolsCalling) {
|
||||
void notifyDesktopHumanApprovalRequired(get, context);
|
||||
// Persist a paused marker so the sidebar reflects "waiting on user" across reload.
|
||||
@@ -475,6 +501,8 @@ export const createGatewayEventHandler = (
|
||||
|
||||
case 'agent_runtime_end': {
|
||||
enqueue(async () => {
|
||||
const data = event.data as { uiMessages?: UIChatMessage[] } | undefined;
|
||||
|
||||
void emitClientAgentSignalSourceEvent({
|
||||
payload: {
|
||||
agentId: context.agentId,
|
||||
@@ -499,7 +527,18 @@ export const createGatewayEventHandler = (
|
||||
get().markUnreadCompleted(completedOp.context.agentId, completedOp.context.topicId);
|
||||
}
|
||||
|
||||
await fetchAndReplaceMessages(get, context).catch(console.error);
|
||||
// Terminal step has no later step_start to carry SoT — server
|
||||
// pushes the canonical snapshot directly on this event. Fall back
|
||||
// to a DB refetch only if the snapshot is absent (older server
|
||||
// builds, or push-event delivery edge cases).
|
||||
if (Array.isArray(data?.uiMessages)) {
|
||||
get().replaceMessages(data.uiMessages, {
|
||||
action: 'gateway/agent_runtime_end',
|
||||
context,
|
||||
});
|
||||
} else {
|
||||
await fetchAndReplaceMessages(get, context).catch(console.error);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -106,8 +106,24 @@ export class MessageQueryActionImpl {
|
||||
|
||||
useFetchMessages = (
|
||||
context: ConversationContext,
|
||||
skipFetch?: boolean,
|
||||
options?: {
|
||||
/**
|
||||
* Skip the fetch entirely (e.g. while another flow owns the data).
|
||||
* Equivalent to passing a null SWR key.
|
||||
*/
|
||||
skipFetch?: boolean;
|
||||
/**
|
||||
* Revalidate when the window regains focus. Defaults to SWR's
|
||||
* client-data default (true). Pass `false` to suppress the focus
|
||||
* refetch — used during streaming so the in-memory stream payload
|
||||
* (Source of Truth) isn't clobbered by a stale DB read while DB
|
||||
* fan-out writes are still in flight.
|
||||
*/
|
||||
revalidateOnFocus?: boolean;
|
||||
},
|
||||
): SWRResponse<UIChatMessage[]> => {
|
||||
const { skipFetch, revalidateOnFocus } = options ?? {};
|
||||
|
||||
// Skip fetch when skipFetch is true or required fields are missing
|
||||
const shouldFetch = !skipFetch && !!context.agentId && !!context.topicId;
|
||||
|
||||
@@ -121,6 +137,7 @@ export class MessageQueryActionImpl {
|
||||
// Use replaceMessages to store the fetched messages
|
||||
this.#get().replaceMessages(data, { action: 'useFetchMessages', context });
|
||||
},
|
||||
...(revalidateOnFocus !== undefined && { revalidateOnFocus }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -122,6 +122,9 @@ exports[`settingsSelectors > defaultAgent > should merge DEFAULT_AGENT and s.set
|
||||
"provider": "deepseek",
|
||||
},
|
||||
"searchMode": "auto",
|
||||
"selfIteration": {
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
"model": "gpt-3.5-turbo",
|
||||
"openingQuestions": [],
|
||||
@@ -166,6 +169,9 @@ exports[`settingsSelectors > defaultAgentConfig > should merge DEFAULT_AGENT_CON
|
||||
"provider": "deepseek",
|
||||
},
|
||||
"searchMode": "auto",
|
||||
"selfIteration": {
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
"model": "gpt-4",
|
||||
"openingQuestions": [],
|
||||
|
||||
Reference in New Issue
Block a user