diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 46fa1d4..8657d93 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -10,6 +10,11 @@ import { import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-snapshot.js"; import { toRunSnapshotMeta } from "../types/run-snapshot.js"; import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js"; +import { + listSnapshotProducts, + patchBlackboardTagContent, + type SnapshotProductRow, +} from "../book/snapshot-products.js"; import { INSTANCE_OPENING_SNAPSHOT_LABEL, OPENING_OUTPUT_TAG, @@ -110,6 +115,7 @@ import { findRecipeCatalogEntry, formatCreationFlowForUser, formatDictateRecipeBrief, + formatModuleCatalogForDictate, findModuleByName, findStepByRef, type FlowStepTitleHint, @@ -158,13 +164,24 @@ import { normalizeQuestions, } from "../skills/question-protocol.js"; import type { QuestionAnswer } from "../types/questions.js"; +import { + expandAtDirectives, + type DirectiveContext, +} from "../directives/expand.js"; +import { + getActivePersona, + listPersonas, + personaForLifecycle, +} from "../persona/store.js"; import { appendBranchVariant, + cloneCheckpoint, createMessageBranchState, createUserVariantMessage, ensureBranchForEdit, ensureBranchForRefresh, findPrecedingUserIndex, + canAttemptRefresh, isRefreshableMessage, recordPreMessageCheckpoint, switchBranchVariant, @@ -172,6 +189,13 @@ import { type MessageBranchState, type SessionCheckpoint, } from "./message-branch.js"; +import { + createCompareJob, + findCandidate, + getCompareJob, + runCompareJob, + type ModelCompareJob, +} from "./model-compare.js"; /** 这些消息种类挂载全量 LLM 上下文痕迹(右键可查看) */ const CONTEXT_TRACE_MESSAGE_KINDS = new Set([ @@ -201,6 +225,8 @@ export type ChatMessage = { branchGroupId?: string; branchIndex?: number; branchTotal?: number; + /** 视图用:这条现在能不能点「重出一版」 */ + canRefresh?: boolean; }; export type SessionView = { @@ -280,6 +306,10 @@ export type SessionView = { { totalTokens: number; cachedTokens: number; cacheMissTokens: number; calls: number } >; }; + /** 用户角色列表(快速切换) */ + personas?: Array<{ id: string; name: string }>; + /** 当前选中的用户角色 */ + activePersona?: { id: string; name: string; description: string } | null; }; export type SessionAction = @@ -546,7 +576,9 @@ export class SessionManager { const onMessage = this.buildOnMessageHandler(() => managed); const thinkingHandlers = this.buildStreamHandlers(id, () => managed); - const llm = createDefaultMainAgentLlm(trackingRef); + const llm = createDefaultMainAgentLlm(trackingRef, () => + this.personaForManaged(managed), + ); const runtime = new PhaseRuntime({ autoStubWorker: !hasRealLlmConfig(), llm, @@ -677,7 +709,9 @@ export class SessionManager { s: ManagedSession, _userText: string, ): Promise { - const llm = createDefaultMainAgentLlm(s.trackingRef); + const llm = createDefaultMainAgentLlm(s.trackingRef, () => + this.personaForManaged(s), + ); let dialogue = extractDictateDialogue(s.messages); const recipeView = resolveSelectedRecipeView( s.runtime.getBlackboard(), @@ -688,12 +722,17 @@ export class SessionManager { recipeView, recipeOptions: s.recipeOptions, }); + const moduleGuide = await resolveDictateModuleGuideForSession({ + skillName: s.runtime.getActiveSkill()?.name, + moduleCatalog: s.moduleCatalog, + }); const result = await runDictateTurn({ llm, dialogue, recipeName: recipeView?.name, recipeBrief, + moduleGuide, handlers: { listProducts: () => this.listDictateProducts(s), writeProduct: (tag, content, order) => { @@ -1037,6 +1076,7 @@ export class SessionManager { * 保证黑板上有 输出.开场白,供开玩主区展示。 * 优先级:设计.开场白(转述产物)> 已有 输出.开场白 > 其它产物拼装 > 非转述聊天兜底。 * 切勿用 dictate_reply(常含确认/建议补充)盖过已钉的设计.开场白。 + * 返回值已展开 @ 指令(进游玩时绑定当前用户角色 / 掷骰)。 */ private ensurePlayOpeningWritten( s: ManagedSession, @@ -1049,19 +1089,36 @@ export class SessionManager { ?.content?.trim() || ""; if (fromDesign) { + const expanded = this.expandDirectivesText( + fromDesign, + this.playDirectiveContext(), + ); const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? ""; - if (existing !== fromDesign) { + if (existing !== expanded) { board.write({ tag: OPENING_OUTPUT_TAG, - content: fromDesign, + content: expanded, source: "runtime", }); } - return fromDesign; + return expanded; } const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? ""; - if (existing) return existing; + if (existing) { + const expanded = this.expandDirectivesText( + existing, + this.playDirectiveContext(), + ); + if (expanded !== existing) { + board.write({ + tag: OPENING_OUTPUT_TAG, + content: expanded, + source: "runtime", + }); + } + return expanded; + } const fromProducts = this.deriveOpeningFromDictateProducts(s); const fromMessages = fromProducts @@ -1070,12 +1127,43 @@ export class SessionManager { const opening = (fromProducts || fromMessages).trim(); if (!opening) return ""; + const expanded = this.expandDirectivesText( + opening, + this.playDirectiveContext(), + ); board.write({ tag: OPENING_OUTPUT_TAG, - content: opening, + content: expanded, source: "runtime", }); - return opening; + return expanded; + } + + private personaForManaged(s: ManagedSession): ReturnType< + typeof personaForLifecycle + > { + if (!s.runtime) return personaForLifecycle("design"); + return personaForLifecycle(inferLifecycleStage(s.runtime.getSession())); + } + + private playDirectiveContext(): DirectiveContext { + const p = getActivePersona(); + if (!p) return {}; + return { + persona: { name: p.name, description: p.description }, + }; + } + + private directiveContextFor(s: ManagedSession): DirectiveContext { + const p = this.personaForManaged(s); + if (!p) return {}; + return { persona: p }; + } + + /** 发送 / 开场白:匹配即替换;骰子写入展开后正文,不重掷。 */ + private expandDirectivesText(text: string, ctx: DirectiveContext): string { + if (!text.includes("@") && !text.includes("{{user}}")) return text; + return expandAtDirectives(text, ctx).text; } private deriveOpeningFromMessages(messages: ChatMessage[] | undefined): string { @@ -1320,6 +1408,57 @@ export class SessionManager { return toRunSnapshotMeta(next); } + /** 快照内创作产物(按流程步骤序);含 play-working */ + getGameSnapshotProducts( + bookId: string, + snapshotId: string, + ): { save: RunSnapshotMeta; products: SnapshotProductRow[] } { + if (!getBook(bookId)) throw new Error("Book 不存在"); + const snapshot = loadRunSnapshotFile(bookId, snapshotId); + if (!snapshot) throw new Error("存档不存在"); + return { + save: toRunSnapshotMeta(snapshot), + products: listSnapshotProducts(snapshot.blackboardItems), + }; + } + + /** 手改快照某一产物 tag;写回文件。当前游玩工作副本会同步活板。 */ + patchGameSnapshotProduct( + bookId: string, + snapshotId: string, + tag: string, + content: string, + ): { save: RunSnapshotMeta; products: SnapshotProductRow[] } { + if (!getBook(bookId)) throw new Error("Book 不存在"); + const trimmedTag = tag.trim(); + if (!trimmedTag) throw new Error("tag 不能为空"); + const snapshot = loadRunSnapshotFile(bookId, snapshotId); + if (!snapshot) throw new Error("存档不存在"); + const blackboardItems = patchBlackboardTagContent( + snapshot.blackboardItems, + trimmedTag, + content, + ); + const next: RunSnapshot = { ...snapshot, blackboardItems }; + saveRunSnapshotFile(next); + + if (snapshotId === PLAY_WORKING_SNAPSHOT_ID) { + const active = this.getActiveSessionForBook(bookId); + if (active) { + try { + this.writeUserBoardTag(active.id, trimmedTag, content); + } catch { + /* 快照已写回 */ + } + } + } + + return { + save: toRunSnapshotMeta(next), + products: listSnapshotProducts(blackboardItems), + }; + } + private latestInstanceSnapshot(bookId: string): RunSnapshot | null { const meta = this.listGameSnapshots(bookId).find((s) => s.kind === "instance"); if (!meta) return null; @@ -1448,7 +1587,9 @@ export class SessionManager { reloadAllLlms(): number { let count = 0; for (const s of this.sessions.values()) { - const llm = reloadDefaultMainAgentLlm(s.trackingRef); + const llm = reloadDefaultMainAgentLlm(s.trackingRef, () => + this.personaForManaged(s), + ); s.runtime.reloadLlm(llm, !hasRealLlmConfig()); count += 1; } @@ -1478,7 +1619,11 @@ export class SessionManager { this.captureCheckpoint(s), ); const reason = s.runtime.getSession().waitingReason; - const trimmed = text.trim(); + const trimmedRaw = text.trim(); + const trimmed = this.expandDirectivesText( + trimmedRaw, + this.directiveContextFor(s), + ); const sidecarAnswers = reason?.kind === "review_artifact" && reason.questions?.length && @@ -1660,7 +1805,10 @@ export class SessionManager { /** 编辑用户消息 → 新分支 + 从该点重跑 */ async editMessage(id: string, messageId: string, newText: string): Promise { const s = this.require(id); - const trimmed = newText.trim(); + const trimmed = this.expandDirectivesText( + newText.trim(), + this.directiveContextFor(s), + ); if (!trimmed) throw new Error("内容不能为空"); const idx = s.messages.findIndex((m) => m.id === messageId); @@ -1907,6 +2055,172 @@ export class SessionManager { return this.toView(id); } + /** + * 多模型对比:冻结该消息 contextTrace,对各 profile 并行重放;不改会话。 + * 采用结果见 adoptModelCompare。 + */ + async startModelCompare( + id: string, + messageId: string, + profileIds: string[], + ): Promise { + const s = this.require(id); + const resolved = this.resolveMessageContextTrace(s, messageId); + if (!resolved?.trace?.messages?.length) { + throw new Error("这条消息没有可重放的上下文(可能已被修剪)"); + } + const { message, trace } = resolved; + const job = createCompareJob({ + sessionId: id, + messageId: message.id, + profileIds, + original: { + text: String(message.body ?? message.text ?? ""), + thinking: message.thinking, + model: trace.model ?? message.tokenUsage?.model, + }, + }); + void runCompareJob(job, trace).catch((err) => { + job.status = "error"; + const detail = err instanceof Error ? err.message : String(err); + for (const c of job.candidates) { + if (c.status === "pending" || c.status === "running") { + c.status = "error"; + c.error = detail; + c.finishedAt = new Date().toISOString(); + } + } + }); + return job; + } + + getModelCompare(id: string, jobId: string): ModelCompareJob { + const job = getCompareJob(jobId); + if (!job || job.sessionId !== id) { + throw new Error("对比任务不存在或已过期"); + } + return job; + } + + /** + * 采用某一 profile 的对比结果:写入消息 branch; + * 选用版为 active,其余成功候选与原文一并留作废案(可 swipe)。 + */ + async adoptModelCompare( + id: string, + jobId: string, + profileId: string, + ): Promise { + const s = this.require(id); + const job = this.getModelCompare(id, jobId); + const chosen = findCandidate(job, profileId); + if (!chosen || chosen.status !== "done" || chosen.content == null) { + throw new Error("只能采用已成功生成的结果"); + } + + const idx = s.messages.findIndex((m) => m.id === job.messageId); + if (idx < 0) throw new Error("原消息已不存在,无法采用"); + const target = s.messages[idx]; + + const checkpoint = + s.branchState.preMessageCheckpoints[idx] ?? + s.runtime.getLastWorkerRunSnapshot() ?? + this.captureCheckpoint(s); + + const branch = ensureBranchForRefresh(s.branchState, s.messages, idx, { + runtimeSession: checkpoint.runtimeSession, + blackboardItems: checkpoint.blackboardItems, + }); + + const baseCheckpoint = this.captureCheckpoint(s); + const successCandidates = job.candidates.filter( + (c) => c.status === "done" && c.content != null, + ); + const tailAfter = s.messages.slice(idx + 1).map((m) => ({ ...m })); + + let chosenVariantIndex = branch.activeIndex; + for (const candidate of successCandidates) { + const head: ChatMessage = { + ...target, + id: randomUUID(), + text: candidate.content!, + body: candidate.content!, + thinking: candidate.thinking, + createdAt: new Date().toISOString(), + tokenUsage: { + totalTokens: candidate.usage?.totalTokens ?? 0, + promptTokens: 0, + completionTokens: 0, + cachedTokens: candidate.usage?.cachedTokens, + cacheMissTokens: candidate.usage?.cacheMissTokens, + caller: `model-compare:${candidate.profileId}`, + model: candidate.model, + }, + branchGroupId: branch.groupId, + branchIndex: branch.variants.length, + }; + const variantIndex = branch.variants.length; + branch.variants.push({ + messages: [head, ...tailAfter.map((m) => ({ ...m }))], + checkpoint: cloneCheckpoint(baseCheckpoint), + }); + if (candidate.profileId === profileId) { + chosenVariantIndex = variantIndex; + } + } + + branch.activeIndex = chosenVariantIndex; + const active = branch.variants[chosenVariantIndex]; + if (!active) throw new Error("采用失败:版本缺失"); + + s.messages = [ + ...s.messages.slice(0, branch.anchorIndex), + ...(active.messages as ChatMessage[]), + ]; + s.runtime.restoreFromCheckpoint( + active.checkpoint.runtimeSession, + active.checkpoint.blackboardItems, + ); + for (const m of s.messages.slice(branch.anchorIndex)) { + m.branchGroupId = branch.groupId; + m.branchIndex = branch.activeIndex; + m.branchTotal = branch.variants.length; + } + + this.syncCreationDialogue(s); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + this.persist(s); + return this.toView(id); + } + + private resolveMessageContextTrace( + s: ManagedSession, + messageId: string, + ): { message: ChatMessage; trace: LlmContextTrace } | null { + const msg = s.messages.find((m) => m.id === messageId); + if (msg?.contextTrace?.messages?.length) { + return { message: msg, trace: msg.contextTrace }; + } + const review = buildReviewArtifactView( + s.runtime.getSession(), + s.messages, + s.runtime.getBlackboard(), + s.moduleCatalog, + ); + if ( + review?.contextTrace?.messages?.length && + (review.sourceMessageId === messageId || review.id === messageId) + ) { + const source = + (review.sourceMessageId + ? s.messages.find((m) => m.id === review.sourceMessageId) + : undefined) ?? msg; + if (!source) return null; + return { message: source, trace: review.contextTrace }; + } + return null; + } + async approve( id: string, stepParams?: Record, @@ -2290,7 +2604,11 @@ export class SessionManager { const runtime = new PhaseRuntime({ autoStubWorker: !hasRealLlmConfig(), - llm: createDefaultMainAgentLlm(trackingRef), + llm: createDefaultMainAgentLlm(trackingRef, () => + managedRef + ? this.personaForManaged(managedRef) + : personaForLifecycle("design"), + ), onMessage, ...thinkingHandlers, initialSession: params.runtimeSession, @@ -3056,7 +3374,19 @@ export class SessionManager { } const tokens = getSessionTokenTotals(id); - const messages = this.enrichDisplayMessages(session, [...s.messages]); + const lastWorkerSnap = s.runtime.getLastWorkerRunSnapshot(); + const messages = this.enrichDisplayMessages(session, [...s.messages]).map( + (m, idx) => ({ + ...m, + canRefresh: canAttemptRefresh(m, { + lastWorkerId: lastWorkerSnap?.workerId, + hasLastWorkerSnapshot: Boolean(lastWorkerSnap), + hasMessageCheckpoint: Boolean( + s.branchState.preMessageCheckpoints[idx], + ), + }), + }), + ); const lifecycleStage = inferLifecycleStage(session); const skillPackId = sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined); @@ -3161,6 +3491,13 @@ export class SessionManager { lastTotal: tokens.last?.totalTokens, byCaller: tokens.byCaller, }, + personas: listPersonas().map((p) => ({ id: p.id, name: p.name })), + activePersona: (() => { + const p = getActivePersona(); + return p + ? { id: p.id, name: p.name, description: p.description } + : null; + })(), }; } @@ -3619,6 +3956,29 @@ async function resolveDictateRecipeBriefForSession(params: { return view.declaration?.trim() || undefined; } +/** Boss 直聘:注入能力何时落盘(modules meta);优先会话已载 catalog */ +async function resolveDictateModuleGuideForSession(params: { + skillName?: string | null; + moduleCatalog?: ModuleCatalog | null; +}): Promise { + let catalog = params.moduleCatalog ?? null; + if (!catalog?.modules?.length) { + const skillName = params.skillName?.trim(); + if (!skillName) return undefined; + try { + const skill = await loadSkill(skillName); + const packRoot = skill.skillPackRoot?.trim(); + if (!packRoot) return undefined; + catalog = await loadModuleCatalog(packRoot); + } catch (err) { + console.error("[会话] 加载转述能力目录失败", err); + return undefined; + } + } + if (!catalog?.modules?.length) return undefined; + return formatModuleCatalogForDictate(catalog); +} + function buildWorkerSetUserView( blackboard: Blackboard, session?: RuntimeSession, diff --git a/web/agent-ui.js b/web/agent-ui.js index fae9134..fcda820 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -164,28 +164,32 @@ function canEditMessage(msg) { } function canRefreshMessage(msg) { + if (!msg?.id) return false; + if (typeof msg.canRefresh === "boolean") return msg.canRefresh; if (msg.role === "user") return false; const kind = msg.kind ?? "system_info"; return kind === "worker_questions" || kind === "worker_output"; } -/** ‹ n/total › 进退 + 可重出;后续输入基于当前激活版本 */ +/** ‹ n/total › 仅在有多版时出现;↻ 仅在这条真能重跑时出现 */ function renderVariantNavHtml(msg) { if (!msg?.id) return ""; const total = Math.max(1, Number(msg.branchTotal) || 1); const index = Math.min(total, Math.max(1, (msg.branchIndex ?? 0) + 1)); const refreshable = canRefreshMessage(msg); - if (total <= 1 && !refreshable) return ""; + const showPager = total > 1; + if (!showPager && !refreshable) return ""; const id = esc(msg.id); - const prevDis = index <= 1 ? " disabled" : ""; - const nextDis = index >= total ? " disabled" : ""; + const pager = showPager + ? ` + ${index}/${total} + ` + : ""; const refresh = refreshable ? `` : ""; - return ` - - ${index}/${total} - + return ` + ${pager} ${refresh} `; } @@ -330,6 +334,7 @@ function showMsgMenu(card, x, y) { const delBtn = document.getElementById("msg-menu-delete"); const rollbackBtn = document.getElementById("msg-menu-rollback"); const ctxBtn = document.getElementById("msg-menu-context"); + const compareBtn = document.getElementById("msg-menu-compare"); if (editBtn) editBtn.toggleAttribute("hidden", !canEdit); if (delBtn) delBtn.toggleAttribute("hidden", !canDelete); if (rollbackBtn) { @@ -338,6 +343,7 @@ function showMsgMenu(card, x, y) { rollbackMode === "pending-input" ? "回到这一轮输入" : "回退到这里"; } if (ctxBtn) ctxBtn.toggleAttribute("hidden", !hasContext); + if (compareBtn) compareBtn.toggleAttribute("hidden", !hasContext); menu.hidden = false; menu.style.left = `${x}px`; menu.style.top = `${y}px`; @@ -387,6 +393,10 @@ function wireMsgActionMenu(handlers) { msgMenuState.handlers?.onViewContext?.(contextMessageId); return; } + if (action === "compare") { + msgMenuState.handlers?.onCompareModels?.(contextMessageId); + return; + } if (action === "edit") { startInlineEdit(card, body); return; @@ -4414,20 +4424,7 @@ function renderReviewWorkspace(review, opts = {}) { const copy = reviewComposerCopy(review.workerId, { outputTags: review.outputTags, }); - const sourceMsg = - opts.sourceMessage || - (review.sourceMessageId - ? { id: review.sourceMessageId, kind: "worker_output", branchIndex: 0, branchTotal: 1 } - : null); - // 有源消息时尽量用真实 branch 元数据;至少给出可重出入口 - const navMsg = opts.sourceMessage - ? { - ...opts.sourceMessage, - kind: opts.sourceMessage.kind || "worker_output", - } - : sourceMsg - ? { ...sourceMsg, kind: "worker_output" } - : null; + const navMsg = opts.sourceMessage || null; const variantNav = navMsg ? renderVariantNavHtml(navMsg) : ""; return ` @@ -5096,15 +5093,7 @@ function fillCoordRail(visible, view, handlers) { function resolveReviewSourceMessage(view) { const id = view?.reviewArtifact?.sourceMessageId; if (!id) return null; - const found = (view.messages ?? []).find((m) => m.id === id); - if (found) return found; - return { - id, - role: "system", - kind: "worker_output", - branchIndex: 0, - branchTotal: 1, - }; + return (view.messages ?? []).find((m) => m.id === id) ?? null; } function mountReviewWorkspace(stage, view, handlers, opts = {}) { diff --git a/web/app.js b/web/app.js index dfcf2aa..a172a80 100644 --- a/web/app.js +++ b/web/app.js @@ -32,12 +32,22 @@ import { initMarkdownRender, isMarkdownRenderEnabled, } from "./markdown.js"; +import { wireDirectiveAutocomplete } from "./directive-autocomplete.js"; +import { + refreshDirectiveHighlight, + wireDirectiveHighlight, +} from "./directive-highlight.js"; let sessionId = null; let activeBookId = null; let lastView = null; let books = []; let composerForceInput = false; +/** 全局用户角色(不依赖会话);含 description */ +let globalPersonas = []; +let globalActivePersonaId = null; +/** 面板内正在编辑的角色 id;`__new__` 表示新建 */ +let personaEditingId = null; let pendingComposerDraft = ""; let pendingComposerDraftSeq = 0; /** 正在提交底栏:重绘时不要把已捕获的正文再塞进 pending */ @@ -560,6 +570,259 @@ function composerModeChip(spec) { return `${esc(label)}`; } +function activePersonaRecord() { + return ( + globalPersonas.find((p) => p.id === globalActivePersonaId) || + globalPersonas[0] || + null + ); +} + +function shortPersonaDesc(text) { + const t = String(text || "").trim(); + if (!t) return "(无人设描述)"; + return t.length > 72 ? `${t.slice(0, 72)}…` : t; +} + +function personaPickerHtml() { + const personas = globalPersonas; + const active = activePersonaRecord(); + const activeName = active?.name || "玩家"; + const wasOpen = $("persona-menu")?.open === true; + + const cards = personas + .map((p) => { + const activeCls = p.id === active?.id ? " is-active" : ""; + if (personaEditingId === p.id) { + return `
+ + +
+ + +
+
`; + } + return `
+
${esc(p.name)}
+
${esc(shortPersonaDesc(p.description))}
+
+ + ${ + personas.length > 1 + ? `` + : "" + } +
+
`; + }) + .join(""); + + const newForm = + personaEditingId === "__new__" + ? `
+ + +
+ + +
+
` + : ""; + + return `
+ + 我 + ${esc(activeName)} + + +
`; +} + +function renderPersonaPicker() { + const host = $("persona-picker-host"); + if (!host) return; + if (!globalPersonas.length && personaEditingId !== "__new__") { + host.hidden = false; + host.innerHTML = `
+ 我未设置 +
+
+ 用户角色 + +
+
+
`; + wirePersonaMenu(); + refreshDirectiveHighlight(); + return; + } + host.hidden = false; + host.innerHTML = personaPickerHtml(); + wirePersonaMenu(); + refreshDirectiveHighlight(); +} + +async function loadPersonas() { + try { + const data = await api("/api/personas"); + globalPersonas = (data.personas || []).map((p) => ({ + id: p.id, + name: p.name, + description: p.description || "", + })); + globalActivePersonaId = data.activeId ?? data.active?.id ?? null; + if (lastView) { + lastView.personas = globalPersonas.map((p) => ({ + id: p.id, + name: p.name, + })); + const active = activePersonaRecord(); + lastView.activePersona = active + ? { + id: active.id, + name: active.name, + description: active.description || "", + } + : null; + } + renderPersonaPicker(); + } catch (err) { + console.error(err); + } +} + +function keepPersonaMenuOpen() { + const menu = $("persona-menu"); + if (menu) menu.open = true; +} + +function wirePersonaMenu() { + const host = $("persona-picker-host"); + if (!host) return; + + host.onclick = async (e) => { + const actBtn = e.target.closest("[data-persona-act]"); + if (actBtn && host.contains(actBtn)) { + e.preventDefault(); + e.stopPropagation(); + const act = actBtn.getAttribute("data-persona-act"); + const id = actBtn.getAttribute("data-id"); + try { + if (act === "add") { + personaEditingId = "__new__"; + renderPersonaPicker(); + keepPersonaMenuOpen(); + host.querySelector(".persona-edit input[name=name]")?.focus(); + return; + } + if (act === "edit") { + personaEditingId = id; + renderPersonaPicker(); + keepPersonaMenuOpen(); + host.querySelector(".persona-edit input[name=name]")?.focus(); + return; + } + if (act === "cancel-edit") { + personaEditingId = null; + renderPersonaPicker(); + keepPersonaMenuOpen(); + return; + } + if (act === "save-new") { + const box = host.querySelector("[data-persona-edit='__new__']"); + const name = box?.querySelector("input[name=name]")?.value?.trim() || ""; + const description = + box?.querySelector("textarea[name=description]")?.value || ""; + if (!name) { + host.querySelector(".persona-edit input[name=name]")?.focus(); + return; + } + await api("/api/personas", { + method: "POST", + body: JSON.stringify({ name, description, activate: true }), + }); + personaEditingId = null; + await loadPersonas(); + keepPersonaMenuOpen(); + return; + } + if (act === "save-edit") { + const box = host.querySelector( + `[data-persona-edit="${CSS.escape(id || "")}"]`, + ); + const name = box?.querySelector("input[name=name]")?.value?.trim() || ""; + const description = + box?.querySelector("textarea[name=description]")?.value || ""; + if (!name) return; + await api(`/api/personas/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify({ name, description }), + }); + personaEditingId = null; + await loadPersonas(); + keepPersonaMenuOpen(); + return; + } + if (act === "delete") { + if (!confirm("删除这个用户角色?")) return; + await api(`/api/personas/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + personaEditingId = null; + await loadPersonas(); + keepPersonaMenuOpen(); + return; + } + } catch (err) { + console.error(err); + alert(err.message || "操作失败"); + } + return; + } + + const card = e.target.closest(".persona-card[data-persona-id]"); + if (card && host.contains(card) && !e.target.closest(".persona-card-actions")) { + e.preventDefault(); + const pid = card.getAttribute("data-persona-id"); + if (!pid || pid === globalActivePersonaId) return; + try { + const data = await api( + `/api/personas/${encodeURIComponent(pid)}/activate`, + { method: "POST" }, + ); + globalActivePersonaId = data.activeId ?? pid; + if (Array.isArray(data.personas)) { + globalPersonas = data.personas.map((p) => ({ + id: p.id, + name: p.name, + description: p.description || "", + })); + } + personaEditingId = null; + if (sessionId) { + const view = await api( + `/api/sessions/${encodeURIComponent(sessionId)}`, + ); + renderSession(view); + } else { + renderPersonaPicker(); + } + $("persona-menu")?.removeAttribute("open"); + } catch (err) { + console.error(err); + } + } + }; +} + function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) { const chip = composerModeChip(spec); const tone = spec.acceptAction?.tone @@ -567,11 +830,22 @@ function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) { : ""; return `
${chip} - ${textareaHtml} +
+ + ${textareaHtml} +
${trailing ? `
${trailing}
` : ""}
`; } +function wireComposerDirectiveUi(input) { + if (!input) return; + wireDirectiveAutocomplete(input); + wireDirectiveHighlight(input, { + getPersona: () => activePersonaRecord(), + }); +} + function composerMoreMenuHtml() { const on = isMarkdownRenderEnabled(); return `
@@ -664,6 +938,7 @@ function clearComposerInput() { autosizeComposerInput(input); syncPickReplanButton(input); refreshReviewComposerChrome(); + refreshDirectiveHighlight(input); } /** 正文已提交:立刻清空底栏,且后续重绘不要把刚发出去的字再塞回来。 */ @@ -703,7 +978,10 @@ function renderComposer(view, loading) { root.querySelector("[data-act=retry_run]")?.addEventListener("click", () => retryRun()); applyPendingComposerDraft(); const input = $("composer-input"); - if (input) autosizeComposerInput(input); + if (input) { + autosizeComposerInput(input); + wireComposerDirectiveUi(input); + } return; } if (spec.mode === "idle") { @@ -991,6 +1269,7 @@ function applyPendingComposerDraft() { syncPickReplanButton(input); input.focus(); refreshReviewComposerChrome(); + refreshDirectiveHighlight(input); } /** 无输入框的确认态:Enter = 主按钮 */ @@ -1099,6 +1378,7 @@ function wireComposerForm(opts = {}) { input?.addEventListener("input", grow); input?.addEventListener("change", grow); requestAnimationFrame(grow); + wireComposerDirectiveUi(input); input?.focus(); } @@ -1192,6 +1472,106 @@ function hideBookMenu() { bookMenuBookId = null; } +let saveMenuState = { bookId: null, saveId: null, label: "" }; + +function hideSaveMenu() { + const menu = $("save-action-menu"); + if (menu) menu.hidden = true; + saveMenuState = { bookId: null, saveId: null, label: "" }; +} + +function showSaveMenu(bookId, saveId, label, x, y) { + const menu = $("save-action-menu"); + if (!menu) return; + hideBookMenu(); + saveMenuState = { bookId, saveId, label: label || "" }; + menu.hidden = false; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 4)}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 4)}px`; + } +} + +async function openSnapshotProductsEditor(bookId, saveId, label) { + const dlg = $("dialog-snapshot-products"); + const title = $("snapshot-products-title"); + const meta = $("snapshot-products-meta"); + const body = $("snapshot-products-body"); + if (!dlg || !body) return; + if (title) title.textContent = label ? `产物 · ${label}` : "快照产物"; + if (meta) meta.textContent = "按创作步骤展开;点「改」手改正文后保存。"; + body.innerHTML = `

加载中…

`; + if (typeof dlg.showModal === "function") dlg.showModal(); + else dlg.setAttribute("open", ""); + + try { + const data = await api( + `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`, + ); + const products = Array.isArray(data.products) ? data.products : []; + if (!products.length) { + body.innerHTML = `

这份快照里还没有可编辑产物。

`; + return; + } + body.innerHTML = products + .map((p, i) => { + const preview = String(p.content || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 160); + return `
+
+ ${esc(p.label || p.tag)} + ${esc(p.tag)} + +
+
${esc(preview)}${ + String(p.content || "").length > 160 ? "…" : "" + }
+
`; + }) + .join(""); + body.dataset.bookId = bookId; + body.dataset.saveId = saveId; + body._products = products; + } catch (err) { + body.innerHTML = `

${esc(err.message || "读取失败")}

`; + } +} + +async function saveSnapshotProductEdit(bookId, saveId, tag, content, rowEl, products, idx) { + const data = await api( + `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`, + { + method: "PATCH", + body: JSON.stringify({ tag, content }), + }, + ); + const next = Array.isArray(data.products) ? data.products : products; + const body = $("snapshot-products-body"); + if (body) body._products = next; + const updated = next.find((p) => p.tag === tag) || { tag, content, label: tag }; + products[idx] = updated; + const preview = String(updated.content || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 160); + rowEl.innerHTML = ` +
+ ${esc(updated.label || updated.tag)} + ${esc(updated.tag)} + +
+
${esc(preview)}${ + String(updated.content || "").length > 160 ? "…" : "" + }
`; +} + function refreshBookMenuLabels() { const n = selectedBookIds.size; const multi = bookSelectMode && n > 0; @@ -1221,6 +1601,7 @@ function refreshBookMenuLabels() { function showBookMenu(bookId, x, y) { const menu = $("book-action-menu"); if (!menu) return; + hideSaveMenu(); bookMenuBookId = bookId; refreshBookMenuLabels(); menu.hidden = false; @@ -1371,6 +1752,7 @@ function renderExplorerRow({ onClick, onDelete, onRename, + onContextMenu, child, twistie, expanded, @@ -1417,7 +1799,13 @@ function renderExplorerRow({ e.stopPropagation(); onDelete?.(); }); - if (bookId && !child) { + if (onContextMenu) { + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e); + }); + } else if (bookId && !child) { row.addEventListener("contextmenu", (e) => { e.preventDefault(); if (bookSelectMode && !selectedBookIds.has(bookId)) { @@ -1459,6 +1847,8 @@ function renderProductContents(book, product, list) { meta: playHere && !activePlaySaveId ? "进行中" : "工作副本", active: playHere && !activePlaySaveId, onClick: () => void openCurrentPlay(book.id), + onContextMenu: (e) => + showSaveMenu(book.id, "play-working", "当前游玩", e.clientX, e.clientY), }), ); } @@ -1481,6 +1871,8 @@ function renderProductContents(book, product, list) { onClick: () => void loadPlaySave(book.id, run.id), onRename: () => void renamePlaySave(book.id, run.id, run.label), onDelete: () => void deletePlaySave(book.id, run.id, run.label, "游玩存档"), + onContextMenu: (e) => + showSaveMenu(book.id, run.id, run.label, e.clientX, e.clientY), }), ); } @@ -1541,10 +1933,12 @@ function renderBookContents(book, list) { renderExplorerRow({ name: product.label, meta: bits.join(" · "), - title: "打开定稿,双击改名", + title: "打开定稿,双击改名;右键编辑产物", onClick: () => navigateToProduct(book.id, product.id), onRename: () => void renamePlaySave(book.id, product.id, product.label), onDelete: () => void deletePlaySave(book.id, product.id, product.label, "定稿"), + onContextMenu: (e) => + showSaveMenu(book.id, product.id, product.label, e.clientX, e.clientY), }), ); } @@ -1919,6 +2313,17 @@ function renderHeader(view, loading) { if (btnInst) btnInst.hidden = !showSaveInstance; const btnExport = $("btn-export"); if (btnExport) btnExport.disabled = !(view.messages?.length); + if (view.activePersona?.id) { + globalActivePersonaId = view.activePersona.id; + const hit = globalPersonas.find((p) => p.id === view.activePersona.id); + if (hit) { + hit.name = view.activePersona.name || hit.name; + if (view.activePersona.description != null) { + hit.description = view.activePersona.description; + } + } + } + renderPersonaPicker(); } function closeWorkspace({ emptyCopy = "" } = {}) { @@ -2099,6 +2504,7 @@ function renderSession(view, loading = false) { messageAction("delete", messageId); }, onViewContext: (messageId) => showContextTraceDialog(messageId), + onCompareModels: (messageId) => openModelComparePicker(messageId), onSwitchVariant: (messageId, direction) => messageAction("variant", messageId, { direction }), onRetryRun: () => retryRun(), @@ -2745,6 +3151,200 @@ function showContextTraceDialog(messageId) { dlg.showModal(); } +let comparePickerMessageId = null; +let comparePollTimer = null; +let compareActiveJobId = null; + +function stopComparePoll() { + if (comparePollTimer) { + clearTimeout(comparePollTimer); + comparePollTimer = null; + } +} + +async function openModelComparePicker(messageId) { + if (!sessionId) return; + const msg = (lastView?.messages ?? []).find((m) => m.id === messageId); + const review = lastView?.reviewArtifact; + const hasTrace = + msg?.contextTrace?.messages?.length || + (review && + (review.sourceMessageId === messageId || review.id === messageId) && + review.contextTrace?.messages?.length); + if (!hasTrace) { + alert("这条消息没有可重放的上下文(可能已被修剪)"); + return; + } + comparePickerMessageId = messageId; + const list = $("compare-profile-list"); + const dlg = $("dialog-model-compare-pick"); + if (!list || !dlg) return; + list.innerHTML = `

加载 API 配置…

`; + dlg.showModal(); + try { + const data = await api("/api/profiles"); + const profiles = (data.profiles ?? []).filter((p) => p.apiKey?.trim()); + if (!profiles.length) { + list.innerHTML = `

没有可用的 API 配置,请先到设置页添加。

`; + return; + } + list.innerHTML = profiles + .map( + (p, i) => ` + `, + ) + .join(""); + } catch (err) { + list.innerHTML = `

${esc(err.message || "加载失败")}

`; + } +} + +async function startModelCompareFromPicker() { + if (!sessionId || !comparePickerMessageId) return; + const checked = [ + ...document.querySelectorAll('input[name="compare-profile"]:checked'), + ].map((el) => el.value); + if (checked.length < 1) { + alert("请至少选择一个 API 配置"); + return; + } + $("dialog-model-compare-pick")?.close(); + try { + const job = await api( + `/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(comparePickerMessageId)}/compare`, + { + method: "POST", + body: JSON.stringify({ profileIds: checked }), + }, + ); + openModelCompareWindow(job); + } catch (err) { + alert(err.message || "无法开始对比"); + } +} + +function openModelCompareWindow(job) { + stopComparePoll(); + compareActiveJobId = job.id; + const dlg = $("dialog-model-compare"); + const meta = $("compare-meta"); + if (!dlg || !meta) return; + meta.textContent = `任务 ${job.id.slice(0, 8)} · 原文模型 ${job.original?.model || "?"} · 共 ${job.candidates?.length ?? 0} 个候选`; + renderCompareJob(job); + dlg.showModal(); + scheduleComparePoll(); +} + +function scheduleComparePoll() { + stopComparePoll(); + if (!compareActiveJobId || !sessionId) return; + comparePollTimer = setTimeout(async () => { + try { + const job = await api( + `/api/sessions/${encodeURIComponent(sessionId)}/compare/${encodeURIComponent(compareActiveJobId)}`, + ); + renderCompareJob(job); + if (job.status === "running") scheduleComparePoll(); + } catch (err) { + const meta = $("compare-meta"); + if (meta) meta.textContent = err.message || "轮询失败"; + } + }, 800); +} + +function renderCompareJob(job) { + const host = $("compare-results"); + if (!host) return; + const original = job.original ?? {}; + const cards = [ + `
+
+ 原文 + ${esc(original.model || "当前结果")} +
+
对照 · 不会写入废案
+
${esc(original.text || "")}
+
`, + ...(job.candidates ?? []).map((c) => { + const statusText = + c.status === "done" + ? c.usage?.totalTokens + ? `完成 · ${Number(c.usage.totalTokens).toLocaleString("zh-CN")} tokens` + : "完成" + : c.status === "error" + ? `失败 · ${c.error || ""}` + : c.status === "running" + ? "生成中…" + : "排队中"; + const body = + c.status === "done" + ? [ + c.thinking ? `【思考】\n${c.thinking}\n\n【正文】\n` : "", + c.content || "", + ].join("") + : c.error || ""; + const adoptBtn = + c.status === "done" + ? `
+ +
` + : ""; + return `
+
+ ${esc(c.profileName || c.profileId)} + ${esc(c.model || "")} +
+
${esc(statusText)}
+
${esc(body)}
+ ${adoptBtn} +
`; + }), + ]; + host.innerHTML = cards.join(""); +} + +async function adoptCompareResult(profileId) { + if (!sessionId || !compareActiveJobId || !profileId) return; + const seq = beginUiRequest(); + try { + const view = await api( + `/api/sessions/${encodeURIComponent(sessionId)}/compare/${encodeURIComponent(compareActiveJobId)}/adopt`, + { + method: "POST", + body: JSON.stringify({ profileId }), + }, + ); + if (isStaleUiRequest(seq)) return; + stopComparePoll(); + $("dialog-model-compare")?.close(); + compareActiveJobId = null; + renderSession(view, false); + } catch (err) { + alert(err.message || "采用失败"); + } +} + +$("btn-start-model-compare")?.addEventListener("click", () => { + void startModelCompareFromPicker(); +}); +$("btn-close-model-compare")?.addEventListener("click", () => { + stopComparePoll(); + compareActiveJobId = null; + $("dialog-model-compare")?.close(); +}); +$("compare-results")?.addEventListener("click", (e) => { + const profileId = e.target.closest("[data-adopt-profile]")?.getAttribute("data-adopt-profile"); + if (!profileId) return; + if (!confirm("采用此结果?其它成功候选会作为废案保留,可用左右切换查看。")) return; + void adoptCompareResult(profileId); +}); + $("btn-copy-context")?.addEventListener("click", async () => { const body = $("context-trace-body")?.textContent ?? ""; try { @@ -2805,14 +3405,103 @@ $("book-action-menu")?.addEventListener("click", (e) => { }); document.addEventListener("click", (e) => { if (!e.target.closest("#book-action-menu")) hideBookMenu(); + if (!e.target.closest("#save-action-menu")) hideSaveMenu(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideBookMenu(); + hideSaveMenu(); if (bookSelectMode || selectedBookIds.size) setBookSelectMode(false); } }); -document.addEventListener("scroll", hideBookMenu, true); +document.addEventListener("scroll", () => { + hideBookMenu(); + hideSaveMenu(); +}, true); + +$("save-action-menu")?.addEventListener("click", (e) => { + const action = e.target + .closest("[data-save-menu-action]") + ?.getAttribute("data-save-menu-action"); + const { bookId, saveId, label } = saveMenuState; + hideSaveMenu(); + if (!bookId || !saveId || !action) return; + if (action === "edit-products") { + void openSnapshotProductsEditor(bookId, saveId, label); + } +}); + +$("snapshot-products-body")?.addEventListener("click", (e) => { + const body = $("snapshot-products-body"); + if (!body) return; + const bookId = body.dataset.bookId; + const saveId = body.dataset.saveId; + const products = body._products; + if (!bookId || !saveId || !Array.isArray(products)) return; + + const editBtn = e.target.closest("[data-snap-edit]"); + if (editBtn) { + const idx = Number(editBtn.getAttribute("data-snap-edit")); + const p = products[idx]; + const row = body.querySelector(`.snap-product[data-idx="${idx}"]`); + if (!p || !row) return; + row.innerHTML = ` +
+ ${esc(p.label || p.tag)} + ${esc(p.tag)} +
+ +
+ + +
`; + const ta = row.querySelector("textarea"); + if (ta) { + ta.value = p.content || ""; + ta.focus(); + } + return; + } + + const cancelBtn = e.target.closest("[data-snap-cancel]"); + if (cancelBtn) { + const idx = Number(cancelBtn.getAttribute("data-snap-cancel")); + const p = products[idx]; + const row = body.querySelector(`.snap-product[data-idx="${idx}"]`); + if (!p || !row) return; + const preview = String(p.content || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 160); + row.innerHTML = ` +
+ ${esc(p.label || p.tag)} + ${esc(p.tag)} + +
+
${esc(preview)}${ + String(p.content || "").length > 160 ? "…" : "" + }
`; + return; + } + + const saveBtn = e.target.closest("[data-snap-save]"); + if (saveBtn) { + const idx = Number(saveBtn.getAttribute("data-snap-save")); + const p = products[idx]; + const row = body.querySelector(`.snap-product[data-idx="${idx}"]`); + const ta = row?.querySelector("textarea"); + if (!p || !row || !ta) return; + saveBtn.disabled = true; + void saveSnapshotProductEdit(bookId, saveId, p.tag, ta.value, row, products, idx).catch( + (err) => { + saveBtn.disabled = false; + alert(err.message || "保存失败"); + }, + ); + } +}); + $("form-new-book")?.addEventListener("submit", (e) => { e.preventDefault(); createBook(); @@ -2881,7 +3570,7 @@ async function init() { syncThemeMenuUi(); try { void populateDirectorSelect(); - await loadBooks(); + await Promise.all([loadBooks(), loadPersonas()]); if (books.length) { showCatalog(); } else { diff --git a/web/index.html b/web/index.html index fe9215b..0bd27d9 100644 --- a/web/index.html +++ b/web/index.html @@ -83,6 +83,7 @@