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

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

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,