Initial commit

This commit is contained in:
2026-07-10 08:31:27 +08:00
commit 2b74c30d36
134 changed files with 21801 additions and 0 deletions

276
web/agent-ui.js Normal file
View File

@@ -0,0 +1,276 @@
import { renderIntakePanel } from "./intake-ui.js";
const HIDE_KINDS = new Set(["worker_stub"]);
const MSG_CLASS = {
user_input: "user",
agent_tool: "agent",
orchestrator_decision: "agent",
orchestrator_prompt: "agent",
worker_running: "skill",
worker_output: "skill",
worker_questions: "skill",
error: "error",
};
const MSG_LABEL = {
user_input: "你",
agent_tool: "Tool",
orchestrator_decision: "Agent",
orchestrator_prompt: "Agent",
worker_running: "Skill",
worker_output: "Skill",
worker_questions: "Skill 提问",
error: "错误",
system_info: "系统",
};
function esc(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function fmtTime(iso) {
if (!iso) return "";
return new Date(iso).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
function msgLabel(msg) {
const k = msg.kind ?? (msg.role === "user" ? "user_input" : "system_info");
if (k.startsWith("worker") && msg.actor) return `Skill · ${msg.actor}`;
return MSG_LABEL[k] ?? k;
}
function msgBody(msg, view) {
let body = (msg.body ?? msg.text ?? "").trim();
if (msg.kind === "worker_questions" && !body) {
const wr = view.waitingReason;
if (wr?.kind === "worker_questions") {
const qs = (wr.questions ?? []).filter(Boolean);
if (qs.length) body = qs.map((q) => `- ${q}`).join("\n");
}
if (!body) body = "请补充当前 skill 需要的信息。";
}
return body;
}
export function renderSkillPicker(view, onPick) {
const el = document.getElementById("skill-picker");
if (!el) return;
const selecting = view.waitingReason?.kind === "skill_selection";
const skills = view.skills ?? [];
if (!selecting || !skills.length) {
el.hidden = true;
el.innerHTML = "";
return;
}
el.hidden = false;
el.innerHTML = `
<h2>选择 skill 包</h2>
<p>Agent 需要知道你要用哪套能力。选一项开始实例化(也可在下方输入名称或编号)。</p>
<div class="skill-grid" id="skill-grid"></div>`;
const grid = el.querySelector("#skill-grid");
skills.forEach((skill, i) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "skill-card";
btn.innerHTML = `
<div class="skill-card-name">${esc(skill.name)}</div>
<div class="skill-card-desc">${esc(skill.description || "无说明")}</div>
<div class="skill-card-tag">${esc(skill.category || "")} · #${i + 1}</div>`;
btn.addEventListener("click", () => onPick(skill.name));
grid.appendChild(btn);
});
}
export function renderLifecycle(view) {
const toggle = document.getElementById("lifecycle-toggle");
if (!toggle) return;
const stage = view.lifecycleStage ?? "design";
document.body.dataset.lifecycle = stage;
toggle.querySelectorAll("[data-stage]").forEach((btn) => {
const s = btn.getAttribute("data-stage");
btn.classList.toggle("active", s === stage);
if (s === "play") {
btn.disabled = !view.playReady;
btn.title = view.playReady ? "" : "实例就绪后可切换";
} else {
btn.disabled = false;
}
});
}
export function renderSkillGuide(view) {
const wrap = document.getElementById("skill-guide");
const list = document.getElementById("skill-guide-list");
if (!wrap || !list) return;
const show = view.lifecycleStage === "design" && (view.skillCatalog?.length ?? 0) > 0;
if (!show) {
wrap.hidden = true;
return;
}
wrap.hidden = false;
list.innerHTML = view.skillCatalog
.map(
(s) => `
<div class="skill-guide-item ${s.status}">
<span class="skill-guide-dot"></span>
<div>
<div class="skill-guide-label">${esc(s.label)} <code>${esc(s.id)}</code></div>
<div class="skill-guide-purpose">${esc(s.purpose)}</div>
</div>
</div>`,
)
.join("");
}
export function renderAgentPanel(view, loading) {
const focusEl = document.getElementById("agent-focus");
const traceEl = document.getElementById("tool-trace");
const timelineEl = document.getElementById("agent-timeline");
const badgeEl = document.getElementById("burst-badge");
if (!focusEl || !timelineEl) return;
const f = view.focus;
if (f) {
focusEl.innerHTML = `
<div class="agent-focus-who">${esc(f.actorLabel)}</div>
<div class="agent-focus-action">${esc(loading ? "处理中…" : f.action)}</div>
${f.detail ? `<div class="agent-focus-detail">${esc(f.detail)}</div>` : ""}`;
} else {
focusEl.innerHTML = `<div class="agent-focus-detail">待命</div>`;
}
if (badgeEl && view.burst) {
const show = view.burst.count > 0 || view.phase === "running";
badgeEl.hidden = !show;
badgeEl.textContent = `burst ${view.burst.count}/${view.burst.max}`;
}
if (traceEl) {
const trace = view.toolTrace ?? [];
if (!trace.length) {
traceEl.hidden = true;
traceEl.innerHTML = "";
} else {
traceEl.hidden = false;
traceEl.innerHTML = trace
.map(
(t) => `
<div class="tool-trace-item">
<span class="tool-trace-name">${esc(t.name)}</span>
<span style="float:right;color:var(--muted)">${fmtTime(t.at)}</span>
<div>${esc(t.summary || "—")}</div>
</div>`,
)
.join("");
}
}
const items = [];
for (const msg of view.messages ?? []) {
if (msg.role === "user") {
items.push({ kind: "user_input", title: "你", body: msg.text, at: msg.createdAt });
continue;
}
const kind = msg.kind ?? "system_info";
if (HIDE_KINDS.has(kind)) continue;
items.push({
kind,
title: msg.title ?? msgLabel(msg),
body: msgBody(msg, view).slice(0, 160),
at: msg.createdAt,
});
}
if (loading && f) {
items.push({ kind: "pending", title: f.action, body: f.detail ?? "", at: "" });
}
if (!items.length) {
timelineEl.innerHTML = `<p class="timeline-empty">调度记录将出现在这里</p>`;
return;
}
timelineEl.innerHTML = items
.map(
(it) => `
<div class="timeline-item">
<strong>${esc(it.title)}</strong>
<span style="color:var(--muted);margin-left:6px">${fmtTime(it.at)}</span>
<div style="margin-top:2px;color:var(--muted)">${esc(it.body)}</div>
</div>`,
)
.join("");
timelineEl.scrollTop = timelineEl.scrollHeight;
}
export function renderMessageFeed(view, loading) {
const feed = document.getElementById("message-feed");
if (!feed) return;
const intake =
view.waitingReason?.kind === "intake" && view.intake?.fields?.length;
const selecting = view.waitingReason?.kind === "skill_selection";
feed.innerHTML = "";
if (intake) {
const box = document.createElement("div");
box.className = "intake-panel";
box.innerHTML = renderIntakePanel(view.intake, { variant: "feed" });
feed.appendChild(box);
}
const visible = (view.messages ?? []).filter((m) => {
if (m.role === "user") return true;
return !HIDE_KINDS.has(m.kind ?? "");
});
if (!visible.length && !loading && !intake) {
const p = document.createElement("p");
p.className = "empty";
if (selecting) {
p.textContent = "在上方选择 skill 包,或在下方输入 skill 名称。";
} else if (view.lifecycleStage === "play") {
p.textContent = "游玩模式Agent 将调度 run skill推进世界与叙事。";
} else {
p.textContent = "创作模式:与 Agent 对话,完成实例化后可切到游玩。";
}
feed.appendChild(p);
return;
}
for (const msg of visible) {
const isUser = msg.role === "user";
const kind = msg.kind ?? (isUser ? "user_input" : "system_info");
const card = document.createElement("article");
card.className = `msg ${MSG_CLASS[kind] ?? "system"}`;
card.innerHTML = `
<header class="msg-head">
<span class="msg-tag">${esc(msgLabel(msg))}</span>
<span>${esc(msg.title ?? "")}</span>
<span class="msg-time">${fmtTime(msg.createdAt)}</span>
</header>
<div class="msg-body">${esc(msgBody(msg, view))}</div>`;
feed.appendChild(card);
}
if (loading) {
const pending = document.createElement("article");
pending.className = "msg agent msg-pending";
pending.innerHTML = `
<header class="msg-head"><span class="msg-tag">进行中</span><span>${esc(view.focus?.action ?? "处理中")}</span></header>
<div class="msg-body">…</div>`;
feed.appendChild(pending);
}
feed.scrollTop = feed.scrollHeight;
}
export function renderWorkspace(view, loading, onPickSkill) {
renderLifecycle(view);
renderSkillPicker(view, onPickSkill);
renderMessageFeed(view, loading);
renderSkillGuide(view);
renderAgentPanel(view, loading);
}

459
web/app.js Normal file
View File

@@ -0,0 +1,459 @@
import { renderWorkspace } from "./agent-ui.js";
import { downloadMarkdown, sessionToMarkdown } from "./export.js";
import { renderIntakePanel } from "./intake-ui.js";
let sessionId = null;
let activeBookId = null;
let lastView = null;
let books = [];
let composerForceInput = false;
const $ = (id) => document.getElementById(id);
const PHASE = { idle: "待命", running: "执行中", waiting_user: "等待你", done: "已完成", error: "出错" };
const REASON = {
skill_selection: "选择 skill",
intake: "填写需求",
input: "补充说明",
worker_questions: "回答提问",
approve_step: "确认执行",
review_artifact: "验收产物",
};
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "请求失败");
return data;
}
function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function statusFor(view, loading) {
if (loading) return { cls: "running", text: "处理中…" };
if (view.phase === "done") return { cls: "done", text: "已完成" };
if (view.phase === "running" && !view.waitingReason) {
return { cls: "running", text: view.focus?.action ?? "Agent 运行中" };
}
if (view.waitingReason) {
return { cls: "waiting", text: REASON[view.waitingReason.kind] ?? view.waitingReason.kind };
}
return { cls: "", text: PHASE[view.phase] ?? view.phase };
}
function isAgentBusy(view, loading) {
if (loading) return true;
return view.phase === "running" && !view.waitingReason;
}
function resolveComposer(view, loading) {
if (view.phase === "done") return { mode: "idle", text: "会话已结束" };
if (isAgentBusy(view, loading)) {
return { mode: "waiting", text: view.focus?.action ?? "Agent 或 Skill 执行中…" };
}
const confirmIntake = view.actions?.find((a) => a.type === "confirm_intake");
if (view.waitingReason?.kind === "intake" && view.intake) {
const send = view.actions?.find((a) => a.type === "send_message");
return {
mode: view.intake.ready && confirmIntake ? "intake_ready" : "intake",
intake: view.intake,
intakePrompt: view.intakePrompt,
confirmIntake,
placeholder: send?.placeholder ?? "补充信息…",
};
}
const approve = view.actions?.find((a) => a.type === "approve");
const accept = view.actions?.find((a) => a.type === "accept");
if ((approve || accept) && !composerForceInput) {
return {
mode: "action",
primary: approve ?? accept,
primaryType: approve ? "approve" : "accept",
hint: approve ? view.focus?.detail : "验收 Skill 产出",
showReject: true,
};
}
const send = view.actions?.find((a) => a.type === "send_message");
if (
send ||
view.waitingReason?.kind === "input" ||
view.waitingReason?.kind === "skill_selection" ||
view.waitingReason?.kind === "worker_questions" ||
composerForceInput
) {
let hint = view.hints?.[0] ?? null;
if (view.waitingReason?.kind === "skill_selection") {
hint = "选择 skill 包,或输入名称 / 编号";
}
return { mode: "input", placeholder: send?.placeholder ?? "输入消息…", hint };
}
const finish = view.actions?.find((a) => a.type === "finish");
if (finish) {
return { mode: "action", primary: finish, primaryType: "finish", hint: null, showReject: false };
}
return { mode: "idle", text: "暂无可用操作" };
}
function renderComposer(view, loading) {
const root = $("composer");
if (!root) return;
const spec = resolveComposer(view, loading);
if (spec.mode === "waiting") {
root.innerHTML = `<div class="composer-waiting">${esc(spec.text)}</div>`;
return;
}
if (spec.mode === "idle") {
root.innerHTML = `<div class="composer-idle">${esc(spec.text)}</div>`;
return;
}
if (spec.mode === "intake" || spec.mode === "intake_ready") {
const confirm =
spec.mode === "intake_ready" && spec.confirmIntake
? `<div class="composer-actions"><button type="button" class="btn btn-primary" data-act="confirm_intake">${esc(spec.confirmIntake.label)}</button></div>`
: "";
root.innerHTML = `
${spec.intakePrompt ? `<p class="composer-hint">${esc(spec.intakePrompt)}</p>` : ""}
<div class="intake-panel">${renderIntakePanel(spec.intake, { variant: "composer" })}</div>
${confirm}
<form class="composer-form" id="composer-form">
<textarea id="composer-input" rows="2" placeholder="${esc(spec.placeholder)}"></textarea>
<button type="submit" class="btn btn-primary">发送</button>
</form>`;
wireComposerForm();
root.querySelector("[data-act=confirm_intake]")?.addEventListener("click", () => runAction("confirm_intake"));
return;
}
if (spec.mode === "action") {
const reject = spec.showReject
? `<button type="button" class="btn" data-act="reject">${spec.primaryType === "approve" ? "暂不" : "重新来"}</button>
<button type="button" class="btn" data-act="force-input">说明意见</button>`
: "";
root.innerHTML = `
${spec.hint ? `<p class="composer-hint">${esc(spec.hint)}</p>` : ""}
<div class="composer-actions">
<button type="button" class="btn btn-primary" data-act="${esc(spec.primaryType)}">${esc(spec.primary.label)}</button>
${reject}
</div>`;
root.querySelector(`[data-act="${spec.primaryType}"]`)?.addEventListener("click", () => runAction(spec.primaryType));
root.querySelector("[data-act=reject]")?.addEventListener("click", () => runAction("reject"));
root.querySelector("[data-act=force-input]")?.addEventListener("click", () => {
composerForceInput = true;
renderComposer(view, loading);
});
return;
}
root.innerHTML = `
${spec.hint ? `<p class="composer-hint">${esc(spec.hint)}</p>` : ""}
<form class="composer-form" id="composer-form">
<textarea id="composer-input" rows="2" placeholder="${esc(spec.placeholder)}"></textarea>
<button type="submit" class="btn btn-primary">发送</button>
</form>`;
wireComposerForm();
}
function wireComposerForm() {
const form = $("composer-form");
const input = $("composer-input");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
await sendText(input?.value ?? "");
});
input?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
form?.requestSubmit();
}
});
input?.focus();
}
function renderBookList() {
const list = $("book-list");
if (!list) return;
if (!books.length) {
list.innerHTML = `<p class="sidebar-empty">暂无作品<br><button type="button" class="btn-sm" id="btn-new-inline">+ 新建</button></p>`;
$("btn-new-inline")?.addEventListener("click", openNewBookDialog);
return;
}
list.innerHTML = "";
for (const book of books) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `book-item${book.id === activeBookId ? " active" : ""}`;
const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "未选 skill";
btn.innerHTML = `
<div class="book-item-title">${esc(book.title)}</div>
<div class="book-item-sub">${esc(skill)} · ${esc(book.preview?.slice(0, 40) || "")}</div>`;
btn.addEventListener("click", () => openBook(book.id));
list.appendChild(btn);
}
}
function renderHeader(view, loading) {
const st = statusFor(view, loading);
$("work-title").textContent = view.bookTitle ?? "未命名作品";
const skill = view.activeSkill ?? "未选 skill";
const phase = view.waitingReason
? REASON[view.waitingReason.kind] ?? view.phase
: PHASE[view.phase] ?? view.phase;
$("work-meta").textContent = `${skill} · ${phase}`;
$("status-dot").className = `status-dot ${st.cls}`;
$("status-text").textContent = st.text;
$("btn-delete").hidden = !view.bookId;
$("btn-saves").hidden = !view.bookId;
$("btn-export").disabled = !(view.messages?.length);
}
function renderEmpty() {
sessionId = null;
lastView = null;
activeBookId = null;
composerForceInput = false;
$("work-title").textContent = "未打开作品";
$("work-meta").textContent = "";
$("status-dot").className = "status-dot";
$("status-text").textContent = "—";
$("btn-delete").hidden = true;
$("btn-saves").hidden = true;
$("btn-export").disabled = true;
$("message-feed").innerHTML = `<p class="empty">点击左侧 + 新建作品</p>`;
$("skill-picker").hidden = true;
$("skill-guide").hidden = true;
$("agent-focus").innerHTML = "";
$("agent-timeline").innerHTML = "";
$("tool-trace").hidden = true;
document.body.dataset.lifecycle = "design";
$("composer").innerHTML = `<div class="composer-idle">暂无打开的作品</div>`;
renderBookList();
}
function renderSession(view, loading = false) {
lastView = view;
sessionId = view.id;
activeBookId = view.bookId ?? activeBookId;
if (!loading && !["approve_step", "review_artifact"].includes(view.waitingReason?.kind)) {
composerForceInput = false;
}
renderHeader(view, loading);
renderBookList();
renderWorkspace(view, loading, (skillId) => sendText(skillId));
renderComposer(view, loading);
}
async function sendText(text) {
const trimmed = text?.trim();
if (!trimmed || !sessionId) return;
try {
renderSession(lastView, true);
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: "POST",
body: JSON.stringify({ text: trimmed }),
});
renderSession(view, false);
} catch (err) {
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
}
}
async function runAction(action) {
if (!sessionId) return;
try {
renderSession(lastView, true);
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, {
method: "POST",
body: JSON.stringify({ action }),
});
renderSession(view, false);
} catch (err) {
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
}
}
async function setLifecycle(stage) {
if (!sessionId || stage === lastView?.lifecycleStage) return;
if (stage === "play" && !lastView?.playReady) return;
try {
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/lifecycle`, {
method: "POST",
body: JSON.stringify({ stage }),
});
renderSession(view, false);
} catch (err) {
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
}
}
async function loadBooks() {
const data = await api("/api/books");
books = data.books ?? [];
renderBookList();
}
function openNewBookDialog() {
$("input-book-title").value = "";
$("dialog-new-book").showModal();
$("input-book-title").focus();
}
async function createBook() {
const title = $("input-book-title").value.trim() || "未命名作品";
$("btn-create-book").disabled = true;
try {
const data = await api("/api/books", {
method: "POST",
body: JSON.stringify({ title }),
});
$("dialog-new-book").close();
if (data.book) books.unshift(data.book);
activeBookId = data.book?.id;
renderSession(data.session, false);
} catch (err) {
alert(err.message);
} finally {
$("btn-create-book").disabled = false;
}
}
async function openBook(bookId) {
activeBookId = bookId;
renderBookList();
if (lastView) renderSession(lastView, true);
try {
const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, { method: "POST" });
if (data.book) {
const i = books.findIndex((b) => b.id === bookId);
if (i >= 0) books[i] = { ...books[i], ...data.book };
}
renderSession(data.session, false);
} catch (err) {
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
}
}
async function deleteBook() {
if (!activeBookId || !lastView?.bookTitle) return;
if (!confirm(`删除「${lastView.bookTitle}」?不可恢复。`)) return;
try {
await api(`/api/books/${encodeURIComponent(activeBookId)}`, { method: "DELETE" });
books = books.filter((b) => b.id !== activeBookId);
if (books.length) await openBook(books[0].id);
else renderEmpty();
} catch (err) {
alert(err.message);
}
}
function exportSession() {
if (!lastView) return;
const name = (lastView.bookTitle ?? "session").replace(/[\\/:*?"<>|]/g, "_");
downloadMarkdown(`${name}.md`, sessionToMarkdown(lastView));
}
async function refreshSaves() {
if (!activeBookId) return;
const data = await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`);
const list = $("saves-list");
const saves = data.saves ?? [];
if (!saves.length) {
list.innerHTML = `<p class="timeline-empty">暂无存档</p>`;
return;
}
list.innerHTML = saves
.map(
(s) => `
<div class="save-row" data-id="${esc(s.id)}">
<span>${esc(s.label)} <small style="color:var(--muted)">${esc(s.kind)}</small></span>
<span>
<button type="button" class="btn-sm" data-load="${esc(s.id)}">读档</button>
<button type="button" class="btn-sm btn-danger" data-del="${esc(s.id)}">删</button>
</span>
</div>`,
)
.join("");
list.querySelectorAll("[data-load]").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
const data = await api(
`/api/books/${encodeURIComponent(activeBookId)}/saves/${encodeURIComponent(btn.getAttribute("data-load"))}/load`,
{ method: "POST" },
);
$("dialog-saves").close();
renderSession(data.session, false);
} catch (err) {
alert(err.message);
}
});
});
list.querySelectorAll("[data-del]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!confirm("删除此存档?")) return;
await api(
`/api/books/${encodeURIComponent(activeBookId)}/saves/${encodeURIComponent(btn.getAttribute("data-del"))}`,
{ method: "DELETE" },
);
refreshSaves();
});
});
}
async function createSave() {
const label = $("input-save-label").value.trim();
const kind = document.querySelector('input[name="save-kind"]:checked')?.value ?? "run";
if (!label) return alert("请输入名称");
try {
await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, {
method: "POST",
body: JSON.stringify({ label, kind, sessionId }),
});
$("input-save-label").value = "";
refreshSaves();
} catch (err) {
alert(err.message);
}
}
$("btn-new-book")?.addEventListener("click", openNewBookDialog);
$("btn-new-inline")?.addEventListener("click", openNewBookDialog);
$("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close());
$("form-new-book")?.addEventListener("submit", (e) => {
e.preventDefault();
createBook();
});
$("lifecycle-toggle")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-stage]");
if (!btn?.disabled) setLifecycle(btn.getAttribute("data-stage"));
});
$("btn-delete")?.addEventListener("click", deleteBook);
$("btn-export")?.addEventListener("click", exportSession);
$("btn-saves")?.addEventListener("click", () => {
$("dialog-saves").showModal();
refreshSaves();
});
$("btn-close-saves")?.addEventListener("click", () => $("dialog-saves").close());
$("btn-save-create")?.addEventListener("click", createSave);
async function init() {
try {
await loadBooks();
if (books.length) await openBook(books[0].id);
else renderEmpty();
} catch {
$("status-text").textContent = "加载失败";
}
}
init();

