会话与主界面接通角色、指令、对比与快照产物。
发送时展开占位,并把重出门槛、模型对比与快照编辑挂到现有交互上。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+373
-13
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user