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.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user