108
web/export.js Normal file
View File

@@ -0,0 +1,108 @@
/** 将会话消息导出为可读 Markdown 文本 */
function formatExportTime(iso) {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
const KIND_LABELS = {
user_input: "用户输入",
orchestrator_decision: "总管决策",
orchestrator_prompt: "总管询问",
agent_tool: "Agent Tool",
worker_running: "Worker 执行",
worker_output: "Worker 产出",
worker_stub: "Worker 占位",
system_info: "系统",
error: "错误",
};
export function sessionToMarkdown(view) {
if (!view) return "";
const lines = [];
lines.push(`# ${view.bookTitle ?? "未命名作品"}`);
lines.push("");
lines.push(`- Skill: \`${view.activeSkill ?? "—"}\``);
lines.push(`- 阶段: ${view.phase ?? "—"}`);
if (view.waitingReason?.kind) {
lines.push(`- 等待: ${view.waitingReason.kind}`);
}
lines.push(`- 导出时间: ${new Date().toLocaleString("zh-CN")}`);
lines.push("");
if (view.skillCatalog?.length) {
const stageLabel = view.lifecycleStage === "play" ? "游玩" : "创作";
lines.push(`## ${stageLabel}能力清单`);
lines.push("");
for (const skill of view.skillCatalog) {
const mark =
skill.status === "done"
? "x"
: skill.status === "active"
? ">"
: " ";
lines.push(`- [${mark}] **${skill.label}** (\`${skill.id}\`) — ${skill.purpose}`);
}
lines.push("");
} else if (view.pipeline?.length) {
lines.push("## 流程进度");
lines.push("");
for (const step of view.pipeline) {
const mark =
step.status === "done" ? "x" : step.status === "active" ? ">" : " ";
lines.push(`- [${mark}] ${step.label}`);
}
lines.push("");
}
lines.push("## 对话与调度记录");
lines.push("");
for (const msg of view.messages ?? []) {
const kind = msg.kind ?? (msg.role === "user" ? "user_input" : "system_info");
const label = msg.title ?? KIND_LABELS[kind] ?? kind;
lines.push(`### ${label}`);
lines.push("");
lines.push(`*${formatExportTime(msg.createdAt)}${msg.actor ? ` · ${msg.actor}` : ""}*`);
if (msg.tokenUsage?.totalTokens) {
lines.push(`*Token: ${msg.tokenUsage.totalTokens}*`);
}
lines.push("");
if (msg.thinking?.trim()) {
lines.push("**思维链**");
lines.push("");
lines.push(msg.thinking.trim());
lines.push("");
}
const body = (msg.body ?? msg.text ?? "").trim();
if (body) {
lines.push(body);
lines.push("");
}
lines.push("---");
lines.push("");
}
return lines.join("\n").trim() + "\n";
}
export function downloadMarkdown(filename, content) {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

99
web/index.html Normal file
View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Writing Agent</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body data-lifecycle="design">
<div class="layout">
<aside class="panel panel-books" id="panel-books">
<header class="panel-head">
<strong>作品</strong>
<button type="button" class="btn-sm" id="btn-new-book" title="新建">+</button>
</header>
<div class="book-list" id="book-list"></div>
</aside>
<main class="panel panel-main">
<header class="main-head" id="main-head">
<div class="main-head-left">
<h1 class="work-title" id="work-title">未打开作品</h1>
<span class="work-meta" id="work-meta"></span>
</div>
<div class="main-head-center">
<div class="lifecycle-toggle" id="lifecycle-toggle" role="group" aria-label="创作 / 游玩">
<button type="button" class="lifecycle-btn active" data-stage="design">创作</button>
<button type="button" class="lifecycle-btn" data-stage="play" disabled>游玩</button>
</div>
</div>
<div class="main-head-right">
<a class="link" href="/settings.html">设置</a>
<button type="button" class="btn-sm" id="btn-saves" hidden>存档</button>
<button type="button" class="btn-sm" id="btn-export" disabled>导出</button>
<button type="button" class="btn-sm btn-danger" id="btn-delete" hidden>删除</button>
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text"></span></span>
</div>
</header>
<div class="main-body">
<section class="panel-feed" id="panel-feed">
<div id="skill-picker" class="skill-picker" hidden></div>
<div id="message-feed" class="message-feed">
<p class="empty">点击左侧 + 新建作品</p>
</div>
<div id="skill-guide" class="skill-guide" hidden>
<header class="skill-guide-head">计划中的 skill占位</header>
<div id="skill-guide-list"></div>
</div>
</section>
<aside class="panel-agent" id="panel-agent">
<header class="panel-head">
<strong>Agent</strong>
<span class="burst-badge" id="burst-badge" hidden></span>
</header>
<div id="agent-focus" class="agent-focus"></div>
<div id="tool-trace" class="tool-trace" hidden></div>
<div id="agent-timeline" class="agent-timeline"></div>
</aside>
</div>
<footer class="composer" id="composer"></footer>
</main>
</div>
<dialog id="dialog-new-book" class="dialog">
<form method="dialog" id="form-new-book">
<h2>新建作品</h2>
<p class="dialog-desc">只需起名。打开后由 Agent 引导你选择 skill 包并开始实例化。</p>
<label class="field">
名称
<input id="input-book-title" type="text" placeholder="未命名作品" maxlength="80" autofocus />
</label>
<div class="dialog-actions">
<button type="button" class="btn" value="cancel" id="btn-cancel-new">取消</button>
<button type="submit" class="btn btn-primary" id="btn-create-book">创建</button>
</div>
</form>
</dialog>
<dialog id="dialog-saves" class="dialog dialog-wide">
<header class="dialog-head-row">
<h2>存档</h2>
<button type="button" class="btn-sm" id="btn-close-saves">关闭</button>
</header>
<p class="dialog-desc">实例 = 设计完成后的设定;进度 = 含运行状态的完整快照。</p>
<div class="saves-new">
<input id="input-save-label" type="text" placeholder="存档名称" maxlength="60" />
<label><input type="radio" name="save-kind" value="instance" /> 实例</label>
<label><input type="radio" name="save-kind" value="run" checked /> 进度</label>
<button type="button" class="btn btn-primary" id="btn-save-create">保存</button>
</div>
<div id="saves-list" class="saves-list"></div>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>

96
web/intake-ui.js Normal file
View File

@@ -0,0 +1,96 @@
function escapeHtml(text) {
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function intakeItemRow(f, { showEmpty = true } = {}) {
if (!f.filled && !showEmpty) return "";
const status = f.filled ? "filled" : "empty";
const preview = f.value
? `<span class="intake-value" title="${escapeHtml(f.value)}">${escapeHtml(f.value)}</span>`
: `<span class="intake-value empty">未填写</span>`;
return `<li class="intake-item ${status}">
<span class="intake-mark" aria-hidden="true">${f.filled ? "✓" : "○"}</span>
<span class="intake-label">${escapeHtml(f.label)}</span>
${preview}
</li>`;
}
function intakeListSection(title, items, countLabel, { showEmpty = true } = {}) {
if (!items.length) return "";
const filled = items.filter((f) => f.filled).length;
const rows = items.map((f) => intakeItemRow(f, { showEmpty })).filter(Boolean);
if (!rows.length) return "";
return `<div class="intake-section">
<div class="intake-section-head">
<span class="intake-section-title">${escapeHtml(title)}</span>
<span class="intake-section-count">${filled}/${items.length} ${countLabel}</span>
</div>
<ul class="intake-list">${rows.join("")}</ul>
</div>`;
}
function renderFilledSummary(fields) {
const filled = fields.filter((f) => f.filled && f.value);
if (!filled.length) return "";
const body = filled
.map(
(f) =>
`<section class="intake-filled-entry">
<h4 class="intake-filled-label">${escapeHtml(f.label)}</h4>
<div class="intake-filled-value">${escapeHtml(f.value)}</div>
</section>`,
)
.join("");
return `<details class="intake-filled-block" open>
<summary>已填写内容 <span class="intake-summary-badge">${filled.length} 项</span></summary>
<div class="intake-filled-body">${body}</div>
</details>`;
}
function renderOptionalBlock(optional) {
if (!optional.length) return "";
const filled = optional.filter((f) => f.filled).length;
const open = filled > 0 ? " open" : "";
const rows = optional.map((f) => intakeItemRow(f, { showEmpty: true })).join("");
return `<details class="intake-optional-block"${open}>
<summary>可选项(选填) <span class="intake-summary-badge">${filled}/${optional.length} 已填</span></summary>
<div class="intake-optional-body">
<p class="intake-optional-hint">以下可补充;不填也可在确认后进入实例化。</p>
<ul class="intake-list">${rows}</ul>
</div>
</details>`;
}
/**
* @param {object | undefined} intake
* @param {{ variant?: "composer" | "feed" }} [options]
*/
export function renderIntakePanel(intake, options = {}) {
if (!intake?.fields?.length) return "";
const variant = options.variant ?? "composer";
const required = intake.fields.filter((f) => f.required);
const optional = intake.fields.filter((f) => !f.required);
const requiredSection = intakeListSection("必要项", required, "已填");
const filledSummary = renderFilledSummary(intake.fields);
const optionalBlock = renderOptionalBlock(optional);
const headBadge =
required.length > 0
? `<span class="intake-panel-badge">${intake.requiredFilled}/${intake.requiredTotal} 必要项</span>`
: "";
const className =
variant === "feed" ? "intake-panel intake-panel-feed" : "intake-panel";
return `<div class="${className}" role="region" aria-label="初始化填空进度">
<div class="intake-panel-head">
<span class="intake-panel-title">填空进度</span>
${headBadge}
</div>
${requiredSection}
${filledSummary}
${optionalBlock}
</div>`;
}

420
web/settings.css Normal file
View File

@@ -0,0 +1,420 @@
.settings-sidebar {
min-width: var(--sidebar-width);
}
.settings-content {
max-width: 720px;
width: 100%;
}
.active-settings-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 24px;
background: var(--surface-hover);
border-bottom: 1px solid var(--border);
font-size: 0.8125rem;
}
.active-settings-bar .label {
color: var(--text-tertiary);
margin-right: 4px;
}
.active-settings-bar .active-tag {
padding: 3px 10px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
font-weight: 500;
}
.active-settings-bar .active-tag.missing {
color: var(--danger);
border-color: rgba(245, 74, 69, 0.35);
}
.settings-toast {
margin: 0 24px 0;
padding: 10px 14px;
border-radius: var(--radius-md);
background: rgba(0, 181, 120, 0.12);
color: #00875a;
font-size: 0.82rem;
}
.settings-toast.error {
background: rgba(245, 74, 69, 0.1);
color: var(--danger);
}
.config-card-actions .btn-activate {
background: var(--text) !important;
color: #fff !important;
border: none !important;
border-radius: 999px;
padding: 6px 16px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
}
.config-card-actions .btn-activate:hover {
background: #000 !important;
}
.config-card-actions .btn-active-label {
background: var(--accent-soft);
color: var(--accent);
border: none;
border-radius: 999px;
padding: 6px 14px;
font-size: 0.78rem;
cursor: default;
}
.import-report-actions {
margin-top: 10px;
display: flex;
gap: 8px;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 24px 32px 40px;
background: var(--surface);
display: flex;
justify-content: center;
}
.settings-section.hidden {
display: none;
}
.sidebar-nav-item {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-nav-icon {
font-size: 0.9rem;
opacity: 0.85;
}
.card-list {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 720px;
}
.config-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
transition: border-color 0.15s, box-shadow 0.15s;
}
.config-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
.config-card.active {
border-color: rgba(51, 112, 255, 0.45);
box-shadow: 0 0 0 1px var(--accent-soft), 0 4px 16px rgba(51, 112, 255, 0.08);
}
.config-card-head {
display: flex;
align-items: flex-start;
gap: 12px;
}
.config-card-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.85rem;
flex-shrink: 0;
color: #fff;
font-weight: 700;
}
.config-card-icon.api {
background: linear-gradient(135deg, #6b9fff, var(--agent-blue));
}
.config-card-icon.preset {
background: linear-gradient(135deg, #ff85c0, var(--agent-pink));
}
.config-card-info {
flex: 1;
min-width: 0;
}
.config-card-title {
font-weight: 600;
font-size: 0.9rem;
}
.config-card-meta {
color: var(--text-tertiary);
font-size: 0.78rem;
word-break: break-all;
margin-top: 4px;
line-height: 1.5;
}
.config-card-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-left: 48px;
}
.config-card-actions button:not(.btn-danger) {
background: var(--surface-hover);
color: var(--text);
border: none;
border-radius: 999px;
padding: 5px 14px;
font-size: 0.78rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.config-card-actions button:not(.btn-danger):hover {
background: var(--accent-soft);
color: var(--accent);
}
.test-result {
font-size: 0.78rem;
margin-top: 2px;
padding-left: 48px;
}
.test-result.ok {
color: var(--success);
}
.test-result.fail {
color: var(--danger);
}
.import-report {
margin-top: 16px;
padding: 14px 16px;
background: var(--bubble);
border-radius: var(--radius-md);
font-size: 0.76rem;
overflow-x: auto;
white-space: pre-wrap;
color: var(--text-secondary);
max-width: 720px;
line-height: 1.6;
}
.preset-entries-panel {
margin-top: 4px;
padding-left: 48px;
}
.preset-entries-inner {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-hover);
padding: 12px 14px;
}
.preset-gen-params {
font-size: 0.76rem;
color: var(--text-secondary);
margin-bottom: 10px;
line-height: 1.5;
}
.preset-entries-summary {
font-size: 0.74rem;
color: var(--text-tertiary);
margin: 0 0 10px;
}
.preset-entry {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin-bottom: 8px;
}
.preset-entry:last-child {
margin-bottom: 0;
}
.preset-entry.skipped {
opacity: 0.72;
}
.preset-entry-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 0.78rem;
}
.entry-index {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
display: grid;
place-items: center;
font-size: 0.68rem;
font-weight: 700;
flex-shrink: 0;
}
.entry-role {
padding: 2px 8px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
}
.entry-role.role-system {
background: rgba(51, 112, 255, 0.12);
color: var(--accent);
}
.entry-role.role-user {
background: rgba(0, 181, 120, 0.12);
color: var(--mention);
}
.entry-role.role-assistant {
background: rgba(114, 46, 209, 0.12);
color: var(--agent-purple);
}
.entry-name {
font-weight: 600;
color: var(--text);
}
.entry-skip {
font-size: 0.68rem;
color: var(--text-tertiary);
}
.preset-entry-content {
margin: 0;
padding: 10px 12px;
background: var(--bubble);
border-radius: var(--radius-sm);
font-size: 0.76rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
max-height: 240px;
overflow: auto;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.preset-entry-empty {
margin: 0;
font-size: 0.74rem;
color: var(--text-tertiary);
font-style: italic;
}
.profile-dialog {
border: none;
border-radius: var(--radius-lg);
padding: 0;
background: var(--surface);
color: var(--text);
max-width: 420px;
width: calc(100% - 32px);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
}
.profile-dialog::backdrop {
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
}
.profile-dialog form {
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.profile-dialog h3 {
margin: 0 0 4px;
font-size: 1rem;
font-weight: 600;
}
.profile-dialog label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.78rem;
color: var(--text-secondary);
font-weight: 500;
}
.profile-dialog input {
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text);
font-size: 0.875rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.profile-dialog input:focus {
outline: none;
border-color: rgba(51, 112, 255, 0.5);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
@media (max-width: 900px) {
.config-card-actions {
padding-left: 0;
}
.test-result {
padding-left: 0;
}
.preset-entries-panel {
padding-left: 0;
}
}

105
web/settings.html Normal file
View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>设置 · Writing Agent</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="/settings.css" />
</head>
<body>
<div class="app-shell">
<header class="site-header">
<div class="header-left">
<a href="/" class="brand" aria-label="Writing Agent 首页">
<span class="brand-logo" aria-hidden="true"></span>
<span class="brand-name">Writing Agent</span>
</a>
<nav class="header-nav" aria-label="主导航">
<a class="header-nav-link" href="/">对话</a>
<a class="header-nav-link active" href="/settings.html" aria-current="page">设置</a>
</nav>
</div>
</header>
<div class="app-body">
<aside class="sidebar settings-sidebar">
<div class="sidebar-head">
<h2>设置</h2>
</div>
<button type="button" class="sidebar-nav-item active" data-section="api">
<span class="sidebar-nav-icon"></span> API 配置
</button>
<button type="button" class="sidebar-nav-item" data-section="preset">
<span class="sidebar-nav-icon">📋</span> 预设 Preset
</button>
<p class="sidebar-note">
保存在本地 <code>~/.writing-agent/</code><br />明文存储,不同步
</p>
</aside>
<main class="settings-panel">
<header class="panel-header">
<div>
<h1 id="panel-title">API 配置</h1>
<span class="panel-subtitle" id="panel-subtitle">管理 LLM 连接</span>
</div>
<div class="panel-header-actions" id="panel-actions"></div>
</header>
<div class="active-settings-bar" id="active-settings-bar"></div>
<div class="settings-toast" id="settings-toast" hidden></div>
<div class="panel-body">
<div class="settings-content">
<section class="settings-section" id="section-api">
<div id="profiles-list" class="card-list"></div>
</section>
<section class="settings-section hidden" id="section-preset">
<div id="presets-list" class="card-list"></div>
<pre id="import-report" class="import-report" hidden></pre>
</section>
</div>
</div>
</main>
</div>
</div>
<dialog id="profile-dialog" class="profile-dialog">
<form id="profile-form" method="dialog">
<h3 id="profile-dialog-title">新增 API 配置</h3>
<label>
名称
<input name="name" required placeholder="例如DeepSeek" />
</label>
<label>
Base URL
<input name="baseUrl" required placeholder="https://api.deepseek.com" />
</label>
<label>
API Key
<input name="apiKey" type="password" placeholder="sk-..." />
</label>
<label>
Model
<input name="model" required placeholder="deepseek-v4-pro" />
</label>
<div class="dialog-actions">
<button type="button" id="profile-cancel" class="btn-secondary">取消</button>
<button type="button" id="profile-save" class="btn-secondary">仅保存</button>
<button type="submit" class="btn-primary">保存并选用</button>
</div>
</form>
</dialog>
<input type="file" id="preset-file" accept=".json,application/json" hidden />
<script src="/settings.js" type="module"></script>
</body>
</html>

441
web/settings.js Normal file
View File

@@ -0,0 +1,441 @@
let state = {
settings: { activeProfileId: null, activePresetId: null },
profiles: [],
presets: [],
};
let editingProfileId = null;
let activeSection = "api";
let lastImportedPresetId = null;
const expandedPresetEntries = new Map();
const profilesListEl = document.getElementById("profiles-list");
const presetsListEl = document.getElementById("presets-list");
const importReportEl = document.getElementById("import-report");
const profileDialog = document.getElementById("profile-dialog");
const profileForm = document.getElementById("profile-form");
const profileDialogTitle = document.getElementById("profile-dialog-title");
const presetFileEl = document.getElementById("preset-file");
const panelTitleEl = document.getElementById("panel-title");
const panelSubtitleEl = document.getElementById("panel-subtitle");
const panelActionsEl = document.getElementById("panel-actions");
const sectionApiEl = document.getElementById("section-api");
const sectionPresetEl = document.getElementById("section-preset");
const activeSettingsBarEl = document.getElementById("active-settings-bar");
const settingsToastEl = document.getElementById("settings-toast");
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "请求失败");
return data;
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function maskKey(key) {
if (!key) return "(未设置)";
if (key.length <= 8) return "****";
return `${key.slice(0, 4)}${key.slice(-4)}`;
}
let toastTimer = null;
function showToast(message, isError = false) {
settingsToastEl.hidden = false;
settingsToastEl.textContent = message;
settingsToastEl.classList.toggle("error", isError);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
settingsToastEl.hidden = true;
}, 4000);
}
function renderActiveBar() {
const profile = state.profiles.find(
(p) => p.id === state.settings.activeProfileId,
);
const preset = state.presets.find(
(p) => p.id === state.settings.activePresetId,
);
activeSettingsBarEl.innerHTML = `
<span class="label">当前生效</span>
<span class="active-tag ${profile ? "" : "missing"}">
API: ${profile ? escapeHtml(`${profile.name} · ${profile.model}`) : "未选用"}
</span>
<span class="active-tag ${preset ? "" : "missing"}">
预设: ${preset ? escapeHtml(preset.name) : "未选用"}
</span>`;
}
function switchSection(section) {
activeSection = section;
document.querySelectorAll(".sidebar-nav-item").forEach((el) => {
el.classList.toggle("active", el.dataset.section === section);
});
sectionApiEl.classList.toggle("hidden", section !== "api");
sectionPresetEl.classList.toggle("hidden", section !== "preset");
renderPanelHeader();
}
function renderPanelHeader() {
panelActionsEl.innerHTML = "";
if (activeSection === "api") {
panelTitleEl.textContent = "API 配置";
panelSubtitleEl.textContent = "保存后点击「选用」或「保存并选用」立即生效";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn-primary";
btn.textContent = "新增配置";
btn.addEventListener("click", () => openProfileDialog());
panelActionsEl.appendChild(btn);
} else {
panelTitleEl.textContent = "预设 Preset";
panelSubtitleEl.textContent = "导入后点击「选用此预设」注入所有 LLM 请求";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn-primary";
btn.textContent = "导入 JSON";
btn.addEventListener("click", () => presetFileEl.click());
panelActionsEl.appendChild(btn);
}
}
function activateButtonHtml(active, id, action) {
if (active) {
return `<button type="button" class="btn-active-label" disabled>✓ 使用中</button>`;
}
return `<button type="button" class="btn-activate" data-action="${action}" data-id="${id}">选用并生效</button>`;
}
function renderProfiles() {
profilesListEl.innerHTML = "";
if (!state.profiles.length) {
profilesListEl.innerHTML =
'<p class="empty-hint">暂无配置,点击右上角「新增配置」。</p>';
return;
}
for (const p of state.profiles) {
const active = p.id === state.settings.activeProfileId;
const card = document.createElement("div");
card.className = `config-card${active ? " active" : ""}`;
card.innerHTML = `
<div class="config-card-head">
<div class="config-card-icon api">${escapeHtml(p.name.charAt(0).toUpperCase())}</div>
<div class="config-card-info">
<div class="config-card-title">
${escapeHtml(p.name)}
${active ? '<span class="badge">当前</span>' : ""}
</div>
<div class="config-card-meta">${escapeHtml(p.model)} · ${escapeHtml(p.baseUrl)}</div>
<div class="config-card-meta">Key: ${escapeHtml(maskKey(p.apiKey))}</div>
</div>
</div>
<div class="config-card-actions">
${activateButtonHtml(active, p.id, "activate-profile")}
<button type="button" data-action="edit-profile" data-id="${p.id}">编辑</button>
<button type="button" data-action="test-profile" data-id="${p.id}">测试连接</button>
<button type="button" class="btn-danger" data-action="delete-profile" data-id="${p.id}">删除</button>
</div>
<div class="test-result" id="test-${p.id}"></div>`;
profilesListEl.appendChild(card);
}
}
function renderPresets() {
presetsListEl.innerHTML = "";
if (!state.presets.length) {
presetsListEl.innerHTML =
'<p class="empty-hint">暂无预设,点击右上角「导入 JSON」。</p>';
return;
}
for (const p of state.presets) {
const active = p.id === state.settings.activePresetId;
const expanded = expandedPresetEntries.get(p.id);
const card = document.createElement("div");
card.className = `config-card${active ? " active" : ""}`;
card.innerHTML = `
<div class="config-card-head">
<div class="config-card-icon preset">P</div>
<div class="config-card-info">
<div class="config-card-title">
${escapeHtml(p.name)}
${active ? '<span class="badge">当前</span>' : ""}
</div>
<div class="config-card-meta">
${escapeHtml(p.source)} · 启用 ${p.enabledCount}
· 注入 ${p.injectingCount ?? "?"}
· ${escapeHtml(p.importedAt?.slice(0, 10) ?? "")}
</div>
</div>
</div>
<div class="config-card-actions">
${activateButtonHtml(active, p.id, "activate-preset")}
<button type="button" data-action="toggle-preset-entries" data-id="${p.id}">
${expanded ? "收起条目" : "查看启用条目"}
</button>
<button type="button" class="btn-danger" data-action="delete-preset" data-id="${p.id}">删除</button>
</div>
<div class="preset-entries-panel" id="preset-entries-${p.id}" ${expanded ? "" : "hidden"}></div>`;
presetsListEl.appendChild(card);
if (expanded) {
renderPresetEntriesPanel(p.id, expanded);
}
}
}
function renderPresetEntriesPanel(presetId, data) {
const panel = document.getElementById(`preset-entries-${presetId}`);
if (!panel || !data) return;
const gen = data.generation ?? {};
const genLines = Object.entries(gen)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${k}: ${v}`);
const entriesHtml = data.entries
.map((entry, idx) => {
const roleClass = `role-${entry.role}`;
const status = entry.willInject
? ""
: entry.marker
? '<span class="entry-skip">marker · 无内容</span>'
: '<span class="entry-skip">空内容 · 不注入</span>';
return `
<article class="preset-entry ${entry.willInject ? "injecting" : "skipped"}">
<header class="preset-entry-head">
<span class="entry-index">${idx + 1}</span>
<span class="entry-role ${roleClass}">${escapeHtml(entry.role)}</span>
<span class="entry-name">${escapeHtml(entry.name)}</span>
${status}
</header>
${entry.content
? `<pre class="preset-entry-content">${escapeHtml(entry.content)}</pre>`
: `<p class="preset-entry-empty">(无文本内容)</p>`}
</article>`;
})
.join("");
panel.innerHTML = `
<div class="preset-entries-inner">
${genLines.length ? `<div class="preset-gen-params"><strong>生成参数</strong> ${escapeHtml(genLines.join(" · "))}</div>` : ""}
<p class="preset-entries-summary">共 ${data.entries.length} 条启用顺序,${data.injectingCount} 条会注入请求</p>
${entriesHtml || '<p class="empty-hint">无启用条目</p>'}
</div>`;
}
async function togglePresetEntries(presetId) {
if (expandedPresetEntries.has(presetId)) {
expandedPresetEntries.delete(presetId);
renderPresets();
return;
}
const data = await api(`/api/presets/${encodeURIComponent(presetId)}/entries`);
expandedPresetEntries.set(presetId, data);
renderPresets();
}
async function loadAll() {
const data = await api("/api/settings");
state.settings = data.settings;
state.profiles = data.profiles;
state.presets = data.presets;
renderActiveBar();
renderProfiles();
renderPresets();
}
function openProfileDialog(profile = null) {
editingProfileId = profile?.id ?? null;
profileDialogTitle.textContent = profile ? "编辑 API 配置" : "新增 API 配置";
profileForm.name.value = profile?.name ?? "";
profileForm.baseUrl.value = profile?.baseUrl ?? "https://api.deepseek.com";
profileForm.apiKey.value = profile?.apiKey ?? "";
profileForm.model.value = profile?.model ?? "deepseek-v4-pro";
profileDialog.showModal();
}
async function saveProfile(activate) {
const payload = {
name: profileForm.name.value,
baseUrl: profileForm.baseUrl.value,
model: profileForm.model.value,
activate,
};
const apiKey = profileForm.apiKey.value.trim();
if (apiKey || !editingProfileId) {
payload.apiKey = apiKey;
}
let result;
if (editingProfileId) {
result = await api(`/api/profiles/${editingProfileId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
if (activate) {
result = await api(`/api/profiles/${editingProfileId}/activate`, {
method: "POST",
});
}
} else {
result = await api("/api/profiles", {
method: "POST",
body: JSON.stringify(payload),
});
}
profileDialog.close();
await loadAll();
if (activate) {
const n = result.reloadedSessions ?? 0;
showToast(`已选用并生效${n > 0 ? `,已更新 ${n} 个活跃会话` : ""}`);
} else {
showToast("已保存。点击「选用并生效」后才会用于 LLM 请求。");
}
}
async function activateProfile(id) {
const result = await api(`/api/profiles/${id}/activate`, { method: "POST" });
await loadAll();
const n = result.reloadedSessions ?? 0;
showToast(`API 配置已生效${n > 0 ? `${n} 个会话已更新)` : ""}`);
}
async function activatePreset(id) {
const result = await api(`/api/presets/${id}/activate`, { method: "POST" });
await loadAll();
const n = result.reloadedSessions ?? 0;
showToast(`预设已生效${n > 0 ? `${n} 个会话已更新)` : ""}`);
}
document.querySelectorAll(".sidebar-nav-item").forEach((btn) => {
btn.addEventListener("click", () => switchSection(btn.dataset.section));
});
document.getElementById("profile-cancel").addEventListener("click", () => {
profileDialog.close();
});
document.getElementById("profile-save").addEventListener("click", () => {
saveProfile(false).catch((err) => showToast(err.message, true));
});
profileForm.addEventListener("submit", async (e) => {
e.preventDefault();
try {
await saveProfile(true);
} catch (err) {
showToast(err.message, true);
}
});
profilesListEl.addEventListener("click", async (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const id = btn.dataset.id;
const action = btn.dataset.action;
try {
if (action === "activate-profile") {
await activateProfile(id);
} else if (action === "edit-profile") {
openProfileDialog(state.profiles.find((p) => p.id === id));
} else if (action === "test-profile") {
const el = document.getElementById(`test-${id}`);
el.textContent = "测试中…";
el.className = "test-result";
const result = await api(`/api/profiles/${id}/test`, { method: "POST" });
el.textContent = result.message;
el.className = `test-result ${result.ok ? "ok" : "fail"}`;
} else if (action === "delete-profile") {
if (!confirm("确定删除此 API 配置?")) return;
await api(`/api/profiles/${id}`, { method: "DELETE" });
await loadAll();
showToast("已删除");
}
} catch (err) {
showToast(err.message, true);
}
});
presetsListEl.addEventListener("click", async (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const id = btn.dataset.id;
const action = btn.dataset.action;
try {
if (action === "activate-preset") {
await activatePreset(id);
} else if (action === "toggle-preset-entries") {
await togglePresetEntries(id);
} else if (action === "delete-preset") {
if (!confirm("确定删除此预设?")) return;
await api(`/api/presets/${id}`, { method: "DELETE" });
await loadAll();
showToast("已删除");
}
} catch (err) {
showToast(err.message, true);
}
});
presetFileEl.addEventListener("change", async () => {
const file = presetFileEl.files?.[0];
if (!file) return;
try {
const text = await file.text();
const raw = JSON.parse(text);
const name = file.name.replace(/\.json$/i, "");
const report = await api("/api/presets/import", {
method: "POST",
body: JSON.stringify({ raw, name }),
});
lastImportedPresetId = report.preset.id;
importReportEl.hidden = false;
importReportEl.innerHTML = `
<div>${[
`已导入: ${report.preset.name}`,
`条目: ${report.promptCount},启用: ${report.enabledCount}`,
report.warnings.length ? `警告: ${report.warnings.join("; ")}` : "",
]
.filter(Boolean)
.join("<br>")}</div>
<div class="import-report-actions">
<button type="button" class="btn-activate" id="activate-imported-preset">选用此预设并生效</button>
</div>`;
document
.getElementById("activate-imported-preset")
?.addEventListener("click", async () => {
try {
await activatePreset(lastImportedPresetId);
} catch (err) {
showToast(err.message, true);
}
});
await loadAll();
showToast("预设已导入,请点击「选用此预设并生效」");
} catch (err) {
showToast(err.message, true);
} finally {
presetFileEl.value = "";
}
});
renderPanelHeader();
loadAll().catch((err) => {
showToast(`加载设置失败: ${err.message}`, true);
});

