"use client"; import { useEffect, useState } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import { deleteTaskRuns, putRun } from "@/lib/history/db"; export type ModelOption = { id: string; label: string }; export type AgentInfo = { id: string; label: string; vendor: string; available: boolean; path?: string; /** UI uses this to badge unsupported / batch adapters. Mirrors AgentProtocol on the server. */ protocol: "stdin" | "argv" | "argv-message" | "acp" | "pi-rpc"; /** Curated model list for the picker. Always begins with `default`. */ models: ModelOption[]; /** True for ACP / pi-rpc adapters where Convert returns a friendly error. */ unsupported?: boolean; }; export type ConvertStatus = "idle" | "running" | "done" | "error"; export type LogEntry = { kind: | "info" | "stderr" | "error" | "delta" | "meta" | "start" | "done" | "raw"; text: string; ts: number; /** elapsed ms from convert start */ elapsed?: number; /** structured payload for meta events */ data?: unknown; }; export type RunStats = { startedAt?: number; firstByteAt?: number; endedAt?: number; promptBytes?: number; outputBytes: number; deltaCount: number; model?: string; costUsd?: number; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheCreateTokens?: number; durationMs?: number; bin?: string; }; export type Task = { id: string; name: string; // input content: string; format: string; filename?: string; templateId: string; // output html: string; status: ConvertStatus; log: LogEntry[]; stats: RunStats; // sample-derived fields — when populated, the next convert switches to // diff-edit mode and asks the agent to make minimal changes to baseHtml // instead of regenerating from scratch. baseContent?: string; baseHtml?: string; /** id of the source sample (if any), so the gallery can mark it loaded */ sampleId?: string; /** * Pasted/uploaded image data URLs, keyed by the short `asset:` token * shown in the editor textarea. Keeps the textarea readable while * preserving the real bytes for Convert. Resolved back to inline data URLs * in `use-convert.ts` before the prompt is shipped to the agent. */ assets?: Record; /** * Past one-click deployments of this task's html. Bounded ring (latest * 5 per task to keep localStorage from ballooning). Each entry pairs * a (provider, hash-of-html-at-deploy-time, url) so the user can tell * which historical version of the HTML each public URL points to. */ deployments?: DeploymentRecord[]; // meta createdAt: number; updatedAt: number; }; export type DeploymentStatus = "ready" | "protected" | "link-delayed"; export type DeploymentRecord = { id: string; /** "vercel" | "cloudflare-pages" — keep open-ended for future providers. */ provider: string; url: string; /** Provider-side deployment id, surfaced in error messages / dashboards. */ deploymentId?: string; /** SHA-256 (first 12 hex chars) of the HTML that was deployed. Lets the * user tell which version of the page each URL points to. */ htmlHash?: string; htmlBytes?: number; status: DeploymentStatus; statusMessage?: string; deployedAt: number; reachableAt?: number; }; const emptyStats: RunStats = { outputBytes: 0, deltaCount: 0 }; function makeTask(init?: Partial): Task { const now = Date.now(); return { id: init?.id ?? `t_${now.toString(36)}_${Math.random().toString(36).slice(2, 7)}`, name: init?.name ?? "新任务", content: init?.content ?? "", format: init?.format ?? "text", filename: init?.filename, templateId: init?.templateId ?? "article-magazine", html: init?.html ?? "", status: init?.status ?? "idle", log: init?.log ?? [], stats: init?.stats ?? emptyStats, baseContent: init?.baseContent, baseHtml: init?.baseHtml, sampleId: init?.sampleId, createdAt: init?.createdAt ?? now, updatedAt: init?.updatedAt ?? now, }; } // Shipped locales. Adding a new one means dropping a dictionary into // `src/lib/i18n/locales/` and registering it in `src/lib/i18n.ts`. // We ship only en + zh-CN today and leave the rest to contributors. export type Locale = "en" | "zh-CN"; export const LOCALES: Locale[] = ["en", "zh-CN"]; export const LOCALE_LABEL: Record = { "en": "English", "zh-CN": "简体中文", }; /** * Which workspace panes are visible. * - "split" — editor on the left, preview on the right (default) * - "editor" — editor only (full-width input, hide preview) * - "preview" — preview only (after the convert is done you usually want the * full canvas — esp. for decks) */ export type LayoutMode = "split" | "editor" | "preview"; export const LAYOUT_MODES: LayoutMode[] = ["editor", "split", "preview"]; type Persisted = { tasks: Task[]; activeTaskId: string; selectedAgent: string | undefined; /** Per-agent last-picked model id. Empty / "default" means no `--model` flag. */ agentModels: Record; /** * Per-agent absolute path override. Wins over PATH scan and `envOverride` * when set. Used by users whose CLI lives somewhere our `userToolchainDirs` * heuristic doesn't cover (Scoop on Windows, custom installs, etc.). */ agentBinOverrides: Record; welcomeAck: boolean; sidebarCollapsed: boolean; /** Whether the per-task version history pane is open next to the tasks sidebar. */ historyPaneOpen: boolean; locale: Locale; layoutMode: LayoutMode; }; type State = { // multi-task tasks: Task[]; activeTaskId: string; // global agents: AgentInfo[]; selectedAgent?: string; /** Per-agent model id. "default" or absent = no --model flag. */ agentModels: Record; /** Per-agent absolute binary path override. See Persisted comment. */ agentBinOverrides: Record; welcomeAck: boolean; sidebarCollapsed: boolean; historyPaneOpen: boolean; locale: Locale; layoutMode: LayoutMode; // task lifecycle newTask: (init?: Partial>) => string; deleteTask: (id: string) => void; setActiveTask: (id: string) => void; renameTask: (id: string, name: string) => void; duplicateTask: (id: string) => string; /** * Load a sample into a brand-new task. Sets content / format / templateId / * html / baseContent / baseHtml so the user can immediately preview the * world-class HTML, edit the content, and the next Convert switches to * diff-edit mode (minimal token use). */ loadSample: (sample: { id: string; name: string; content: string; format: string; templateId: string; html: string; }) => string; // active-task field setters (operate on the currently active task) setContent: (s: string) => void; setFormat: (f: string) => void; setFilename: (f?: string) => void; setSelectedTemplate: (id: string) => void; /** * Add an image (or other binary) asset to the active task and return the * `asset:` placeholder token to insert into the textarea. The data URL * is kept off-textarea so editing stays readable. */ addAsset: (dataUrl: string) => string; // active-task convert-state setters (used by ad-hoc callers, e.g. drafts-menu) pushLog: (entry: Omit) => void; // per-task convert-state setters (used by convert pipeline so a background // task keeps streaming even when the user switches to a different one) setHtmlFor: (taskId: string, html: string) => void; appendHtmlFor: (taskId: string, chunk: string) => void; resetHtmlFor: (taskId: string) => void; setStatusFor: (taskId: string, s: ConvertStatus) => void; pushLogFor: (taskId: string, entry: Omit) => void; clearLogFor: (taskId: string) => void; resetStatsFor: (taskId: string) => void; patchStatsFor: (taskId: string, patch: Partial) => void; /** snapshot the current (content, html) as the new diff-edit baseline */ commitBaseFor: (taskId: string) => void; /** record a successful one-click deployment of the task's html. */ pushDeploymentFor: (taskId: string, deployment: DeploymentRecord) => void; /** delete a past deployment record (UI only; the public URL remains). */ removeDeploymentFor: (taskId: string, deploymentRecordId: string) => void; // global setters setAgents: (a: AgentInfo[]) => void; setSelectedAgent: (id?: string) => void; setAgentModel: (agentId: string, modelId: string) => void; /** Set / clear an absolute path override for one agent. Empty string clears. */ setAgentBinOverride: (agentId: string, path: string) => void; setWelcomeAck: (v: boolean) => void; setSidebarCollapsed: (v: boolean) => void; setHistoryPaneOpen: (v: boolean) => void; setLocale: (l: Locale) => void; setLayoutMode: (m: LayoutMode) => void; }; function patchTask(tasks: Task[], id: string, patch: Partial | ((t: Task) => Partial)): Task[] { let changed = false; const next = tasks.map((t) => { if (t.id !== id) return t; const p = typeof patch === "function" ? patch(t) : patch; changed = true; return { ...t, ...p, updatedAt: Date.now() }; }); return changed ? next : tasks; } const initialTask = makeTask({ name: "任务 1" }); export const useStore = create()( persist( (set, get) => ({ tasks: [initialTask], activeTaskId: initialTask.id, agents: [], selectedAgent: undefined, agentModels: {}, agentBinOverrides: {}, welcomeAck: false, sidebarCollapsed: false, historyPaneOpen: false, locale: "en", layoutMode: "split", newTask: (init) => { const tasks = get().tasks; const n = tasks.length + 1; const t = makeTask({ name: init?.name ?? `任务 ${n}`, ...init }); set({ tasks: [...tasks, t], activeTaskId: t.id }); return t.id; }, deleteTask: (id) => { const { tasks, activeTaskId } = get(); if (tasks.length <= 1) { // never leave 0 tasks — replace with a fresh empty one const fresh = makeTask({ name: "任务 1" }); set({ tasks: [fresh], activeTaskId: fresh.id }); void deleteTaskRuns(id).catch(() => {}); return; } const next = tasks.filter((t) => t.id !== id); const nextActive = activeTaskId === id ? next[Math.max(0, tasks.findIndex((t) => t.id === id) - 1)]?.id ?? next[0].id : activeTaskId; set({ tasks: next, activeTaskId: nextActive }); void deleteTaskRuns(id).catch(() => {}); }, setActiveTask: (id) => { if (get().tasks.some((t) => t.id === id)) set({ activeTaskId: id }); }, renameTask: (id, name) => { set((s) => ({ tasks: patchTask(s.tasks, id, { name: name.trim() || "未命名" }) })); }, duplicateTask: (id) => { const src = get().tasks.find((t) => t.id === id); if (!src) return id; const copy = makeTask({ name: `${src.name} · 副本`, content: src.content, format: src.format, filename: src.filename, templateId: src.templateId, }); set((s) => ({ tasks: [...s.tasks, copy], activeTaskId: copy.id })); return copy.id; }, loadSample: (sample) => { const { tasks, activeTaskId } = get(); const active = tasks.find((t) => t.id === activeTaskId); // if the active task is empty (no content, no html), reuse it; otherwise create a new task const reuseActive = active && !active.content.trim() && !active.html; const t = makeTask({ id: reuseActive ? active!.id : undefined, name: sample.name, content: sample.content, format: sample.format, templateId: sample.templateId, html: sample.html, baseContent: sample.content, baseHtml: sample.html, sampleId: sample.id, status: sample.html ? "done" : "idle", }); if (reuseActive) { set((s) => ({ tasks: patchTask(s.tasks, active!.id, t), activeTaskId: active!.id })); return active!.id; } set((s) => ({ tasks: [...s.tasks, t], activeTaskId: t.id })); return t.id; }, setContent: (s) => set((st) => ({ tasks: patchTask(st.tasks, st.activeTaskId, { content: s }) })), setFormat: (f) => set((st) => ({ tasks: patchTask(st.tasks, st.activeTaskId, { format: f }) })), setFilename: (f) => set((st) => ({ tasks: patchTask(st.tasks, st.activeTaskId, { filename: f }) })), setSelectedTemplate: (id) => set((st) => ({ tasks: patchTask(st.tasks, st.activeTaskId, { templateId: id }) })), addAsset: (dataUrl: string) => { const id = `a_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; set((st) => ({ tasks: patchTask(st.tasks, st.activeTaskId, (t) => ({ assets: { ...(t.assets ?? {}), [id]: dataUrl }, })), })); return id; }, pushLog: (entry) => set((st) => ({ tasks: patchTask(st.tasks, st.activeTaskId, (t) => ({ log: [...t.log.slice(-400), { ...entry, ts: Date.now() }], })), })), setHtmlFor: (taskId, html) => set((st) => ({ tasks: patchTask(st.tasks, taskId, { html }) })), appendHtmlFor: (taskId, chunk) => set((st) => ({ tasks: patchTask(st.tasks, taskId, (t) => ({ html: t.html + chunk, stats: { ...t.stats, outputBytes: t.stats.outputBytes + chunk.length, deltaCount: t.stats.deltaCount + 1, firstByteAt: t.stats.firstByteAt ?? Date.now(), }, })), })), resetHtmlFor: (taskId) => set((st) => ({ tasks: patchTask(st.tasks, taskId, { html: "" }) })), setStatusFor: (taskId, s) => set((st) => ({ tasks: patchTask(st.tasks, taskId, { status: s }) })), pushLogFor: (taskId, entry) => set((st) => ({ tasks: patchTask(st.tasks, taskId, (t) => ({ log: [...t.log.slice(-400), { ...entry, ts: Date.now() }], })), })), clearLogFor: (taskId) => set((st) => ({ tasks: patchTask(st.tasks, taskId, { log: [] }) })), resetStatsFor: (taskId) => set((st) => ({ tasks: patchTask(st.tasks, taskId, { stats: emptyStats }) })), patchStatsFor: (taskId, patch) => set((st) => ({ tasks: patchTask(st.tasks, taskId, (t) => ({ stats: { ...t.stats, ...patch } })), })), commitBaseFor: (taskId) => { // Snapshot the freshly converted (content, html) as the next diff-edit // baseline, then archive it to IndexedDB so the history pane has a // version to render. IDB write is fire-and-forget — history is a // nice-to-have and must not block the convert pipeline. type Snap = { content: string; html: string; stats: RunStats; templateId: string }; let snapshot: Snap | null = null; set((st) => ({ tasks: patchTask(st.tasks, taskId, (t) => { snapshot = { content: t.content, html: t.html, stats: t.stats, templateId: t.templateId, }; return { baseContent: t.content, baseHtml: t.html }; }), })); const snap = snapshot as Snap | null; if (snap && snap.html) { void putRun({ taskId, html: snap.html, content: snap.content, stats: snap.stats, templateId: snap.templateId, ts: Date.now(), }).catch(() => { // history is best-effort; ignore quota / private-mode failures }); } }, pushDeploymentFor: (taskId, deployment) => set((st) => ({ tasks: patchTask(st.tasks, taskId, (t) => { const prev = t.deployments ?? []; // Bounded ring: latest 5 per task. Older entries roll off so // localStorage doesn't bloat with stale public URLs over time. const next = [deployment, ...prev].slice(0, 5); return { deployments: next }; }), })), removeDeploymentFor: (taskId, deploymentRecordId) => set((st) => ({ tasks: patchTask(st.tasks, taskId, (t) => ({ deployments: (t.deployments ?? []).filter( (d) => d.id !== deploymentRecordId, ), })), })), setAgents: (a) => set({ agents: a }), setSelectedAgent: (id) => set({ selectedAgent: id }), setAgentModel: (agentId, modelId) => set((s) => ({ agentModels: { ...s.agentModels, [agentId]: modelId } })), setAgentBinOverride: (agentId, path) => set((s) => { const next = { ...s.agentBinOverrides }; if (path.trim()) next[agentId] = path.trim(); else delete next[agentId]; return { agentBinOverrides: next }; }), setWelcomeAck: (v) => set({ welcomeAck: v }), setSidebarCollapsed: (v) => set({ sidebarCollapsed: v }), setHistoryPaneOpen: (v) => set({ historyPaneOpen: v }), setLocale: (l) => set({ locale: l }), setLayoutMode: (m) => set({ layoutMode: m }), }), { // Legacy key from the old "HTML Everything" brand; do NOT rename — every // existing user's saved tasks live under this localStorage key. name: "html-everything-store", version: 7, partialize: (s): Persisted => ({ tasks: s.tasks.map((t) => ({ ...t, // never persist running status — a dropped tab can't keep the stream status: (t.status === "running" ? "idle" : t.status) as ConvertStatus, })), activeTaskId: s.activeTaskId, selectedAgent: s.selectedAgent, agentModels: s.agentModels, agentBinOverrides: s.agentBinOverrides, welcomeAck: s.welcomeAck, sidebarCollapsed: s.sidebarCollapsed, historyPaneOpen: s.historyPaneOpen, locale: s.locale, layoutMode: s.layoutMode, }), migrate: (persisted, fromVersion): Persisted => { // v1 → v2: wrap top-level content/format/filename/selectedTemplate into a single task. if (fromVersion < 2 && persisted && typeof persisted === "object") { const old = persisted as Record; const t = makeTask({ name: "任务 1", content: typeof old.content === "string" ? old.content : "", format: typeof old.format === "string" ? old.format : "text", filename: typeof old.filename === "string" ? old.filename : undefined, templateId: typeof old.selectedTemplate === "string" ? old.selectedTemplate : "article-magazine", }); persisted = { tasks: [t], activeTaskId: t.id, selectedAgent: typeof old.selectedAgent === "string" ? old.selectedAgent : undefined, welcomeAck: !!old.welcomeAck, sidebarCollapsed: false, }; } // v2 → v3: introduce agentModels map. if (fromVersion < 3 && persisted && typeof persisted === "object") { const p = persisted as Record; if (!p.agentModels || typeof p.agentModels !== "object") { p.agentModels = {}; } } // v3 → v4: introduce UI locale (default English). if (fromVersion < 4 && persisted && typeof persisted === "object") { const p = persisted as Record; if (!p.locale || typeof p.locale !== "string" || !(LOCALES as string[]).includes(p.locale as string)) { p.locale = "en"; } } // v4 → v5: introduce workspace layoutMode (default split). if (fromVersion < 5 && persisted && typeof persisted === "object") { const p = persisted as Record; if ( !p.layoutMode || typeof p.layoutMode !== "string" || !(LAYOUT_MODES as string[]).includes(p.layoutMode as string) ) { p.layoutMode = "split"; } } // v5 → v6: introduce agentBinOverrides map. if (fromVersion < 6 && persisted && typeof persisted === "object") { const p = persisted as Record; if (!p.agentBinOverrides || typeof p.agentBinOverrides !== "object") { p.agentBinOverrides = {}; } } // v6 → v7: per-task deployments[] ring buffer for one-click // publishing. Initialize to undefined on existing tasks; the new // deploy code reads `t.deployments ?? []`, so explicit init is // unnecessary, but we leave the migration entry here so the // version bump is auditable. if (fromVersion < 7 && persisted && typeof persisted === "object") { const p = persisted as Record; if (Array.isArray(p.tasks)) { for (const t of p.tasks as Array>) { if (!Array.isArray(t.deployments)) t.deployments = []; } } } return persisted as Persisted; }, }, ), ); /** Returns true once the persist middleware has finished restoring state. */ export function usePersistHydrated(): boolean { const [hydrated, setHydrated] = useState(false); useEffect(() => { setHydrated(useStore.persist.hasHydrated()); const unsub = useStore.persist.onFinishHydration(() => setHydrated(true)); return unsub; }, []); return hydrated; } // ── selectors / hooks for active task ────────────────────────────── export const selectActiveTask = (s: State): Task | undefined => s.tasks.find((t) => t.id === s.activeTaskId); export function useActiveTask(): Task | undefined { return useStore(selectActiveTask); } export function useActiveField(field: K): Task[K] | undefined { return useStore((s) => selectActiveTask(s)?.[field]); }