"use client"; import { useCallback } from "react"; import { useStore } from "./store"; import { summarizeForAgent } from "./parsers/auto"; type ConvertReq = { taskId: string; agent: string; templateId: string; content: string; format?: string; /** Optional model override. "default" / undefined → no --model flag. */ model?: string; }; /** prefix logged when the run is sent in diff-edit mode (vs full regeneration) */ const DIFF_LOG_PREFIX = "🔁 diff-edit 模式"; // per-task abort controllers — multiple tasks can stream concurrently const controllers = new Map(); export function useConvert() { const cancel = useCallback((taskId: string) => { const ctl = controllers.get(taskId); if (ctl) { ctl.abort(); controllers.delete(taskId); } useStore.getState().setStatusFor(taskId, "idle"); }, []); const run = useCallback( async (req: ConvertReq) => { const { taskId } = req; cancel(taskId); const ctl = new AbortController(); controllers.set(taskId, ctl); const store = useStore.getState(); store.setStatusFor(taskId, "running"); store.resetHtmlFor(taskId); store.clearLogFor(taskId); store.resetStatsFor(taskId); const startedAt = Date.now(); store.patchStatsFor(taskId, { startedAt }); // Inline `asset:` placeholders (created by useUploadFile for // images) back into real `data:image/...` URLs before the agent // sees the prompt. Editor stays readable; agent gets the bytes. const taskWithAssets = store.tasks.find((t) => t.id === taskId); const assets = taskWithAssets?.assets ?? {}; const inlinedContent = Object.keys(assets).length ? req.content.replace(/asset:([a-z0-9_]+)/gi, (m, id) => assets[id] ?? m) : req.content; const summary = summarizeForAgent(inlinedContent); const enrichedContent = summary.preview && summary.format !== "markdown" && summary.format !== "html" && summary.format !== "text" ? `${summary.preview}\n\n--- 原始内容 ---\n${summary.raw}` : summary.raw; const useModel = req.model && req.model !== "default" ? req.model : undefined; const binOverride = store.agentBinOverrides[req.agent]?.trim() || undefined; // diff-edit mode: if the task was loaded from a sample (or the user has // already converted once) AND the content has actually changed, we ship // the previous (baseContent, baseHtml) so the API can ask the agent for // minimal edits instead of a fresh regeneration. This preserves the // design system AND saves output tokens. const task = store.tasks.find((t) => t.id === taskId); const isEdit = !!task?.baseHtml && !!task?.baseContent && task.baseContent.trim() !== req.content.trim(); const editPayload = isEdit ? { editFromHtml: task!.baseHtml!, editFromContent: task!.baseContent!, } : null; const payload = { agent: req.agent, templateId: req.templateId, content: enrichedContent, format: req.format ?? summary.format, ...(useModel ? { model: useModel } : {}), ...(binOverride ? { binOverride } : {}), ...(editPayload ?? {}), }; const sizeNote = `输入 ${enrichedContent.length.toLocaleString()} 字符 (${summary.format})`; store.pushLogFor(taskId, { kind: "info", text: isEdit ? `${DIFF_LOG_PREFIX} · ${req.agent}${useModel ? ` · 模型 ${useModel}` : ""} · 模板 ${req.templateId} · ${sizeNote} · 原 HTML ${(task!.baseHtml!.length / 1024).toFixed(1)} KB` : `准备调用 ${req.agent}${useModel ? ` · 模型 ${useModel}` : ""} · 模板 ${req.templateId} · ${sizeNote}`, }); try { const res = await fetch("/api/convert", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: ctl.signal, }); if (!res.ok || !res.body) { const text = await res.text().catch(() => res.statusText); throw new Error(`HTTP ${res.status}: ${text}`); } const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ""; let lastEvent = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let blank: number; while ((blank = buf.indexOf("\n\n")) !== -1) { const block = buf.slice(0, blank); buf = buf.slice(blank + 2); const lines = block.split("\n"); let event = lastEvent; const dataLines: string[] = []; for (const l of lines) { if (l.startsWith("event:")) event = l.slice(6).trim(); else if (l.startsWith("data:")) dataLines.push(l.slice(5).trim()); } lastEvent = event; if (!dataLines.length) continue; const dataStr = dataLines.join("\n"); let data: unknown; try { data = JSON.parse(dataStr); } catch { continue; } handleEvent(taskId, event, data, startedAt); } } const endedAt = Date.now(); useStore.getState().patchStatsFor(taskId, { endedAt, durationMs: endedAt - startedAt }); useStore.getState().setStatusFor(taskId, "done"); // record the just-finished (content, html) as the new diff-edit baseline // so the user's next edit goes through diff mode instead of full regen useStore.getState().commitBaseFor(taskId); } catch (err) { if ((err as Error)?.name === "AbortError") { useStore.getState().pushLogFor(taskId, { kind: "info", text: "已取消" }); useStore.getState().setStatusFor(taskId, "idle"); return; } useStore.getState().pushLogFor(taskId, { kind: "error", text: (err as Error)?.message ?? String(err), }); useStore.getState().setStatusFor(taskId, "error"); } finally { if (controllers.get(taskId) === ctl) controllers.delete(taskId); } }, [cancel], ); return { run, cancel }; } function handleEvent(taskId: string, event: string, data: unknown, startedAt: number) { const d = data as Record; const store = useStore.getState(); const elapsed = Date.now() - startedAt; switch (event) { case "start": { const bin = String(d.bin ?? ""); const promptBytes = Number(d.promptBytes ?? 0); store.patchStatsFor(taskId, { bin, promptBytes }); store.pushLogFor(taskId, { kind: "start", elapsed, text: `spawn ${shortPath(bin)} · prompt ${formatBytes(promptBytes)}`, data: { argv: d.argv }, }); break; } case "delta": { if (typeof d.text === "string" && d.text) { const task = store.tasks.find((t) => t.id === taskId); const isFirstChunk = !task?.stats.firstByteAt; store.appendHtmlFor(taskId, d.text); if (isFirstChunk) { store.pushLogFor(taskId, { kind: "delta", elapsed, text: `收到首个 HTML 片段 (${formatBytes(d.text.length)})`, }); } } break; } case "html": { // Agent decided to write the HTML to a file via the Write tool instead // of streaming it. The parser rescued the file's content from the // tool_use input — REPLACE the accumulated text (preamble + "已输出至 …" // confirmation) so the preview shows the real document. if (typeof d.text === "string") { store.setHtmlFor(taskId, d.text); const len = d.text.length; const prev = useStore.getState().tasks.find((t) => t.id === taskId)?.stats; store.patchStatsFor(taskId, { outputBytes: len, firstByteAt: prev?.firstByteAt ?? Date.now(), }); store.pushLogFor(taskId, { kind: "info", elapsed, text: `📄 从 Write 工具输入恢复 HTML (${len.toLocaleString()} 字节)`, }); } break; } case "meta": { const key = String(d.key); const value = d.value; const patch: Partial = {}; if (key === "model" && typeof value === "string") patch.model = value; if (key === "duration_ms" && typeof value === "number") patch.durationMs = value; if (key === "cost_usd" && typeof value === "number") patch.costUsd = value; if (key === "usage" && value && typeof value === "object") { const u = value as Record; patch.inputTokens = u.input_tokens; patch.outputTokens = u.output_tokens; patch.cacheReadTokens = u.cache_read_input_tokens; patch.cacheCreateTokens = u.cache_creation_input_tokens; } if (Object.keys(patch).length) store.patchStatsFor(taskId, patch); store.pushLogFor(taskId, { kind: "meta", elapsed, text: formatMeta(key, value), data: value, }); break; } case "stderr": { if (typeof d.text === "string") store.pushLogFor(taskId, { kind: "stderr", elapsed, text: String(d.text).trim(), }); break; } case "raw": { if (typeof d.text === "string") store.pushLogFor(taskId, { kind: "raw", elapsed, text: String(d.text) }); break; } case "done": { store.pushLogFor(taskId, { kind: "done", elapsed, text: `agent 进程退出 (exit=${d.code ?? "?"})`, }); break; } case "error": { store.pushLogFor(taskId, { kind: "error", elapsed, text: String(d.message ?? "agent error"), }); break; } } } function formatBytes(n: number) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(2)} MB`; } function shortPath(p: string) { if (!p) return ""; return p.replace(/^.*\//, "…/"); } function formatMeta(key: string, value: unknown): string { if (key === "model") return `model = ${value}`; if (key === "session") return `session = ${value}`; if (key === "cwd") return `cwd = ${value}`; if (key === "duration_ms") return `duration = ${value} ms`; if (key === "cost_usd") return `cost ≈ $${(value as number).toFixed(4)}`; if (key === "result") return `result = ${value}`; if (key === "rate_limit" && value && typeof value === "object") { const r = value as { status?: string; rateLimitType?: string }; return `rate-limit: ${r.status} (${r.rateLimitType})`; } if (key === "usage" && value && typeof value === "object") { const u = value as Record; const parts: string[] = []; if (u.input_tokens) parts.push(`in=${u.input_tokens}`); if (u.output_tokens) parts.push(`out=${u.output_tokens}`); if (u.cache_read_input_tokens) parts.push(`cache-read=${u.cache_read_input_tokens}`); if (u.cache_creation_input_tokens) parts.push(`cache-create=${u.cache_creation_input_tokens}`); return `usage: ${parts.join(" · ")}`; } if (key === "thinking") return `thinking ${(value as string).slice(0, 80)}…`; return `${key}: ${typeof value === "object" ? JSON.stringify(value).slice(0, 120) : String(value)}`; }