会话与主界面接通角色、指令、对比与快照产物。

发送时展开占位,并把重出门槛、模型对比与快照编辑挂到现有交互上。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 01:15:47 +08:00
co-authored by Cursor
parent 26328751ee
commit f1f836af54
5 changed files with 1574 additions and 55 deletions
+373 -13
View File
@@ -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<AgentMessageKind>([
@@ -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<void> {
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<SessionView> {
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<ModelCompareJob> {
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<SessionView> {
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<string, unknown>,
@@ -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<string | undefined> {
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,
+20 -31
View File
@@ -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
? `<button type="button" class="msg-action" data-msg-action="variant-prev" data-msg-id="${id}"${index <= 1 ? " disabled" : ""} aria-label="上一版">‹</button>
<span class="msg-variant-count">${index}/${total}</span>
<button type="button" class="msg-action" data-msg-action="variant-next" data-msg-id="${id}"${index >= total ? " disabled" : ""} aria-label="下一版">›</button>`
: "";
const refresh = refreshable
? `<button type="button" class="msg-action msg-action-primary" data-msg-action="refresh" data-msg-id="${id}" title="重出一版(新版本)">↻</button>`
: "";
return `<span class="msg-variant-nav" title="切换版本;在此版本上继续输入">
<button type="button" class="msg-action" data-msg-action="variant-prev" data-msg-id="${id}"${prevDis} aria-label="上一版">‹</button>
<span class="msg-variant-count">${index}/${total}</span>
<button type="button" class="msg-action" data-msg-action="variant-next" data-msg-id="${id}"${nextDis} aria-label="下一版">›</button>
return `<span class="msg-variant-nav" title="${showPager ? "切换版本;在此版本上继续输入" : "重出一版"}">
${pager}
${refresh}
</span>`;
}
@@ -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 = {}) {
+695 -6
View File
@@ -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 `<span class="composer-mode-chip" data-task="${esc(id)}"${tone} title="${esc(tip)}">${esc(label)}</span>`;
}
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 `<div class="persona-edit" data-persona-edit="${esc(p.id)}">
<input type="text" name="name" value="${esc(p.name)}" placeholder="角色名" maxlength="40" />
<textarea name="description" placeholder="简单人设描述(外貌、性格、身份…)">${esc(p.description || "")}</textarea>
<div class="persona-edit-actions">
<button type="button" class="btn-sm" data-persona-act="cancel-edit">取消</button>
<button type="button" class="btn-sm btn-primary" data-persona-act="save-edit" data-id="${esc(p.id)}">保存</button>
</div>
</div>`;
}
return `<div class="persona-card${activeCls}" data-persona-id="${esc(p.id)}" role="button" tabindex="0">
<div class="persona-card-name">${esc(p.name)}</div>
<div class="persona-card-desc">${esc(shortPersonaDesc(p.description))}</div>
<div class="persona-card-actions">
<button type="button" data-persona-act="edit" data-id="${esc(p.id)}">编辑</button>
${
personas.length > 1
? `<button type="button" data-persona-act="delete" data-id="${esc(p.id)}">删除</button>`
: ""
}
</div>
</div>`;
})
.join("");
const newForm =
personaEditingId === "__new__"
? `<div class="persona-edit" data-persona-edit="__new__">
<input type="text" name="name" value="" placeholder="角色名" maxlength="40" />
<textarea name="description" placeholder="简单人设描述(外貌、性格、身份…)"></textarea>
<div class="persona-edit-actions">
<button type="button" class="btn-sm" data-persona-act="cancel-edit">取消</button>
<button type="button" class="btn-sm btn-primary" data-persona-act="save-new">创建</button>
</div>
</div>`
: "";
return `<details class="more-menu persona-menu" id="persona-menu"${wasOpen ? " open" : ""}>
<summary class="persona-menu-sum" title="用户角色(@玩家)">
<span class="persona-menu-sum-label">我</span>
<span class="persona-menu-sum-name">${esc(activeName)}</span>
</summary>
<div class="persona-menu-panel" role="menu">
<div class="persona-menu-head">
<span class="persona-menu-head-title">用户角色</span>
<button type="button" class="persona-menu-add" data-persona-act="add">+ 新建</button>
</div>
${newForm}
${cards || `<p class="empty" style="margin:8px;font-size:12px">还没有角色</p>`}
</div>
</details>`;
}
function renderPersonaPicker() {
const host = $("persona-picker-host");
if (!host) return;
if (!globalPersonas.length && personaEditingId !== "__new__") {
host.hidden = false;
host.innerHTML = `<details class="more-menu persona-menu" id="persona-menu">
<summary class="persona-menu-sum" title="用户角色"><span class="persona-menu-sum-label">我</span><span class="persona-menu-sum-name">未设置</span></summary>
<div class="persona-menu-panel">
<div class="persona-menu-head">
<span class="persona-menu-head-title">用户角色</span>
<button type="button" class="persona-menu-add" data-persona-act="add">+ 新建</button>
</div>
</div>
</details>`;
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 `<div class="composer-input-shell" data-task="${esc(spec.task?.id || "")}"${tone}>
${chip}
${textareaHtml}
<div class="composer-input-field">
<div class="composer-input-backdrop" aria-hidden="true"></div>
${textareaHtml}
</div>
${trailing ? `<div class="composer-shell-actions">${trailing}</div>` : ""}
</div>`;
}
function wireComposerDirectiveUi(input) {
if (!input) return;
wireDirectiveAutocomplete(input);
wireDirectiveHighlight(input, {
getPersona: () => activePersonaRecord(),
});
}
function composerMoreMenuHtml() {
const on = isMarkdownRenderEnabled();
return `<details class="more-menu composer-more-menu" id="composer-more-menu">
@@ -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 = `<p class="empty-sm">加载中…</p>`;
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 = `<p class="empty-sm">这份快照里还没有可编辑产物。</p>`;
return;
}
body.innerHTML = products
.map((p, i) => {
const preview = String(p.content || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 160);
return `<div class="snap-product" data-idx="${i}" data-tag="${esc(p.tag)}">
<div class="snap-product-head">
<span class="snap-product-label">${esc(p.label || p.tag)}</span>
<span class="snap-product-tag">${esc(p.tag)}</span>
<button type="button" class="btn-sm snap-product-edit" data-snap-edit="${i}">改</button>
</div>
<div class="snap-product-preview" data-snap-preview="${i}">${esc(preview)}${
String(p.content || "").length > 160 ? "…" : ""
}</div>
</div>`;
})
.join("");
body.dataset.bookId = bookId;
body.dataset.saveId = saveId;
body._products = products;
} catch (err) {
body.innerHTML = `<p class="empty-sm">${esc(err.message || "读取失败")}</p>`;
}
}
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 = `
<div class="snap-product-head">
<span class="snap-product-label">${esc(updated.label || updated.tag)}</span>
<span class="snap-product-tag">${esc(updated.tag)}</span>
<button type="button" class="btn-sm snap-product-edit" data-snap-edit="${idx}">改</button>
</div>
<div class="snap-product-preview" data-snap-preview="${idx}">${esc(preview)}${
String(updated.content || "").length > 160 ? "…" : ""
}</div>`;
}
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 = `<p class="dialog-desc">加载 API 配置…</p>`;
dlg.showModal();
try {
const data = await api("/api/profiles");
const profiles = (data.profiles ?? []).filter((p) => p.apiKey?.trim());
if (!profiles.length) {
list.innerHTML = `<p class="dialog-desc">没有可用的 API 配置,请先到设置页添加。</p>`;
return;
}
list.innerHTML = profiles
.map(
(p, i) => `
<label class="compare-profile-item">
<input type="checkbox" name="compare-profile" value="${esc(p.id)}" ${i < 2 ? "checked" : ""} />
<span>
<strong>${esc(p.name)}</strong>
<div class="muted">${esc(p.model)}</div>
</span>
</label>`,
)
.join("");
} catch (err) {
list.innerHTML = `<p class="dialog-desc">${esc(err.message || "加载失败")}</p>`;
}
}
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 = [
`<article class="compare-card">
<div class="compare-card-head">
<strong>原文</strong>
<span>${esc(original.model || "当前结果")}</span>
</div>
<div class="compare-card-status">对照 · 不会写入废案</div>
<pre class="compare-card-body">${esc(original.text || "")}</pre>
</article>`,
...(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"
? `<div class="compare-card-actions">
<button type="button" class="btn btn-primary" data-adopt-profile="${esc(c.profileId)}">采用此结果</button>
</div>`
: "";
return `<article class="compare-card">
<div class="compare-card-head">
<strong>${esc(c.profileName || c.profileId)}</strong>
<span>${esc(c.model || "")}</span>
</div>
<div class="compare-card-status ${c.status === "error" ? "is-error" : ""}">${esc(statusText)}</div>
<pre class="compare-card-body">${esc(body)}</pre>
${adoptBtn}
</article>`;
}),
];
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 = `
<div class="snap-product-head">
<span class="snap-product-label">${esc(p.label || p.tag)}</span>
<span class="snap-product-tag">${esc(p.tag)}</span>
</div>
<textarea class="snap-product-ta" data-snap-ta="${idx}"></textarea>
<div class="snap-product-actions">
<button type="button" class="btn btn-primary btn-sm" data-snap-save="${idx}">保存</button>
<button type="button" class="btn btn-sm" data-snap-cancel="${idx}">取消</button>
</div>`;
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 = `
<div class="snap-product-head">
<span class="snap-product-label">${esc(p.label || p.tag)}</span>
<span class="snap-product-tag">${esc(p.tag)}</span>
<button type="button" class="btn-sm snap-product-edit" data-snap-edit="${idx}">改</button>
</div>
<div class="snap-product-preview" data-snap-preview="${idx}">${esc(preview)}${
String(p.content || "").length > 160 ? "…" : ""
}</div>`;
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 {
+40
View File
@@ -83,6 +83,7 @@
<header class="main-head" id="main-head">
<div class="main-head-left" aria-hidden="true"></div>
<nav class="main-app-nav" aria-label="应用">
<div id="persona-picker-host" class="persona-picker-host" hidden></div>
<a href="/modules.html">技能</a>
<a href="/shells.html">呈现壳</a>
<a href="/stats.html">Token</a>
@@ -232,8 +233,13 @@
<button type="button" class="context-menu-item context-menu-danger" id="book-menu-delete" data-action="delete">删除</button>
</menu>
<menu id="save-action-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" data-save-menu-action="edit-products">编辑产物</button>
</menu>
<menu id="msg-action-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button>
<button type="button" class="context-menu-item" id="msg-menu-compare" data-msg-menu-action="compare" hidden>对比</button>
<button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button>
<button type="button" class="context-menu-item" id="msg-menu-copy" data-msg-menu-action="copy">复制</button>
<button type="button" class="context-menu-item context-menu-separator-before" id="msg-menu-rollback" data-msg-menu-action="rollback" hidden>回退到这里</button>
@@ -246,6 +252,17 @@
<button type="button" class="context-menu-item context-menu-danger context-menu-separator-before" id="flow-menu-delete" data-flow-menu-action="delete">删除产物</button>
</menu>
<dialog id="dialog-snapshot-products" class="dialog dialog-wide" aria-labelledby="snapshot-products-title">
<form method="dialog" class="context-trace-form" id="form-snapshot-products">
<h2 id="snapshot-products-title">快照产物</h2>
<p class="dialog-desc" id="snapshot-products-meta"></p>
<div id="snapshot-products-body" class="step-artifact-body snapshot-products-body"></div>
<div class="dialog-actions">
<button type="submit" class="btn btn-primary" value="close">关闭</button>
</div>
</form>
</dialog>
<dialog id="dialog-step-artifact" class="dialog dialog-wide" aria-labelledby="step-artifact-title">
<form method="dialog" class="context-trace-form">
<h2 id="step-artifact-title">产物</h2>
@@ -268,6 +285,29 @@
</form>
</dialog>
<dialog id="dialog-model-compare-pick" class="dialog" aria-labelledby="compare-pick-title">
<form method="dialog" id="form-model-compare-pick">
<h2 id="compare-pick-title">选择要对比的模型</h2>
<p class="dialog-desc">用同一份已保存的请求上下文,并行调用所选 API 配置。</p>
<div id="compare-profile-list" class="compare-profile-list"></div>
<div class="dialog-actions">
<button type="submit" class="btn" value="cancel">取消</button>
<button type="button" class="btn btn-primary" id="btn-start-model-compare">开始对比</button>
</div>
</form>
</dialog>
<dialog id="dialog-model-compare" class="dialog dialog-compare" aria-labelledby="compare-title">
<div class="compare-shell">
<div class="dialog-head-row">
<h2 id="compare-title">模型对比</h2>
<button type="button" class="btn" id="btn-close-model-compare">关闭</button>
</div>
<p class="dialog-desc" id="compare-meta"></p>
<div id="compare-results" class="compare-results"></div>
</div>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>
+446 -5
View File
@@ -312,7 +312,7 @@ body[data-nav-level="root"] .burst-badge { display: none; }
body.rail-collapsed .panel-rail { align-items: stretch; }
/* composer 按内容自适应;中间区吃掉剩余高度,整页锁在视口内 */
/* panel-main:上=悬停遮盖 chrome;下=工作区(左中右在 main-body / head 内) */
/* panel-main:上=悬停遮盖 chrome;下=工作区 */
.panel-main {
display: grid;
grid-template-rows: minmax(0, 1fr);
@@ -332,7 +332,7 @@ body.rail-collapsed .panel-rail { align-items: stretch; }
overflow: hidden;
}
/* 顶缘热区 + 遮盖条:不占文档流,悬停/聚焦才展开 */
/* 顶缘热区 + 遮盖条:不占文档流,悬停/聚焦才展开 */
.main-chrome {
position: absolute;
top: 0;
@@ -362,13 +362,15 @@ body.rail-collapsed .panel-rail { align-items: stretch; }
}
.main-chrome:hover,
.main-chrome:focus-within,
.main-chrome:has(.theme-menu[open]) {
.main-chrome:has(.theme-menu[open]),
.main-chrome:has(.persona-menu[open]) {
height: auto;
min-height: 40px;
}
.main-chrome:hover .main-head,
.main-chrome:focus-within .main-head,
.main-chrome:has(.theme-menu[open]) .main-head {
.main-chrome:has(.theme-menu[open]) .main-head,
.main-chrome:has(.persona-menu[open]) .main-head {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
@@ -425,7 +427,7 @@ body.rail-collapsed .panel-rail { align-items: stretch; }
}
@media (hover: none) {
/* 触控无悬停:顶缘仍可点开,聚焦后保持 */
/* 触控无悬停:顶缘仍可点开,聚焦后保持 */
.main-chrome {
height: 12px;
}
@@ -1494,6 +1496,54 @@ body.design-workspace .flow-node.is-selectable:focus-visible .flow-node-menu-btn
overflow: auto;
margin: 8px 0 0;
}
.snapshot-products-body .snap-product {
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
padding: 8px 0;
}
.snapshot-products-body .snap-product:first-child {
border-top: 0;
padding-top: 0;
}
.snapshot-products-body .snap-product-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
}
.snapshot-products-body .snap-product-label {
font-weight: 600;
}
.snapshot-products-body .snap-product-tag {
color: var(--muted);
font-size: 0.85em;
}
.snapshot-products-body .snap-product-edit {
margin-left: auto;
}
.snapshot-products-body textarea.snap-product-ta {
width: 100%;
min-height: 160px;
resize: vertical;
font: inherit;
line-height: 1.45;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: inherit;
}
.snapshot-products-body .snap-product-actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.snapshot-products-body .snap-product-preview {
white-space: pre-wrap;
max-height: 8em;
overflow: auto;
font-size: 0.92em;
opacity: 0.92;
}
body.design-workspace .flow-node[data-run="current"] {
border-color: var(--accent);
background: var(--surface);
@@ -5096,6 +5146,7 @@ body.design-workspace .af-mosaic {
}
/* 唯一描边:输入 + 操作同框 */
.composer-input-shell {
position: relative;
flex: 1 1 auto;
min-width: 0;
min-height: var(--composer-control-h);
@@ -5127,6 +5178,291 @@ body.design-workspace .af-mosaic {
gap: 10px;
padding-bottom: 2px;
}
.persona-picker-host {
display: flex;
align-items: center;
position: relative;
margin-right: 2px;
}
.persona-picker-host[hidden] {
display: none !important;
}
.persona-menu {
position: relative;
}
.persona-menu > summary {
list-style: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 11rem;
padding: 5px 10px;
border-radius: 6px;
border: 0;
background: transparent;
color: var(--muted);
font-size: 13px;
font-weight: 500;
line-height: 1.2;
}
.persona-menu > summary::-webkit-details-marker { display: none; }
.persona-menu > summary:hover,
.persona-menu[open] > summary {
color: var(--text);
background: var(--accent-soft);
}
.persona-menu-sum-label {
opacity: 0.7;
flex-shrink: 0;
}
.persona-menu-sum-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.persona-menu-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 50;
width: min(320px, 88vw);
max-height: min(420px, 70vh);
overflow: auto;
padding: 8px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
box-shadow: 0 12px 32px color-mix(in srgb, var(--text) 14%, transparent);
}
.persona-menu-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
padding: 0 4px 6px;
border-bottom: 1px solid var(--border);
}
.persona-menu-head-title {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.persona-menu-add {
border: 0;
background: transparent;
color: var(--accent);
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.persona-menu-add:hover {
background: var(--accent-soft);
}
.persona-card {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 8px;
margin: 0 0 4px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
text-align: left;
cursor: pointer;
color: var(--text);
}
.persona-card:hover {
background: color-mix(in srgb, var(--text) 5%, transparent);
}
.persona-card.is-active {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: var(--accent-soft);
}
.persona-card-name {
font-size: 13px;
font-weight: 600;
line-height: 1.3;
}
.persona-card-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-wrap;
}
.persona-card-actions {
display: flex;
gap: 6px;
margin-top: 2px;
}
.persona-card-actions button {
border: 0;
background: transparent;
color: var(--muted);
font-size: 11px;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
}
.persona-card-actions button:hover {
color: var(--text);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.persona-edit {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 4px;
background: var(--bg, var(--surface));
}
.persona-edit input,
.persona-edit textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 13px;
}
.persona-edit textarea {
min-height: 72px;
resize: vertical;
}
.persona-edit-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.persona-edit-actions .btn-sm {
font-size: 12px;
}
.directive-ac-popup {
position: absolute;
left: 8px;
bottom: calc(100% + 6px);
z-index: 40;
min-width: 220px;
max-width: min(360px, 90vw);
max-height: 220px;
overflow: auto;
padding: 4px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.directive-ac-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.directive-ac-item:hover,
.directive-ac-item.is-active {
background: var(--hover, rgba(127, 127, 127, 0.12));
}
.directive-ac-item code {
font-size: 12px;
}
.directive-ac-hint {
font-size: 11px;
color: var(--muted);
}
/* 指令高亮:镜像层 + 透明字 + 悬停预览 */
.composer-input-field {
position: relative;
flex: 1 1 auto;
min-width: 0;
align-self: stretch;
display: flex;
}
.composer-input-field textarea {
width: 100%;
flex: 1 1 auto;
}
.composer-input-backdrop {
position: absolute;
inset: 0;
overflow: hidden;
padding: 6px 4px;
margin: 0;
border: 0;
white-space: pre-wrap;
overflow-wrap: break-word;
word-wrap: break-word;
line-height: 1.5;
font: inherit;
color: var(--text);
pointer-events: none;
z-index: 0;
}
.composer-input-backdrop .dir-hit {
border-radius: 3px;
padding: 0 1px;
margin: 0 -1px;
background: var(--accent-soft);
color: var(--accent);
font: inherit;
font-weight: inherit;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.composer-input-backdrop .dir-hit--dice {
background: color-mix(in srgb, var(--warn) 16%, transparent);
color: color-mix(in srgb, var(--warn) 75%, var(--text));
}
.composer-input-backdrop .dir-hit--desc {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.directive-preview-tip {
position: fixed;
z-index: 80;
max-width: min(280px, calc(100vw - 24px));
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: var(--shadow);
pointer-events: none;
font-size: 12px;
line-height: 1.4;
color: var(--text);
}
.directive-preview-tip-title {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 650;
color: var(--accent);
margin-bottom: 2px;
}
.directive-preview-tip-body {
color: var(--muted);
}
.composer-mode-chip {
flex-shrink: 0;
align-self: center;
@@ -5183,6 +5519,19 @@ body.design-workspace .af-mosaic {
background: transparent;
box-shadow: none;
field-sizing: content;
position: relative;
z-index: 1;
caret-color: var(--text);
color: var(--text);
}
.composer-input-field textarea:not(.is-empty) {
color: transparent;
-webkit-text-fill-color: transparent;
}
.composer-input-field textarea:not(.is-empty)::selection {
background: var(--accent-soft);
color: transparent;
-webkit-text-fill-color: transparent;
}
.composer-input-shell textarea:focus {
outline: none;
@@ -5357,6 +5706,98 @@ body.design-workspace .af-mosaic {
border: 1px solid var(--border);
border-radius: 8px;
}
.dialog-compare {
width: min(1100px, 96vw);
max-width: min(1100px, 96vw);
max-height: min(92vh, 960px);
padding: 0;
}
.compare-shell {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
max-height: min(90vh, 940px);
}
.compare-profile-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 4px;
max-height: 280px;
overflow: auto;
}
.compare-profile-item {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
line-height: 1.4;
}
.compare-profile-item input {
margin-top: 2px;
}
.compare-profile-item .muted {
color: var(--muted);
font-size: 12px;
}
.compare-results {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
overflow: auto;
min-height: 280px;
max-height: min(70vh, 720px);
align-items: stretch;
}
.compare-card {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
background: color-mix(in srgb, var(--panel, #fff) 94%, #000 6%);
min-height: 200px;
}
.compare-card-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.compare-card-head strong {
font-size: 13px;
}
.compare-card-head span {
font-size: 12px;
color: var(--muted);
}
.compare-card-status {
font-size: 12px;
color: var(--muted);
}
.compare-card-status.is-error {
color: var(--danger, #b91c1c);
}
.compare-card-body {
flex: 1;
margin: 0;
padding: 8px;
overflow: auto;
max-height: 420px;
font-size: 12.5px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
background: var(--panel, #fff);
border: 1px solid var(--border);
border-radius: 6px;
}
.compare-card-actions {
display: flex;
justify-content: flex-end;
}
.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); }