215
web/styles.css Normal file
View File

@@ -0,0 +1,215 @@
/* Functional layout — agent-first, no production-line chrome */
:root {
--bg: #f4f4f5;
--surface: #fff;
--border: #d4d4d8;
--text: #18181b;
--muted: #71717a;
--accent: #2563eb;
--accent-soft: #dbeafe;
--ok: #16a34a;
--warn: #ca8a04;
--danger: #dc2626;
--books-w: 220px;
--agent-w: 300px;
--head-h: 48px;
--composer-min: 72px;
font-family: system-ui, "Segoe UI", "PingFang SC", sans-serif;
font-size: 14px;
color: var(--text);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
button, input, textarea { font: inherit; }
a.link { color: var(--accent); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.layout {
display: grid;
grid-template-columns: var(--books-w) 1fr;
height: 100vh;
background: var(--bg);
}
.panel { background: var(--surface); border: 1px solid var(--border); min-height: 0; }
.panel-books { border-right: none; display: flex; flex-direction: column; }
.panel-head {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; border-bottom: 1px solid var(--border);
font-size: 13px;
}
.panel-main { display: grid; grid-template-rows: var(--head-h) 1fr var(--composer-min); min-width: 0; }
.main-head {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
padding: 0 12px;
border-bottom: 1px solid var(--border);
}
.main-head-left { min-width: 0; }
.main-head-right { display: flex; align-items: center; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
.work-title { margin: 0; font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.work-meta { font-size: 12px; color: var(--muted); }
.main-body {
display: grid;
grid-template-columns: 1fr var(--agent-w);
min-height: 0;
}
.panel-feed { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--border); }
.panel-agent { display: flex; flex-direction: column; min-height: 0; background: #fafafa; }
.btn, .btn-sm {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
cursor: pointer;
padding: 6px 10px;
}
.btn-sm { padding: 4px 8px; font-size: 12px; }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-danger { color: var(--danger); border-color: #fecaca; }
.btn:disabled, .btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
.status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); }
.status-dot.running { background: var(--accent); }
.status-dot.waiting { background: var(--warn); }
.status-dot.done { background: var(--ok); }
/* Books */
.book-list { flex: 1; overflow-y: auto; padding: 6px; }
.book-item {
display: block; width: 100%; text-align: left;
border: 1px solid transparent; border-radius: 6px;
background: transparent; padding: 8px; cursor: pointer;
}
.book-item:hover { background: var(--bg); }
.book-item.active { border-color: var(--accent); background: var(--accent-soft); }
.book-item-title { font-weight: 600; font-size: 13px; }
.book-item-sub { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; }
/* Lifecycle */
.lifecycle-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; padding: 2px; }
.lifecycle-btn {
border: none; background: transparent; padding: 4px 14px; border-radius: 6px;
font-size: 13px; cursor: pointer; color: var(--muted);
}
.lifecycle-btn.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
body[data-lifecycle="play"] { --accent: #16a34a; --accent-soft: #dcfce7; --bg: #f0fdf4; }
body[data-lifecycle="play"] .panel-agent { background: #f7fef9; }
/* Skill picker — primary action when starting */
.skill-picker {
padding: 16px;
border-bottom: 2px solid var(--accent);
background: var(--accent-soft);
}
.skill-picker h2 { margin: 0 0 4px; font-size: 15px; }
.skill-picker p { margin: 0 0 12px; font-size: 13px; color: var(--muted); }
.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
.skill-card {
text-align: left; padding: 12px; border: 1px solid var(--border);
border-radius: 8px; background: var(--surface); cursor: pointer;
}
.skill-card:hover { border-color: var(--accent); }
.skill-card-name { font-weight: 600; margin-bottom: 4px; }
.skill-card-desc { font-size: 12px; color: var(--muted); line-height: 1.4; }
.skill-card-tag { font-size: 11px; color: var(--accent); margin-top: 6px; }
/* Feed */
.message-feed { flex: 1; overflow-y: auto; padding: 12px; min-height: 0; }
.empty { color: var(--muted); text-align: center; padding: 40px 16px; }
.msg {
border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px; margin-bottom: 8px; background: var(--surface);
}
.msg.user { border-left: 3px solid var(--warn); }
.msg.agent { border-left: 3px solid var(--accent); }
.msg.skill { border-left: 3px solid var(--ok); }
.msg.system { border-left: 3px solid var(--border); }
.msg.error { border-left: 3px solid var(--danger); }
.msg-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; font-size: 12px; }
.msg-tag { font-weight: 600; }
.msg-time { color: var(--muted); margin-left: auto; }
.msg-body { white-space: pre-wrap; word-break: break-word; line-height: 1.5; font-size: 13px; }
.msg-pending { opacity: 0.85; }
/* Skill guide */
.skill-guide { border-top: 1px solid var(--border); padding: 10px 12px; max-height: 180px; overflow-y: auto; background: #fafafa; }
.skill-guide-head { font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 8px; }
.skill-guide-item { display: grid; grid-template-columns: 8px 1fr; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
.skill-guide-item:last-child { border-bottom: none; }
.skill-guide-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); margin-top: 4px; }
.skill-guide-item.active .skill-guide-dot { background: var(--accent); }
.skill-guide-item.done .skill-guide-dot { background: var(--ok); }
.skill-guide-label { font-weight: 600; }
.skill-guide-purpose { color: var(--muted); margin-top: 2px; }
/* Agent panel */
.agent-focus { padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
.agent-focus-who { font-weight: 600; }
.agent-focus-action { margin-top: 4px; }
.agent-focus-detail { margin-top: 4px; font-size: 12px; color: var(--muted); }
.burst-badge { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--accent-soft); color: var(--accent); }
.tool-trace { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
.tool-trace-item { padding: 4px 0; border-bottom: 1px dashed var(--border); }
.tool-trace-item:last-child { border-bottom: none; }
.tool-trace-name { font-family: ui-monospace, monospace; font-weight: 600; color: var(--accent); }
.agent-timeline { flex: 1; overflow-y: auto; padding: 8px 12px; font-size: 12px; }
.timeline-item { padding: 6px 0; border-bottom: 1px solid var(--border); }
.timeline-empty { color: var(--muted); }
/* Composer */
.composer {
border-top: 1px solid var(--border);
padding: 10px 12px;
min-height: var(--composer-min);
background: var(--surface);
}
.composer-waiting, .composer-idle {
padding: 12px; text-align: center; color: var(--muted); font-size: 13px;
}
.composer-hint { margin: 0 0 8px; font-size: 12px; color: var(--muted); }
.composer-form { display: flex; gap: 8px; align-items: flex-end; }
.composer-form textarea {
flex: 1; min-height: 40px; max-height: 120px; resize: vertical;
border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px;
}
.composer-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.composer-actions .btn-primary { flex: 1; }
/* Intake (from intake-ui.js) */
.intake-panel { border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 10px; background: #fffbeb; }
.intake-section-head { display: flex; justify-content: space-between; font-size: 12px; font-weight: 600; margin-bottom: 6px; }
.intake-list { list-style: none; margin: 0; padding: 0; }
.intake-item { display: grid; grid-template-columns: 16px 1fr auto; gap: 6px; font-size: 12px; padding: 3px 0; }
.intake-item.filled .intake-mark { color: var(--ok); }
.intake-value.empty { color: var(--muted); }
/* Dialog */
.dialog { border: 1px solid var(--border); border-radius: 10px; padding: 16px; max-width: 400px; }
.dialog-wide { max-width: 520px; }
.dialog::backdrop { background: rgba(0,0,0,0.3); }
.dialog h2 { margin: 0 0 8px; font-size: 16px; }
.dialog-desc { margin: 0 0 12px; font-size: 13px; color: var(--muted); }
.field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.field input { padding: 8px; border: 1px solid var(--border); border-radius: 6px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.dialog-head-row { display: flex; justify-content: space-between; align-items: center; }
.saves-new { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
.saves-list { max-height: 240px; overflow-y: auto; font-size: 13px; }
.save-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.panel-books { display: none; }
.main-body { grid-template-columns: 1fr; }
.panel-agent { display: none; }
}