Initial commit
This commit is contained in:
276
web/agent-ui.js
Normal file
276
web/agent-ui.js
Normal 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, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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
459
web/app.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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
108
web/export.js
Normal 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
99
web/index.html
Normal 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
96
web/intake-ui.js
Normal file
@@ -0,0 +1,96 @@
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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
420
web/settings.css
Normal 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
105
web/settings.html
Normal 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
441
web/settings.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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
215
web/styles.css
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user