Files
open-design/src/providers/daemon.ts
T
pftom a98096a042 Add initial project structure with essential files
- Created .gitignore to exclude build artifacts and dependencies.
- Added index.html as the main entry point for the application.
- Included LICENSE file with Apache 2.0 terms.
- Initialized package.json and package-lock.json for project dependencies.
- Added pnpm-lock.yaml for package management.
- Created QUICKSTART.md for setup instructions.
- Added README.md and README.zh-CN.md for project documentation in English and Chinese.
2026-04-28 12:25:59 +08:00

239 lines
7.4 KiB
TypeScript

/**
* Daemon provider — fetch-based SSE client for /api/chat. The daemon can
* emit three event streams depending on the agent's streamFormat:
* - 'agent' : typed events emitted by Claude Code's stream-json parser
* (status, text_delta, thinking_delta, tool_use, tool_result,
* usage, raw). We forward these to the UI as AgentEvent items.
* - 'stdout' : plain chunks from other CLIs. We wrap them in a single
* rolling 'text' event.
* - 'stderr' : incidental stderr. Shown only when the process exits
* non-zero (tail appended to the error message).
*/
import type { AgentEvent, ChatMessage } from '../types';
import type { StreamHandlers } from './anthropic';
export interface DaemonStreamHandlers extends StreamHandlers {
onAgentEvent: (ev: AgentEvent) => void;
}
export interface DaemonStreamOptions {
agentId: string;
history: ChatMessage[];
systemPrompt: string;
signal: AbortSignal;
handlers: DaemonStreamHandlers;
// The active project's id. When supplied, the daemon spawns the agent
// with cwd = the project folder so its file tools target the right
// workspace.
projectId?: string | null;
// Project-relative paths the user has staged for this turn. The
// daemon resolves them inside the project folder, validates they
// exist, and stitches them into the user message as `@<path>` hints.
attachments?: string[];
}
export async function streamViaDaemon({
agentId,
history,
systemPrompt,
signal,
handlers,
projectId,
attachments,
}: DaemonStreamOptions): Promise<void> {
// Local CLIs are single-turn print-mode programs, so we collapse the whole
// chat into one string. If this becomes too noisy for long histories, the
// fix is to only include the final user turn.
const transcript = history
.map((m) => `## ${m.role}\n${m.content.trim()}`)
.join('\n\n');
const body = JSON.stringify({
agentId,
systemPrompt,
message: transcript,
projectId: projectId ?? null,
attachments: attachments ?? [],
});
let acc = '';
let stderrBuf = '';
let exitCode: number | null = null;
try {
const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal,
});
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => '');
handlers.onError(new Error(`daemon ${resp.status}: ${text || 'no body'}`));
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
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);
const parsed = parseFrame(frame);
if (!parsed) continue;
if (parsed.event === 'stdout') {
const chunk = String(parsed.data.chunk ?? '');
acc += chunk;
handlers.onDelta(chunk);
handlers.onAgentEvent({ kind: 'text', text: chunk });
continue;
}
if (parsed.event === 'stderr') {
stderrBuf += parsed.data.chunk ?? '';
continue;
}
if (parsed.event === 'agent') {
const translated = translateAgentEvent(parsed.data);
if (!translated) continue;
if (translated.kind === 'text') {
acc += translated.text;
handlers.onDelta(translated.text);
}
handlers.onAgentEvent(translated);
continue;
}
if (parsed.event === 'start') {
handlers.onAgentEvent({
kind: 'status',
label: 'starting',
detail: typeof parsed.data.bin === 'string' ? parsed.data.bin : undefined,
});
continue;
}
if (parsed.event === 'error') {
handlers.onError(new Error(String(parsed.data.message ?? 'daemon error')));
return;
}
if (parsed.event === 'end') {
exitCode = typeof parsed.data.code === 'number' ? parsed.data.code : null;
}
}
}
if (exitCode !== null && exitCode !== 0) {
const tail = stderrBuf.trim().slice(-400);
handlers.onError(
new Error(`agent exited with code ${exitCode}${tail ? `\n${tail}` : ''}`),
);
return;
}
handlers.onDone(acc);
} catch (err) {
if ((err as Error).name === 'AbortError') return;
handlers.onError(err instanceof Error ? err : new Error(String(err)));
}
}
interface ParsedFrame {
event: string;
data: Record<string, unknown>;
}
function parseFrame(frame: string): ParsedFrame | null {
const lines = frame.split('\n');
let event = 'message';
let data = '';
for (const line of lines) {
if (line.startsWith('event: ')) event = line.slice(7).trim();
else if (line.startsWith('data: ')) data += line.slice(6);
}
try {
return { event, data: JSON.parse(data) };
} catch {
return null;
}
}
// Translate a raw `agent` SSE payload (what daemon/claude-stream.js emits)
// into the UI's AgentEvent union. Keep this liberal — unknown types just
// return null so the UI ignores them instead of rendering garbage.
function translateAgentEvent(data: Record<string, unknown>): AgentEvent | null {
const t = data.type;
if (t === 'status' && typeof data.label === 'string') {
return {
kind: 'status',
label: data.label,
detail:
typeof data.model === 'string'
? data.model
: typeof data.ttftMs === 'number'
? `first token in ${Math.round((data.ttftMs as number) / 100) / 10}s`
: undefined,
};
}
if (t === 'text_delta' && typeof data.delta === 'string') {
return { kind: 'text', text: data.delta };
}
if (t === 'thinking_delta' && typeof data.delta === 'string') {
return { kind: 'thinking', text: data.delta };
}
if (t === 'thinking_start') {
return { kind: 'status', label: 'thinking' };
}
if (t === 'tool_use' && typeof data.id === 'string' && typeof data.name === 'string') {
return { kind: 'tool_use', id: data.id, name: data.name, input: data.input ?? null };
}
if (t === 'tool_result' && typeof data.toolUseId === 'string') {
return {
kind: 'tool_result',
toolUseId: data.toolUseId,
content: String(data.content ?? ''),
isError: Boolean(data.isError),
};
}
if (t === 'usage') {
const usage = (data.usage ?? {}) as Record<string, number>;
return {
kind: 'usage',
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
costUsd: typeof data.costUsd === 'number' ? data.costUsd : undefined,
durationMs: typeof data.durationMs === 'number' ? data.durationMs : undefined,
};
}
if (t === 'raw' && typeof data.line === 'string') {
return { kind: 'raw', line: data.line };
}
return null;
}
export async function saveArtifact(
identifier: string,
title: string,
html: string,
): Promise<{ url: string; path: string } | null> {
try {
const resp = await fetch('/api/artifacts/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, title, html }),
});
if (!resp.ok) return null;
return (await resp.json()) as { url: string; path: string };
} catch {
return null;
}
}