diff --git a/README.md b/README.md index 664d905e..c3955c9b 100644 --- a/README.md +++ b/README.md @@ -171,18 +171,59 @@ On startup we scan `PATH` (including `~/.local/bin`, `~/.bun/bin`, `/opt/homebre | Agent | Detection binary | Invocation | |---|---|---| | **Claude Code** | `claude` | `claude -p --output-format stream-json` | +| **OpenClaw** | `openclaw` | `openclaw agent --local --json --agent --message ` | | **OpenAI Codex** | `codex` | `codex exec --json --sandbox workspace-write` | | **Cursor Agent** | `cursor-agent` | `cursor-agent --print --output-format stream-json --force --trust` | | **Gemini CLI** | `gemini` | `gemini --output-format stream-json --yolo` | | **GitHub Copilot CLI** | `copilot` | `copilot --allow-all-tools --output-format json` | | **OpenCode** | `opencode-cli` / `opencode` | `opencode run --format json --dangerously-skip-permissions -` | | **Qwen Coder** | `qwen` | `qwen --yolo -` | +| **Qoder CLI** | `qodercli` | `qodercli -p --output-format stream-json --yolo` | +| **DeepSeek TUI** | `deepseek` | `deepseek exec --auto ` | | **Aider** | `aider` | `aider --no-pretty --no-stream --yes-always --message-file -` | > The detection strategy and per-CLI adapter shape are borrowed directly from [`nexu-io/open-design`](https://github.com/nexu-io/open-design) and [`multica-ai/multica`](https://github.com/multica-ai/multica): one privileged process spawns CLIs, JSON-line is the wire protocol, every CLI gets a thin adapter in [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts). If you've already done `claude login` / `cursor login` / `gemini auth` in your terminal, HTML Anything reuses that session. **No second copy of the API key required.** +### Advanced agent / model configuration + +HTML Anything keeps a curated model picker per agent, but you can extend it without editing source: + +```bash +# Add models globally, or only for one agent. Use "id=Label" for prettier names. +HTML_ANYTHING_MODELS="my-fast-model=My Fast Model,provider/custom-model" +HTML_ANYTHING_MODELS_CODEX="gpt-5.2,gpt-5.3-codex=Codex 5.3" + +# Add fallback binary names for an existing adapter. +HTML_ANYTHING_BINS_CODEX="codex-nightly,openai-codex" + +# Override a binary. Values can be absolute paths or names resolvable on PATH. +HTML_ANYTHING_BIN_CODEX="/opt/homebrew/bin/codex" +CODEX_BIN="codex-next" + +# Optional proxy for agent subprocesses. It is never enabled by default. +HTML_ANYTHING_AGENT_PROXY="http://127.0.0.1:7890" +HTML_ANYTHING_AGENT_PROXY_CODEX="none" # explicitly strip proxy env for Codex +``` + +You can also register a custom agent that reuses an existing adapter's invocation/parser: + +```bash +HTML_ANYTHING_EXTRA_AGENTS='[ + { + "id": "codex-nightly", + "label": "Codex Nightly", + "vendor": "OpenAI", + "bin": "codex-nightly", + "adapter": "codex", + "models": ["default", "gpt-5.3-codex=GPT-5.3 Codex"] + } +]' +``` + +`adapter` must point at one of the implemented adapters (`claude`, `openclaw`, `codex`, `cursor-agent`, `gemini`, `copilot`, `opencode`, `qwen`, `qoder`, `deepseek`, `aider`). ACP / pi-rpc agents are detected for visibility, but are not invokable until their protocol adapters land. + ## Skills **75 skills under [`src/lib/templates/skills/`](src/lib/templates/skills/)**, each a folder following the Claude Code [`SKILL.md`](https://docs.anthropic.com/en/docs/claude-code/skills) convention plus an extended frontmatter (`mode` · `scenario` · `surface` · `preview` · `design_system`). diff --git a/src/app/api/convert/route.ts b/src/app/api/convert/route.ts index b4e03678..3f83e00f 100644 --- a/src/app/api/convert/route.ts +++ b/src/app/api/convert/route.ts @@ -93,7 +93,14 @@ export async function POST(req: NextRequest) { format, }); } else { - prompt = assemblePrompt({ body: skill.body, content, format }); + prompt = assemblePrompt({ + body: skill.body, + content, + format, + exampleHtml: skill.exampleHtml, + exampleName: skill.example?.name, + templateName: skill.zhName, + }); } const abortCtl = new AbortController(); req.signal?.addEventListener("abort", () => abortCtl.abort(), { once: true }); diff --git a/src/app/fonts/inter-latin.woff2 b/src/app/fonts/inter-latin.woff2 new file mode 100644 index 00000000..937631c0 Binary files /dev/null and b/src/app/fonts/inter-latin.woff2 differ diff --git a/src/app/fonts/inter-tight-latin.woff2 b/src/app/fonts/inter-tight-latin.woff2 new file mode 100644 index 00000000..228154ff Binary files /dev/null and b/src/app/fonts/inter-tight-latin.woff2 differ diff --git a/src/app/fonts/jetbrains-mono-latin.woff2 b/src/app/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 00000000..2ca6ac60 Binary files /dev/null and b/src/app/fonts/jetbrains-mono-latin.woff2 differ diff --git a/src/app/fonts/playfair-display-italic-latin.woff2 b/src/app/fonts/playfair-display-italic-latin.woff2 new file mode 100644 index 00000000..7bb77681 Binary files /dev/null and b/src/app/fonts/playfair-display-italic-latin.woff2 differ diff --git a/src/app/fonts/playfair-display-latin.woff2 b/src/app/fonts/playfair-display-latin.woff2 new file mode 100644 index 00000000..1636e501 Binary files /dev/null and b/src/app/fonts/playfair-display-latin.woff2 differ diff --git a/src/app/globals.css b/src/app/globals.css index 242fa9bc..ef52c354 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -46,7 +46,7 @@ html, body { height: 100%; - font-family: var(--font-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: var(--font-sans), -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "Noto Sans SC", "PingFang SC", sans-serif; font-feature-settings: "ss01", "cv11"; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 830814d9..c2a12b6a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,30 +1,43 @@ import type { Metadata } from "next"; -import { Inter, Inter_Tight, Playfair_Display, JetBrains_Mono } from "next/font/google"; +import localFont from "next/font/local"; import "./globals.css"; -const inter = Inter({ +const inter = localFont({ + src: "./fonts/inter-latin.woff2", variable: "--font-sans", - subsets: ["latin"], - weight: ["300", "400", "500", "600"], + weight: "300 600", + display: "swap", }); -const interTight = Inter_Tight({ +const interTight = localFont({ + src: "./fonts/inter-tight-latin.woff2", variable: "--font-display", - subsets: ["latin"], - weight: ["400", "500", "600", "700", "800", "900"], + weight: "400 900", + display: "swap", }); -const playfair = Playfair_Display({ +const playfair = localFont({ + src: [ + { + path: "./fonts/playfair-display-latin.woff2", + weight: "400 700", + style: "normal", + }, + { + path: "./fonts/playfair-display-italic-latin.woff2", + weight: "400 700", + style: "italic", + }, + ], variable: "--font-serif", - subsets: ["latin"], - weight: ["400", "500", "600", "700"], - style: ["italic", "normal"], + display: "swap", }); -const mono = JetBrains_Mono({ +const mono = localFont({ + src: "./fonts/jetbrains-mono-latin.woff2", variable: "--font-mono", - subsets: ["latin"], - weight: ["400", "500"], + weight: "400 500", + display: "swap", }); export const metadata: Metadata = { diff --git a/src/components/preview-pane.tsx b/src/components/preview-pane.tsx index ee8a531f..31f0f0ca 100644 --- a/src/components/preview-pane.tsx +++ b/src/components/preview-pane.tsx @@ -1,19 +1,20 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useStore, selectActiveTask, type LogEntry, type RunStats } from "@/lib/store"; +import { useStore, selectActiveTask, type LogEntry, type RunStats, type ResultSnapshot } from "@/lib/store"; import { useT, type DictKey } from "@/lib/i18n"; import { previewHtml, extractHtml } from "@/lib/extract-html"; import { isDeck } from "@/lib/deck"; import { DeckViewer } from "./deck-viewer"; -type PreviewTab = "preview" | "deck" | "code" | "log"; +type PreviewTab = "preview" | "deck" | "code" | "log" | "history"; const PREVIEW_TAB_KEY: Record = { preview: "preview.tab.preview", deck: "preview.tab.deck", code: "preview.tab.code", log: "preview.tab.log", + history: "preview.tab.history", }; const STATUS_KEY: Record = { @@ -37,6 +38,7 @@ const CHIP_KEYS: DictKey[] = [ // stable references so the selector doesn't force re-render when active task is missing const EMPTY_LOG: LogEntry[] = []; const EMPTY_STATS: RunStats = { outputBytes: 0, deltaCount: 0 }; +const EMPTY_RESULTS: ResultSnapshot[] = []; export function PreviewPane({ iframeRef, @@ -47,9 +49,12 @@ export function PreviewPane({ const status = useStore((s) => selectActiveTask(s)?.status ?? "idle"); const log = useStore((s) => selectActiveTask(s)?.log ?? EMPTY_LOG); const stats = useStore((s) => selectActiveTask(s)?.stats ?? EMPTY_STATS); + const results = useStore((s) => selectActiveTask(s)?.results ?? EMPTY_RESULTS); const activeTaskId = useStore((s) => s.activeTaskId); const templateId = useStore((s) => selectActiveTask(s)?.templateId); const setHtmlFor = useStore((s) => s.setHtmlFor); + const restoreResultFor = useStore((s) => s.restoreResultFor); + const deleteResultFor = useStore((s) => s.deleteResultFor); // Template example HTML — fetched lazily when no task.html exists yet, so // switching templates in the picker shows that template's pre-shipped @@ -212,7 +217,7 @@ export function PreviewPane({ style={{ borderBottom: "1px solid var(--line-faint)", background: "var(--surface)" }} >
- {(["preview", ...(deckMode ? (["deck"] as const) : []), "code", "log"] as const).map((id) => ( + {(["preview", ...(deckMode ? (["deck"] as const) : []), "code", "log", "history"] as const).map((id) => ( ))}
@@ -340,6 +356,17 @@ export function PreviewPane({ {tab === "log" && ( )} + {tab === "history" && ( + { + restoreResultFor(activeTaskId, resultId); + setTab("preview"); + }} + onDelete={(resultId) => deleteResultFor(activeTaskId, resultId)} + /> + )} {/* Fullscreen exit chip — only shown when this pane owns the fullscreen. Deck mode is excluded because DeckViewer manages its own fullscreen @@ -509,6 +536,97 @@ function LogPanel({ ); } +function HistoryPanel({ + results, + currentHtml, + onRestore, + onDelete, +}: { + results: ResultSnapshot[]; + currentHtml: string; + onRestore: (resultId: string) => void; + onDelete: (resultId: string) => void; +}) { + const t = useT(); + if (results.length === 0) { + return ( +
+
{t("preview.history.empty")}
+
+ ); + } + return ( +
+
+ {results.map((result) => { + const isCurrent = currentHtml.trim() === result.html.trim(); + const size = `${(result.html.length / 1024).toFixed(1)} KB`; + const date = new Date(result.createdAt).toLocaleString(); + return ( +
+
+
+
+
{result.name}
+ {isCurrent && ( + + {t("preview.history.current")} + + )} +
+
+ {t("preview.history.meta", { + agent: result.model ? `${result.agent} / ${result.model}` : result.agent, + size, + date, + })} +
+
+
+ + +
+
+
+                {result.content.replace(/\s+/g, " ").trim() || result.html.slice(0, 180)}
+              
+
+ ); + })} +
+
+ ); +} + function LogLine({ entry }: { entry: LogEntry }) { const colorMap: Record = { info: { tag: "INFO", bg: "rgba(35,72,184,0.10)", fg: "var(--blue)", text: "var(--ink)" }, diff --git a/src/components/tasks-sidebar.tsx b/src/components/tasks-sidebar.tsx index fd60067e..b215ae00 100644 --- a/src/components/tasks-sidebar.tsx +++ b/src/components/tasks-sidebar.tsx @@ -45,6 +45,8 @@ export function TasksSidebar() { ? tasks.filter((task) => { if (task.name.toLowerCase().includes(trimmed)) return true; if (task.content.toLowerCase().includes(trimmed)) return true; + if (task.html.toLowerCase().includes(trimmed)) return true; + if ((task.results ?? []).some((result) => result.html.toLowerCase().includes(trimmed))) return true; const tpl = templates?.find((x) => x.id === task.templateId); if (tpl) { if (tpl.zhName.toLowerCase().includes(trimmed)) return true; @@ -283,6 +285,7 @@ function TaskCard({ const preview = task.content.replace(/\s+/g, " ").trim().slice(0, 64); const sizeKB = task.html ? `${(task.html.length / 1024).toFixed(1)} KB` : null; + const resultCount = task.results?.length ?? 0; return (
{sizeKB} )} + {resultCount > 0 && ( + <> + · + + {t("tasks.resultCount", { n: resultCount })} + + + )}
{preview || {t("tasks.emptyContent")}} diff --git a/src/lib/agents/argv.ts b/src/lib/agents/argv.ts index 2e1ea9ee..e8eff6e6 100644 --- a/src/lib/agents/argv.ts +++ b/src/lib/agents/argv.ts @@ -52,6 +52,9 @@ export function buildArgv(agent: string, _opts: AgentArgvOpts = {}): string[] { return [ "exec", "--json", + "--ephemeral", + "--ignore-user-config", + "--ignore-rules", "--skip-git-repo-check", "--sandbox", "workspace-write", @@ -133,15 +136,40 @@ export function buildArgv(agent: string, _opts: AgentArgvOpts = {}): string[] { } } -export function envFor(agent: string): NodeJS.ProcessEnv { +function envKey(id: string): string { + return id.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase(); +} + +export function envFor(agent: string, agentId = agent): NodeJS.ProcessEnv { const base = { ...process.env }; if (agent === "gemini") base.GEMINI_CLI_TRUST_WORKSPACE = "true"; + if (agent === "codex") { + const proxy = + process.env[`HTML_ANYTHING_AGENT_PROXY_${envKey(agentId)}`] ?? + process.env.HTML_ANYTHING_AGENT_PROXY; + if (proxy && /^(none|off|false|0)$/i.test(proxy)) { + delete base.HTTP_PROXY; + delete base.HTTPS_PROXY; + delete base.http_proxy; + delete base.https_proxy; + delete base.ALL_PROXY; + delete base.all_proxy; + } else if (proxy) { + base.HTTP_PROXY ??= proxy; + base.HTTPS_PROXY ??= proxy; + base.http_proxy ??= proxy; + base.https_proxy ??= proxy; + delete base.ALL_PROXY; + delete base.all_proxy; + } + } return base; } export type AgentParse = | { kind: "delta"; text: string } | { kind: "meta"; key: string; value: unknown } + | { kind: "error"; message: string } /** * Canonical HTML rescued from a file-write tool call (e.g. Claude's `Write` * tool). Replaces any previously streamed text — the preamble like @@ -245,6 +273,10 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age const obj = parsed as Record; const out: AgentParse[] = []; + if (obj.type === "error" && typeof obj.message === "string") { + out.push({ kind: "error", message: obj.message }); + } + if (agent === "claude") { // Init / system metadata if (obj.type === "system" && obj.subtype === "init") { @@ -299,9 +331,13 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age if (agent === "codex") { if (obj.type === "item.completed" && obj.item && typeof obj.item === "object") { - const item = obj.item as { item_type?: string; text?: string }; - if (item.item_type === "assistant_message" && typeof item.text === "string") { - out.push({ kind: "delta", text: item.text }); + const item = obj.item as { item_type?: string; type?: string }; + const itemType = item.item_type ?? item.type; + const text = extractCodexText(item); + if (text) { + out.push({ kind: "delta", text }); + } else if (itemType === "assistant_message" || itemType === "agent_message" || itemType === "message") { + out.push({ kind: "noise" }); } } if (obj.type === "item.delta" && typeof obj.text === "string") { @@ -313,7 +349,7 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age out.push({ kind: "delta", text: msg.message }); } } - if (obj.type === "task_complete" && obj.usage) { + if ((obj.type === "task_complete" || obj.type === "turn.completed") && obj.usage) { out.push({ kind: "meta", key: "usage", value: obj.usage }); } } @@ -405,6 +441,48 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age return out; } +function extractCodexText(value: unknown): string { + const hits: string[] = []; + const htmlHits: string[] = []; + const visit = (node: unknown, key = "") => { + if (!node) return; + if (typeof node === "string") { + if (/<\/html>\s*$/i.test(node.trim()) || /]/i.test(node)) { + htmlHits.push(node); + } else if ( + key === "text" || + key === "message" || + key === "content" || + key === "output_text" || + key === "final_answer" + ) { + hits.push(node); + } + return; + } + if (Array.isArray(node)) { + node.forEach((item) => visit(item, key)); + return; + } + if (typeof node !== "object") return; + const record = node as Record; + for (const [childKey, childValue] of Object.entries(record)) { + if ( + childKey === "reasoning" || + childKey === "summary" || + childKey === "usage" || + childKey === "metadata" + ) { + continue; + } + visit(childValue, childKey); + } + }; + visit(value); + const html = htmlHits.find((text) => /<\/html>\s*$/i.test(text.trim())) ?? htmlHits[0]; + return html ?? hits.join(""); +} + /** Back-compat shim for callers that just want plain text. */ export function extractTextFromLine(agent: string, line: string): string { return parseLine(agent, line) diff --git a/src/lib/agents/detect.ts b/src/lib/agents/detect.ts index 80eaf2aa..07c38810 100644 --- a/src/lib/agents/detect.ts +++ b/src/lib/agents/detect.ts @@ -25,6 +25,12 @@ export const DEFAULT_MODEL: ModelOption = { id: "default", label: "Default (CLI export type AgentDef = { id: string; + /** + * Reuse another adapter's argv/parser implementation. This lets downstream + * users register wrapper binaries without changing invoke.ts, for example: + * `{ "id": "codex-nightly", "bin": "codex-next", "adapter": "codex" }`. + */ + adapter?: string; label: string; bin: string; fallbackBins?: string[]; @@ -294,8 +300,140 @@ export const AGENTS: AgentDef[] = [ { id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" }, ], }, + ...parseExtraAgents(), ]; +function envKey(id: string): string { + return id.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase(); +} + +function parseList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(/[,\n]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeModelOption(value: unknown): ModelOption | null { + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return null; + const sep = trimmed.indexOf("="); + const [id, label] = sep >= 0 + ? [trimmed.slice(0, sep).trim(), trimmed.slice(sep + 1).trim()] + : [trimmed, trimmed]; + return id ? { id, label: label || id } : null; + } + if (value && typeof value === "object") { + const record = value as Record; + if (typeof record.id !== "string" || !record.id.trim()) return null; + return { + id: record.id.trim(), + label: typeof record.label === "string" && record.label.trim() + ? record.label.trim() + : record.id.trim(), + }; + } + return null; +} + +function mergeModels(...groups: Array): ModelOption[] { + const out: ModelOption[] = [DEFAULT_MODEL]; + const seen = new Set([DEFAULT_MODEL.id]); + for (const group of groups) { + for (const item of group ?? []) { + const model = normalizeModelOption(item); + if (!model || model.id === DEFAULT_MODEL.id || seen.has(model.id)) continue; + seen.add(model.id); + out.push(model); + } + } + return out; +} + +function envModelsFor(agentId: string): ModelOption[] { + const global = parseList(process.env.HTML_ANYTHING_MODELS); + const specific = parseList(process.env[`HTML_ANYTHING_MODELS_${envKey(agentId)}`]); + return mergeModels(global, specific).filter((model) => model.id !== DEFAULT_MODEL.id); +} + +export function modelsForAgent(agent: AgentDef): ModelOption[] { + return mergeModels(agent.fallbackModels, envModelsFor(agent.id)); +} + +function normalizeProtocol(value: unknown): AgentProtocol | undefined { + if ( + value === "stdin" || + value === "argv" || + value === "argv-message" || + value === "acp" || + value === "pi-rpc" + ) { + return value; + } + return undefined; +} + +function parseExtraAgents(): AgentDef[] { + const raw = process.env.HTML_ANYTHING_EXTRA_AGENTS; + if (!raw?.trim()) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + const builtInIds = new Set([ + "claude", + "openclaw", + "codex", + "cursor-agent", + "gemini", + "copilot", + "opencode", + "qwen", + "qoder", + "deepseek", + "aider", + "hermes", + "kimi", + "devin", + "kiro", + "kilo", + "vibe", + "pi", + ]); + const out: AgentDef[] = []; + for (const item of parsed) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + const bin = typeof record.bin === "string" ? record.bin.trim() : ""; + if (!id || !bin || builtInIds.has(id) || out.some((agent) => agent.id === id)) continue; + const modelItems = Array.isArray(record.models) ? record.models : []; + out.push({ + id, + adapter: typeof record.adapter === "string" && record.adapter.trim() + ? record.adapter.trim() + : id, + label: typeof record.label === "string" && record.label.trim() ? record.label.trim() : id, + bin, + fallbackBins: Array.isArray(record.fallbackBins) + ? record.fallbackBins.filter((value): value is string => typeof value === "string" && !!value.trim()) + : [], + envOverride: typeof record.envOverride === "string" && record.envOverride.trim() + ? record.envOverride.trim() + : undefined, + vendor: typeof record.vendor === "string" && record.vendor.trim() ? record.vendor.trim() : "Custom", + protocol: normalizeProtocol(record.protocol), + fallbackModels: mergeModels(modelItems), + }); + } + return out; +} + function userToolchainDirs(): string[] { const home = homedir(); const env = process.env; @@ -386,6 +524,37 @@ export function resolveOnPath(bin: string): string | null { return null; } +function resolveCandidate(candidate: string | undefined): string | null { + const trimmed = candidate?.trim(); + if (!trimmed) return null; + if (existsSync(trimmed)) return trimmed; + return resolveOnPath(trimmed); +} + +export function resolveAgentBin(agent: AgentDef): { path: string; resolvedBin: string } | null { + const overrideKeys = [ + agent.envOverride, + `HTML_ANYTHING_BIN_${envKey(agent.id)}`, + ].filter((key): key is string => !!key); + for (const overrideKey of overrideKeys) { + const override = process.env[overrideKey]; + const overridePath = resolveCandidate(override); + if (overridePath && override) { + return { path: overridePath, resolvedBin: override }; + } + } + const candidates = [ + agent.bin, + ...(agent.fallbackBins ?? []), + ...parseList(process.env[`HTML_ANYTHING_BINS_${envKey(agent.id)}`]), + ]; + for (const candidate of candidates) { + const path = resolveCandidate(candidate); + if (path) return { path, resolvedBin: candidate }; + } + return null; +} + export type DetectedAgent = { id: string; label: string; @@ -412,20 +581,11 @@ export function detectAgents(): DetectedAgent[] { label: a.label, vendor: a.vendor, protocol, - models: a.fallbackModels, + models: modelsForAgent(a), unsupported: unsupported || undefined, }; - const override = a.envOverride ? process.env[a.envOverride] : undefined; - if (override && existsSync(override)) { - return { ...base, available: true, path: override, resolvedBin: a.bin }; - } - const candidates = [a.bin, ...(a.fallbackBins ?? [])]; - for (const c of candidates) { - const p = resolveOnPath(c); - if (p) { - return { ...base, available: true, path: p, resolvedBin: c }; - } - } + const resolved = resolveAgentBin(a); + if (resolved) return { ...base, available: true, path: resolved.path, resolvedBin: resolved.resolvedBin }; return { ...base, available: false }; }); } diff --git a/src/lib/agents/invoke.ts b/src/lib/agents/invoke.ts index 0988c4bd..0d7a1df6 100644 --- a/src/lib/agents/invoke.ts +++ b/src/lib/agents/invoke.ts @@ -1,6 +1,12 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { resolveOnPath, resolveOpenclawAgentId, AGENTS } from "./detect"; -import { buildArgv, envFor, makeParser, UnsupportedAgentProtocolError } from "./argv"; +import { resolveAgentBin, resolveOpenclawAgentId, AGENTS } from "./detect"; +import { + buildArgv, + envFor, + makeParser, + UnsupportedAgentProtocolError, + type AgentParse, +} from "./argv"; export type InvokeOpts = { agent: string; @@ -30,22 +36,19 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { if (!def) { return errorStream(`unknown agent: ${opts.agent}`); } - const candidates = [def.bin, ...(def.fallbackBins ?? [])]; - let bin: string | null = null; - for (const c of candidates) { - bin = resolveOnPath(c); - if (bin) break; - } - if (!bin) { + const adapter = def.adapter ?? def.id; + const resolved = resolveAgentBin(def); + if (!resolved) { return errorStream( `${def.label} (\`${def.bin}\`) is not installed or not on PATH.`, ); } + const bin = resolved.path; // For openclaw we need an async detection step (resolveOpenclawAgentId) // before buildArgv. Do all of the argv assembly inside the stream's async // start so we can `await` and surface failures as `error` events. - const env = envFor(opts.agent); + const env = envFor(adapter, def.id); const promptViaArgv = def.protocol === "argv"; const promptViaMessageFlag = def.protocol === "argv-message"; @@ -53,6 +56,9 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { async start(controller) { let closed = false; let child: ChildProcessWithoutNullStreams | null = null; + let hasContent = false; + let lastUnparsedLine = ""; + let firstContentTimer: ReturnType | null = null; const safeEnqueue = (ev: InvokeEvent) => { if (closed) return; @@ -65,10 +71,27 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { const safeClose = () => { if (closed) return; closed = true; + if (firstContentTimer) clearTimeout(firstContentTimer); try { controller.close(); } catch {} }; + const emitParsed = (part: AgentParse, rawLine?: string) => { + if (part.kind === "delta") { + hasContent = true; + safeEnqueue({ type: "delta", text: part.text }); + } else if (part.kind === "html") { + hasContent = true; + safeEnqueue({ type: "html", text: part.text }); + } else if (part.kind === "meta") { + safeEnqueue({ type: "meta", key: part.key, value: part.value }); + } else if (part.kind === "error") { + safeEnqueue({ type: "error", message: part.message }); + } else if (rawLine) { + lastUnparsedLine = rawLine; + safeEnqueue({ type: "raw", text: rawLine.slice(0, 240) }); + } + }; // Resolve agent-specific argv. For openclaw we first probe `agents // list` to learn the actual agent id (commonly "main") so the CLI's @@ -79,10 +102,10 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { model: opts.model, prompt: opts.prompt, }; - if (opts.agent === "openclaw") { - argvOpts.openclawAgentId = await resolveOpenclawAgentId(bin!); + if (adapter === "openclaw") { + argvOpts.openclawAgentId = await resolveOpenclawAgentId(bin); } - argv = buildArgv(opts.agent, argvOpts); + argv = buildArgv(adapter, argvOpts); } catch (err) { safeEnqueue({ type: "error", @@ -104,7 +127,7 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { if (promptViaMessageFlag) argv = [...argv, "--message", opts.prompt]; try { - child = spawn(bin!, argv, { + child = spawn(bin, argv, { cwd: opts.cwd ?? process.cwd(), env, stdio: ["pipe", "pipe", "pipe"], @@ -120,10 +143,21 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { safeEnqueue({ type: "start", - bin: bin!, + bin, argv, promptBytes: Buffer.byteLength(opts.prompt, "utf8"), }); + firstContentTimer = setTimeout(() => { + if (closed || hasContent) return; + safeEnqueue({ + type: "error", + message: + "No HTML/text was produced after 10 minutes. The selected agent is probably stuck, offline, or waiting for login.", + }); + try { + child?.kill("SIGTERM"); + } catch {} + }, 600_000); child.stdin.on("error", () => {}); try { @@ -135,7 +169,7 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { // One parser per spawn so cross-line dedupe state (sawStreamEventText) // is scoped to this single invocation and doesn't leak across runs. - const parse = makeParser(opts.agent); + const parse = makeParser(adapter); let stdoutBuf = ""; child.stdout.setEncoding("utf8"); @@ -144,24 +178,22 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { stdoutBuf += chunk; // OpenClaw emits one big multi-line JSON document — accumulate and // parse it once on close instead of trying to parse each line. - if (opts.agent === "openclaw") return; + if (adapter === "openclaw") return; let nl: number; while ((nl = stdoutBuf.indexOf("\n")) !== -1) { const line = stdoutBuf.slice(0, nl); stdoutBuf = stdoutBuf.slice(nl + 1); if (!line) continue; for (const part of parse(line)) { - if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); - else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); - else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); - else safeEnqueue({ type: "raw", text: line.slice(0, 240) }); + emitParsed(part, line); } } }); child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { - safeEnqueue({ type: "stderr", text: chunk }); + const text = normalizeStderr(chunk); + if (text) safeEnqueue({ type: "stderr", text }); }); child.on("error", (err) => { @@ -170,7 +202,7 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { }); child.on("close", (code) => { - if (opts.agent === "openclaw") { + if (adapter === "openclaw") { // OpenClaw's `agent --local --json` emits one pretty-printed JSON // document on stdout. The visible reply is at // `data.finalAssistantVisibleText`; usage / model show up in @@ -223,14 +255,20 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { } } else if (stdoutBuf) { for (const part of parse(stdoutBuf)) { - if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); - else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); - else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); + emitParsed(part); } - if (opts.agent === "aider" || opts.agent === "deepseek") { + if (adapter === "aider" || adapter === "deepseek") { + hasContent = true; safeEnqueue({ type: "delta", text: stdoutBuf }); } } + if (!hasContent) { + const hint = summarizeJsonLine(lastUnparsedLine || stdoutBuf); + safeEnqueue({ + type: "error", + message: `Agent exited without producing HTML/text (exit=${code ?? "?"}).${hint ? ` Last event: ${hint}` : ""}`, + }); + } safeEnqueue({ type: "done", code }); safeClose(); }); @@ -247,6 +285,40 @@ export function invokeAgent(opts: InvokeOpts): ReadableStream { }); } +function summarizeJsonLine(line: string): string { + if (!line.trim()) return ""; + try { + const obj = JSON.parse(line) as Record; + const item = obj.item && typeof obj.item === "object" ? (obj.item as Record) : null; + const keys = item ? Object.keys(item).slice(0, 10).join(",") : Object.keys(obj).slice(0, 10).join(","); + const itemType = item ? String(item.type ?? item.item_type ?? "") : ""; + return [String(obj.type ?? "unknown"), itemType, keys].filter(Boolean).join(" / "); + } catch { + return line.slice(0, 180); + } +} + +function normalizeStderr(text: string): string { + const benignCodexNoise = [ + "Reading prompt from stdin", + "ignoring interface.defaultPrompt", + "ignoring interface.icon_small", + "ignoring interface.icon_large", + "configured curated plugin no longer exists", + "failed to warm featured plugin ids cache", + "git sync failed for curated plugin sync", + "state db discrepancy during find_thread_path_by_id_str_in_subdir", + "failed to send events request", + "Failed to delete shell snapshot", + ]; + if (benignCodexNoise.some((needle) => text.includes(needle))) { + return ""; + } + const max = 2000; + if (text.length <= max) return text; + return `${text.slice(0, max)}\n... stderr truncated (${text.length.toLocaleString()} chars)\n`; +} + function errorStream(message: string): ReadableStream { return new ReadableStream({ start(controller) { diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 560ce2f0..8304c262 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -121,6 +121,7 @@ export interface Dict { "preview.tab.deck": string; "preview.tab.code": string; "preview.tab.log": string; + "preview.tab.history": string; // Deck viewer "deck.empty": string; @@ -162,6 +163,11 @@ export interface Dict { "preview.presentTooltip": string; "preview.refresh": string; "preview.refreshTooltip": string; + "preview.history.empty": string; + "preview.history.restore": string; + "preview.history.delete": string; + "preview.history.current": string; + "preview.history.meta": string; // Tasks sidebar "tasks.heading": string; @@ -185,6 +191,7 @@ export interface Dict { "tasks.emptyContent": string; "tasks.defaultName": string; "tasks.matchTooltip": string; + "tasks.resultCount": string; // Export menu "export.button": string; @@ -437,15 +444,22 @@ const en: Dict = { "deck.notesTooltip": "Toggle speaker notes (N)", "deck.slideN": "{n} / {m}", - "tasks.heading": "tasks", + "preview.tab.history": "history", + "preview.history.empty": "Successful conversions will appear here. Each saved result keeps its HTML, source, template, agent, and metrics.", + "preview.history.restore": "Restore", + "preview.history.delete": "Delete result", + "preview.history.current": "current", + "preview.history.meta": "{agent} · {size} · {date}", + + "tasks.heading": "history", "tasks.expand": "Expand task list", "tasks.collapse": "Collapse task list", - "tasks.newTask": "+ New task", - "tasks.search.placeholder": "Search task name / content / template…", + "tasks.newTask": "+ New conversion", + "tasks.search.placeholder": "Search title / source / result / template…", "tasks.search.clear": "Clear search", "tasks.empty.intro": "No tasks match “{query}”", "tasks.empty.clear": "Clear search", - "tasks.footer": "Input and output of every task autosave to your browser and survive a reload.", + "tasks.footer": "Every conversion keeps its source, HTML result, logs, metrics, and recent result snapshots in this browser.", "tasks.status.idle": "idle", "tasks.status.running": "generating", "tasks.status.done": "done", @@ -458,6 +472,7 @@ const en: Dict = { "tasks.emptyContent": "empty", "tasks.defaultName": "Task {n}", "tasks.matchTooltip": "{a} / {b} match", + "tasks.resultCount": "{n} results", "export.button": "⤓ Export / Copy ▾", "export.section.platform": "Copy to platform", @@ -705,15 +720,22 @@ const zhCN: Dict = { "deck.notesTooltip": "切换演讲者备注 (N)", "deck.slideN": "{n} / {m}", - "tasks.heading": "任务", + "preview.tab.history": "历史", + "preview.history.empty": "成功转换后的结果会出现在这里。每条结果都会保留 HTML、源内容、模板、agent 和指标。", + "preview.history.restore": "恢复", + "preview.history.delete": "删除结果", + "preview.history.current": "当前", + "preview.history.meta": "{agent} · {size} · {date}", + + "tasks.heading": "历史", "tasks.expand": "展开任务列表", "tasks.collapse": "折叠任务列表", - "tasks.newTask": "+ 新建任务", - "tasks.search.placeholder": "搜索任务名 / 内容 / 模板…", + "tasks.newTask": "+ 新建转换", + "tasks.search.placeholder": "搜索标题 / 源内容 / 结果 / 模板…", "tasks.search.clear": "清除搜索", "tasks.empty.intro": "没有匹配「{query}」的任务", "tasks.empty.clear": "清除搜索", - "tasks.footer": "所有任务的输入 / 输出都会自动保存到浏览器, 刷新后保留。", + "tasks.footer": "每次转换都会在浏览器里保留源内容、HTML 成果、日志、指标和最近结果快照。", "tasks.status.idle": "待机", "tasks.status.running": "生成中", "tasks.status.done": "完成", @@ -726,6 +748,7 @@ const zhCN: Dict = { "tasks.emptyContent": "空白", "tasks.defaultName": "任务 {n}", "tasks.matchTooltip": "{a} / {b} 匹配", + "tasks.resultCount": "{n} 个成果", "export.button": "⤓ 导出 / 复制 ▾", "export.section.platform": "复制到平台", diff --git a/src/lib/store.ts b/src/lib/store.ts index 3d3454a4..064275cc 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -57,6 +57,19 @@ export type RunStats = { bin?: string; }; +export type ResultSnapshot = { + id: string; + name: string; + html: string; + content: string; + format: string; + templateId: string; + agent: string; + model?: string; + stats: RunStats; + createdAt: number; +}; + export type Task = { id: string; name: string; @@ -70,6 +83,8 @@ export type Task = { status: ConvertStatus; log: LogEntry[]; stats: RunStats; + /** Completed conversion snapshots. Kept locally so users can revisit prior outputs. */ + results: ResultSnapshot[]; // 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. @@ -104,6 +119,7 @@ function makeTask(init?: Partial): Task { status: init?.status ?? "idle", log: init?.log ?? [], stats: init?.stats ?? emptyStats, + results: init?.results ?? [], baseContent: init?.baseContent, baseHtml: init?.baseHtml, sampleId: init?.sampleId, @@ -208,6 +224,9 @@ type State = { clearLogFor: (taskId: string) => void; resetStatsFor: (taskId: string) => void; patchStatsFor: (taskId: string, patch: Partial) => void; + saveResultFor: (taskId: string, meta: { agent: string; model?: string }) => void; + restoreResultFor: (taskId: string, resultId: string) => void; + deleteResultFor: (taskId: string, resultId: string) => void; /** snapshot the current (content, html) as the new diff-edit baseline */ commitBaseFor: (taskId: string) => void; @@ -369,6 +388,48 @@ export const useStore = create()( set((st) => ({ tasks: patchTask(st.tasks, taskId, (t) => ({ stats: { ...t.stats, ...patch } })), })), + saveResultFor: (taskId, meta) => + set((st) => ({ + tasks: patchTask(st.tasks, taskId, (t) => { + if (!t.html.trim()) return {}; + const snapshot: ResultSnapshot = { + id: `r_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, + name: `${t.name} · ${new Date().toLocaleString()}`, + html: t.html, + content: t.content, + format: t.format, + templateId: t.templateId, + agent: meta.agent, + model: meta.model || t.stats.model, + stats: { ...t.stats }, + createdAt: Date.now(), + }; + return { results: [snapshot, ...(t.results ?? [])].slice(0, 12) }; + }), + })), + restoreResultFor: (taskId, resultId) => + set((st) => ({ + tasks: patchTask(st.tasks, taskId, (t) => { + const result = (t.results ?? []).find((r) => r.id === resultId); + if (!result) return {}; + return { + html: result.html, + content: result.content, + format: result.format, + templateId: result.templateId, + stats: result.stats, + status: "done", + baseHtml: result.html, + baseContent: result.content, + }; + }), + })), + deleteResultFor: (taskId, resultId) => + set((st) => ({ + tasks: patchTask(st.tasks, taskId, (t) => ({ + results: (t.results ?? []).filter((r) => r.id !== resultId), + })), + })), commitBaseFor: (taskId) => set((st) => ({ tasks: patchTask(st.tasks, taskId, (t) => ({ @@ -390,7 +451,7 @@ export const useStore = create()( // 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: 5, + version: 6, partialize: (s): Persisted => ({ tasks: s.tasks.map((t) => ({ ...t, @@ -449,6 +510,18 @@ export const useStore = create()( p.layoutMode = "split"; } } + // v5 → v6: introduce per-task conversion result snapshots. + if (fromVersion < 6 && persisted && typeof persisted === "object") { + const p = persisted as Record; + if (Array.isArray(p.tasks)) { + p.tasks = p.tasks.map((task) => ({ + ...(task as Record), + results: Array.isArray((task as Record).results) + ? (task as Record).results + : [], + })); + } + } return persisted as Persisted; }, }, diff --git a/src/lib/templates/shared.ts b/src/lib/templates/shared.ts index 165a566a..f60a77a7 100644 --- a/src/lib/templates/shared.ts +++ b/src/lib/templates/shared.ts @@ -43,13 +43,47 @@ export const SHARED_DESIGN_DIRECTIVES = ` * `buildPrompt` functions in `index.ts` and the skill-folder loader assemble * prompts via this helper so behaviour stays identical. */ +const MAX_TEMPLATE_EXAMPLE_CHARS = 60_000; + +function formatTemplateExample(opts: { + exampleHtml?: string; + exampleName?: string; + templateName?: string; +}): string { + const raw = opts.exampleHtml?.trim(); + if (!raw) return ""; + const truncated = raw.length > MAX_TEMPLATE_EXAMPLE_CHARS; + const html = truncated ? raw.slice(0, MAX_TEMPLATE_EXAMPLE_CHARS) : raw; + return ` +【模板示例 HTML — 视觉还原基准, 不复制内容】 +下面是当前模板随仓库提供的 ${opts.exampleName ?? opts.templateName ?? "example.html"}。这是你必须贴近的视觉与交互基准: +- 复用它的版面节奏、标题层级、空间密度、组件质感、边框/阴影/动效语言。 +- 用户内容必须完整替换示例内容; 禁止照抄示例里的业务文字、数字、公司名或占位文案。 +- 用户内容比示例长时, 沿用示例组件继续扩展 section / card / slide; 不要为了贴合示例页数而压缩信息。 +- 用户内容比示例短时, 保持同一视觉系统, 但删除空壳和无内容模块。 +- 如果示例使用复杂 CSS / JS / 响应式规则, 生成结果应保留同等级的完整度, 不要降级成普通 Markdown 样式。 +${truncated ? `- 注意: 示例 HTML 因长度只截取前 ${MAX_TEMPLATE_EXAMPLE_CHARS.toLocaleString()} 字符; 仍需保持其可见视觉系统。\n` : ""} + +${html} + +`; +} + export function assemblePrompt(opts: { body: string; content: string; format: string; + exampleHtml?: string; + exampleName?: string; + templateName?: string; }): string { return `${SHARED_DESIGN_DIRECTIVES} ${opts.body.trim()} +${formatTemplateExample({ + exampleHtml: opts.exampleHtml, + exampleName: opts.exampleName, + templateName: opts.templateName, +})} 【输入格式】: ${opts.format} 【用户内容】: diff --git a/src/lib/use-convert.ts b/src/lib/use-convert.ts index d911d02e..a98ea7fd 100644 --- a/src/lib/use-convert.ts +++ b/src/lib/use-convert.ts @@ -111,6 +111,7 @@ export function useConvert() { const dec = new TextDecoder(); let buf = ""; let lastEvent = ""; + let sawError = false; while (true) { const { value, done } = await reader.read(); @@ -137,12 +138,19 @@ export function useConvert() { } catch { continue; } + if (event === "error") sawError = true; handleEvent(taskId, event, data, startedAt); } } const endedAt = Date.now(); useStore.getState().patchStatsFor(taskId, { endedAt, durationMs: endedAt - startedAt }); + const html = useStore.getState().tasks.find((t) => t.id === taskId)?.html ?? ""; + if (sawError && !html.trim()) { + useStore.getState().setStatusFor(taskId, "error"); + return; + } useStore.getState().setStatusFor(taskId, "done"); + useStore.getState().saveResultFor(taskId, { agent: req.agent, model: useModel }); // 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); @@ -260,6 +268,8 @@ function handleEvent(taskId: string, event: string, data: unknown, startedAt: nu elapsed, text: String(d.message ?? "agent error"), }); + const html = store.tasks.find((t) => t.id === taskId)?.html ?? ""; + if (!html.trim()) store.setStatusFor(taskId, "error"); break; } }