diff --git a/src/server/message-branch.ts b/src/server/message-branch.ts index 837e38f..194c845 100644 --- a/src/server/message-branch.ts +++ b/src/server/message-branch.ts @@ -215,13 +215,15 @@ export function isRefreshableMessage(msg: BranchableMessage): boolean { return kind === "worker_questions" || kind === "worker_output"; } -/** 与 refreshMessage 同一套门槛:类型对,且有 worker 快照或该条检查点。 */ +/** 与 refreshMessage 同一套门槛:类型对,且有可回滚快照。 */ export function canAttemptRefresh( msg: BranchableMessage, opts: { lastWorkerId?: string; hasLastWorkerSnapshot: boolean; hasMessageCheckpoint: boolean; + /** 消息已在分支里(曾重 roll / swipe) */ + hasBranchVariants?: boolean; }, ): boolean { if (!isRefreshableMessage(msg)) return false; @@ -229,6 +231,7 @@ export function canAttemptRefresh( (typeof msg.actor === "string" && msg.actor.trim()) || opts.lastWorkerId?.trim(); if (!workerId) return false; + if (opts.hasBranchVariants) return true; return opts.hasMessageCheckpoint || opts.hasLastWorkerSnapshot; } diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 822906a..ad49b2d 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -20,12 +20,17 @@ import { OPENING_OUTPUT_TAG, OPENING_PERSONA_CHOICE_TAG, OPENING_PERSONA_TAG, + OPENING_PRODUCT_FAMILY, SLOT_OPENING_SELECTED_INDEX, parseOpeningSealPayload, readSealedOpeningPersona, serializeOpeningPersona, splitOpeningDocument, } from "../skills/opening-seal.js"; +import { buildPlayAuxView, collectOpenings } from "../skills/play-aux.js"; +import { + dictateProductFamily, +} from "../dictate/repeatable-tags.js"; import { PLAY_WORKING_SNAPSHOT_ID, PLAY_WORKING_SNAPSHOT_LABEL, @@ -45,6 +50,16 @@ import { reloadDefaultMainAgentLlm, type LlmTrackingRef, } from "../runtime/llm-factory.js"; +import { + acceptPreferenceCandidate, + collectPreferenceCandidates, + formatRecentDialogueForCollect, +} from "../preference/index.js"; +import { + loadAppSettings, + normalizeContextTraceKeepLatest, + normalizePreferenceCollectEveryTurns, +} from "../config/settings.js"; import { isAbortError, runWithAbortSignal } from "../llm/run-abort.js"; import { getSessionTokenTotals, type MessageTokenUsage } from "../stats/token-store.js"; import type { PersistedBookSession } from "../types/book-session.js"; @@ -78,10 +93,6 @@ import { sessionSkillPackId, skillPacksMatch, } from "../book/skill-id.js"; -import { - loadAppSettings, - normalizeContextTraceKeepLatest, -} from "../config/settings.js"; import { pruneContextTraces, type LlmContextTrace, @@ -183,10 +194,16 @@ import { personaForLifecycle, } from "../persona/store.js"; import { + DICTATE_OPENING_REPEATABLE_FAMILIES, + ensureProtagonistFromPersona, + formatPersonaDescription, overlayPlayPersona, + parseProtagonistSpec, + personaBoundToOpening, + PROTAGONIST_TAG, + protagonistTagForOpeningSlot, readSessionProtagonist, } from "../persona/protagonist-bind.js"; -import { buildPlayAuxView } from "../skills/play-aux.js"; import type { TableCellPatch } from "../blackboard/table-cells.js"; import { appendBranchVariant, @@ -336,6 +353,8 @@ export type SessionView = { openingPersonaChoice?: "opening" | "global" | null; /** 游玩辅助 Tab:表格 / 开场与角色 */ playAux?: import("../skills/play-aux.js").PlayAuxView; + /** 待审核偏好候选:展示时可编辑,收下 / 不要 */ + preferenceReview?: Array<{ id: string; content: string }>; }; export type SessionAction = @@ -425,6 +444,10 @@ type ManagedSession = { runGen?: number; /** 本轮开始时的消息条数,取消时裁掉执行中产生的调度句 */ runMessageCutoff?: number; + /** 本局游玩已完成的用户回合数(偏好采集计数;不进快照) */ + preferencePlayTurns?: number; + /** 待审核的偏好候选(可编辑后收下/不要) */ + preferenceReview?: import("../preference/collect.js").PreferenceCandidate[]; }; export class SessionManager { @@ -822,9 +845,12 @@ export class SessionManager { skillName: s.runtime.getActiveSkill()?.name, moduleCatalog: s.moduleCatalog, }); - const repeatableFamilies = (s.moduleCatalog?.modules ?? []) - .filter((m) => m.repeatable === true && m.artifact?.trim()) - .map((m) => m.artifact!.trim()); + const repeatableFamilies = [ + ...(s.moduleCatalog?.modules ?? []) + .filter((m) => m.repeatable === true && m.artifact?.trim()) + .map((m) => m.artifact!.trim()), + ...DICTATE_OPENING_REPEATABLE_FAMILIES, + ]; const result = await runDictateTurn({ llm, @@ -837,6 +863,22 @@ export class SessionManager { listProducts: () => this.listDictateProducts(s), writeProduct: (tag, content, order) => { const board = s.runtime.getBlackboard(); + let writeContent = content; + if (dictateProductFamily(tag) === OPENING_PRODUCT_FAMILY) { + const split = splitOpeningDocument(content); + if (split.persona) { + ensureProtagonistFromPersona( + board, + split.persona, + protagonistTagForOpeningSlot(tag), + ); + } + // 仅有用户角色 meta、无叙事:抬到主角设定,不污染开场白 + if (split.persona && !split.text.trim()) { + return; + } + writeContent = split.text.trim() || content; + } const existing = board.getLatestByTag(tag); const prev = existing?.metadata?.[DICTATE_ORDER_META_KEY]; const resolved = @@ -847,7 +889,7 @@ export class SessionManager { : defaultDictateOrder(tag); board.write({ tag, - content, + content: writeContent, source: "agent", metadata: { ...(existing?.metadata ?? {}), @@ -1190,6 +1232,8 @@ export class SessionManager { s.messages = []; } s.branchState = createMessageBranchState(); + s.preferencePlayTurns = 0; + s.preferenceReview = undefined; } /** 开玩时记下是否用这条开场自带的用户角色;须在恢复快照之后写,否则会被冲掉。 */ @@ -1207,7 +1251,7 @@ export class SessionManager { /** * 保证黑板上有 输出.开场白,供开玩主区展示。 - * 优先级:设计.开场白(转述产物)> 已有 输出.开场白 > 其它产物拼装 > 非转述聊天兜底。 + * 优先级:选中的设计.开场白[#槽] / 开场白与开场变量候选 > 已有 输出.开场白 > 其它产物拼装。 * 切勿用 dictate_reply(常含确认/建议补充)盖过已钉的设计.开场白。 * 返回值已展开 @ 指令(进游玩时绑定当前用户角色 / 掷骰)。 */ @@ -1217,26 +1261,69 @@ export class SessionManager { ): string { this.sealOpeningPersonaFromBoard(s); const board = s.runtime.getBlackboard(); - const fromDesign = - board.getContentByTag("设计.开场白")?.trim() || - this.listDictateProducts(s).find((p) => p.tag === "设计.开场白") - ?.content?.trim() || - ""; - if (fromDesign) { - const body = splitOpeningDocument(fromDesign).text || fromDesign; - const expanded = this.expandDirectivesText( - body, - this.playDirectiveContext(s), - ); - const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? ""; - if (existing !== expanded) { - board.write({ - tag: OPENING_OUTPUT_TAG, - content: expanded, - source: "runtime", - }); + const openings = collectOpenings(board); + const selectedRaw = s.runtime.getSession().slots[SLOT_OPENING_SELECTED_INDEX]; + const selectedIndex = + typeof selectedRaw === "number" + ? selectedRaw + : typeof selectedRaw === "string" && selectedRaw.trim() + ? Number(selectedRaw) + : 0; + const picked = + openings.length > 0 + ? openings[ + Math.min( + Math.max(0, Math.trunc(selectedIndex) || 0), + openings.length - 1, + ) + ] + : null; + + if (picked) { + if (picked.persona) { + ensureProtagonistFromPersona( + board, + picked.persona, + protagonistTagForOpeningSlot(picked.tag), + ); + } + const body = splitOpeningDocument( + board.getContentByTag(picked.tag) ?? picked.text, + ).text.trim() || picked.text.trim(); + if (body) { + // 若叙事与黑板原文不同(剥了 meta),回写该开场 tag + const rawOnBoard = board.getContentByTag(picked.tag)?.trim() ?? ""; + if ( + dictateProductFamily(picked.tag) === OPENING_PRODUCT_FAMILY && + rawOnBoard && + body !== rawOnBoard && + splitOpeningDocument(rawOnBoard).persona + ) { + const existingDesign = board.getLatestByTag(picked.tag); + board.write({ + tag: picked.tag, + content: body, + source: "runtime", + metadata: existingDesign?.metadata, + }); + } + const expanded = this.expandDirectivesText( + body, + this.playDirectiveContext(s), + ); + const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? ""; + if (existing !== expanded) { + board.write({ + tag: OPENING_OUTPUT_TAG, + content: expanded, + source: "runtime", + }); + } + return expanded; + } + if (picked.persona) { + return board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? ""; } - return expanded; } const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? ""; @@ -1274,12 +1361,13 @@ export class SessionManager { return expanded; } - /** 开场 meta 里的用户角色:落到 运行.开场用户角色,不进全局列表。 */ + /** 选中开场绑定的用户角色 → 运行.开场用户角色,并补写同槽主角设定。 */ private sealOpeningPersonaFromBoard(s: ManagedSession): void { const board = s.runtime.getBlackboard(); if (readSealedOpeningPersona(board.getContentByTag(OPENING_PERSONA_TAG))) { return; } + const openings = collectOpenings(board); const selectedRaw = s.runtime.getSession().slots[SLOT_OPENING_SELECTED_INDEX]; const selectedIndex = typeof selectedRaw === "number" @@ -1287,17 +1375,44 @@ export class SessionManager { : typeof selectedRaw === "string" && selectedRaw.trim() ? Number(selectedRaw) : 0; + const picked = + openings.length > 0 + ? openings[ + Math.min( + Math.max(0, Math.trunc(selectedIndex) || 0), + openings.length - 1, + ) + ] + : null; + const fromPicked = picked + ? personaBoundToOpening( + board, + picked.tag, + board.getContentByTag(picked.tag) ?? picked.text, + ) + : null; + const protagonist = parseProtagonistSpec( + board.getContentByTag(PROTAGONIST_TAG), + ); const persona = - parseOpeningSealPayload( - board.getContentByTag("设计.开场白与开场变量"), - selectedIndex, - )?.persona ?? splitOpeningDocument(board.getContentByTag("设计.开场白")).persona; + fromPicked ?? + (protagonist + ? { + name: protagonist.name, + description: formatPersonaDescription(protagonist), + } + : null); if (!persona) return; board.write({ tag: OPENING_PERSONA_TAG, content: serializeOpeningPersona(persona), source: "runtime", }); + ensureProtagonistFromPersona( + board, + persona, + picked ? protagonistTagForOpeningSlot(picked.tag) : PROTAGONIST_TAG, + ); } private personaForManaged(s: ManagedSession): ReturnType< @@ -1366,8 +1481,10 @@ export class SessionManager { private deriveOpeningFromDictateProducts(s: ManagedSession): string { const products = this.listDictateProducts(s); if (!products.length) return ""; - // 开场白是「按正文格式生成的内容」,优先设计.开场白;不要把设计.正文组成(格式)当开场白 - const opening = products.find((p) => p.tag === "设计.开场白")?.content?.trim(); + const opening = + products.find((p) => p.tag === "设计.开场白")?.content?.trim() || + products.find((p) => dictateProductFamily(p.tag) === OPENING_PRODUCT_FAMILY) + ?.content?.trim(); if (opening) return opening; const preferred = products.filter( (p) => @@ -1860,11 +1977,90 @@ export class SessionManager { console.error("[会话] 发送消息失败", detail); await this.recoverFailedRun(s, detail); } + await this.maybeCollectPreferencesAfterPlayTurn(s, { + countedPlayInput: Boolean( + waitingBefore?.kind === "input" && + isPlayLayerActive(s.runtime.getSession().slots) && + s.runtime.getSession().waitingReason?.kind === "input", + ), + }); this.syncCreationDialogue(s); this.persist(s); return this.toView(id); } + /** + * 游玩回合结束后:计数;到期则采集偏好候选(不改本局用户约束)。 + */ + private async maybeCollectPreferencesAfterPlayTurn( + s: ManagedSession, + opts: { countedPlayInput: boolean }, + ): Promise { + if (!opts.countedPlayInput) return; + if (!isPlayLayerActive(s.runtime.getSession().slots)) return; + if (s.preferenceReview?.length) return; + + const every = normalizePreferenceCollectEveryTurns( + loadAppSettings().preferenceCollectEveryTurns, + ); + if (every <= 0) return; + + const turns = (s.preferencePlayTurns ?? 0) + 1; + s.preferencePlayTurns = turns; + if (turns % every !== 0) return; + if (!hasRealLlmConfig()) return; + + const dialogue = formatRecentDialogueForCollect(s.messages); + if (!dialogue.trim()) return; + + try { + const llm = createDefaultMainAgentLlm(s.trackingRef, () => + this.personaForManaged(s), + ); + const candidates = await collectPreferenceCandidates({ + llm, + recentDialogue: dialogue, + }); + if (candidates.length) { + s.preferenceReview = candidates; + s.messages.push( + this.msg( + "system", + `[偏好] 发现 ${candidates.length} 条可记入偏好库的候选,请在卡片上编辑后收下或不要。`, + ), + ); + } + } catch (err) { + console.warn( + "[偏好] 采集失败", + err instanceof Error ? err.message : String(err), + ); + } + } + + /** 审核偏好候选:收下(可改正文)/ 不要。不改本局「设计.用户约束」。 */ + resolvePreferenceReview( + sessionId: string, + candidateId: string, + action: "accept" | "reject", + content?: string, + ): SessionView { + const s = this.require(sessionId); + const list = s.preferenceReview ?? []; + const idx = list.findIndex((c) => c.id === candidateId); + if (idx < 0) throw new Error("没有这条待审偏好"); + const cur = list[idx]!; + if (action === "accept") { + const body = (content ?? cur.content).trim(); + if (!body) throw new Error("偏好内容不能为空"); + acceptPreferenceCandidate(body); + } + const next = list.filter((c) => c.id !== candidateId); + s.preferenceReview = next.length ? next : undefined; + if (s.bookId) this.persist(s); + return this.toView(sessionId); + } + /** * 结构化追问作答:UI 可省略结构,发给 AI 必须含问+答。 * displayText 进气泡;aiText 进 runtime / 黑板。 @@ -2045,7 +2241,15 @@ export class SessionManager { const workerId = target.actor ?? workerSnap?.workerId; if (!workerId) throw new Error("无法识别要刷新的 Worker"); - const checkpoint = workerSnap ?? s.branchState.preMessageCheckpoints[idx]; + const groupId = target.branchGroupId ?? target.id; + const existingBranch = s.branchState.branches[groupId]; + const branchVariantCp = + existingBranch?.variants[existingBranch.activeIndex]?.checkpoint; + + const checkpoint = + workerSnap ?? + s.branchState.preMessageCheckpoints[idx] ?? + branchVariantCp; if (!checkpoint) { throw new Error("缺少重 roll 快照,请尝试编辑上一条用户消息"); } @@ -3547,6 +3751,12 @@ export class SessionManager { hasMessageCheckpoint: Boolean( s.branchState.preMessageCheckpoints[idx], ), + hasBranchVariants: Boolean( + (typeof m.branchTotal === "number" && m.branchTotal > 1) || + (m.branchGroupId && + (s.branchState.branches[m.branchGroupId]?.variants.length ?? 0) > + 1), + ), }), }), ); @@ -3675,6 +3885,9 @@ export class SessionManager { s.runtime.getBlackboard(), s.runtime.getSession().slots, ), + preferenceReview: s.preferenceReview?.length + ? s.preferenceReview.map((c) => ({ id: c.id, content: c.content })) + : undefined, }; } diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 4a23a65..8a0d9b4 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -188,6 +188,35 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "POST" && sub === "/preferences/review") { + const body = JSON.parse(await readBody(req)) as { + candidateId?: string; + action?: "accept" | "reject"; + content?: string; + }; + const candidateId = + typeof body.candidateId === "string" ? body.candidateId.trim() : ""; + const action = body.action; + if (!candidateId || (action !== "accept" && action !== "reject")) { + json(res, 400, { error: "需要 candidateId 与 action=accept|reject" }); + return; + } + try { + const view = sessionManager.resolvePreferenceReview( + sessionId, + candidateId, + action, + typeof body.content === "string" ? body.content : undefined, + ); + json(res, 200, view); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "审核失败", + }); + } + return; + } + if (req.method === "POST" && sub === "/dictate/clear-dialogue") { try { const view = await sessionManager.clearDictateDialogue(sessionId); diff --git a/web/agent-ui.js b/web/agent-ui.js index 6f0098f..695e0f1 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -174,15 +174,21 @@ function canEditMessage(msg) { return msg.role === "user"; } +/** 是否显示重 roll(↻ / 末页 ›)。已有多版时即使服务端 canRefresh=false 也允许再点。 */ 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"; + const kindOk = kind === "worker_questions" || kind === "worker_output"; + if (!kindOk) return false; + const total = Math.max(1, Number(msg.branchTotal) || 1); + // 已经 swipe / 重 roll 出过多版 → 再点应仍可生成 + if (total > 1) return true; + if (typeof msg.canRefresh === "boolean") return msg.canRefresh; + return true; } -/** ‹ n/total › 仅在有多版时出现;↻ 仅在这条真能重跑时出现 */ +/** ‹ n/total › 多版切换;末页 › 与 ↻ 均可重 roll;单版只显示 ↻ */ function renderVariantNavHtml(msg) { if (!msg?.id) return ""; const total = Math.max(1, Number(msg.branchTotal) || 1); @@ -191,15 +197,21 @@ function renderVariantNavHtml(msg) { const showPager = total > 1; if (!showPager && !refreshable) return ""; const id = esc(msg.id); + const atLast = index >= total; + // 末页 ›:可重 roll 时改为 refresh;不可则禁用 + const nextAction = atLast && refreshable ? "refresh" : "variant-next"; + const nextDisabled = atLast && !refreshable; + const nextTitle = atLast && refreshable ? "已是最新版,再点重出一版" : "下一版"; + const nextAria = atLast && refreshable ? "重出一版" : "下一版"; const pager = showPager ? ` ${index}/${total} - ` + ` : ""; const refresh = refreshable ? `` : ""; - return ` + return ` ${pager} ${refresh} `; @@ -228,23 +240,50 @@ const flowMenuState = { handlers: null, }; -function placeMenu(menu, x, y) { +/** html { zoom } 时:clientX/Y 是视口像素,fixed 的 left/top 是未缩放布局坐标 */ +function readDocumentCssZoom() { + const raw = getComputedStyle(document.documentElement).zoom; + if (!raw || raw === "normal") return 1; + const z = Number.parseFloat(raw); + return Number.isFinite(z) && z > 0 ? z : 1; +} + +/** + * 把菜单放到光标旁(视口坐标 clientX/Y)。 + * 自动消化 html zoom,避免菜单相对光标漂移。 + */ +export function placeMenu(menu, clientX, clientY) { + if (!menu) return; + const zoom = readDocumentCssZoom(); + const pad = 4; + 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`; - } + menu.style.position = "fixed"; + menu.style.display = "block"; + menu.style.zIndex = "1200"; + menu.style.right = "auto"; + menu.style.bottom = "auto"; + menu.style.margin = "0"; + // 先放到 0,0 量真实视觉尺寸(getBoundingClientRect 已含 zoom) + menu.style.left = "0"; + menu.style.top = "0"; + const { width: w, height: h } = menu.getBoundingClientRect(); + + // 默认锚在光标处;溢出时只做最小贴边,避免整块翻到光标另一侧(右侧栏会像「差一截」) + let visualLeft = Math.min(clientX, window.innerWidth - w - pad); + let visualTop = Math.min(clientY, window.innerHeight - h - pad); + if (visualLeft < pad) visualLeft = pad; + if (visualTop < pad) visualTop = pad; + + menu.style.left = `${visualLeft / zoom}px`; + menu.style.top = `${visualTop / zoom}px`; } function showFlowNodeMenu(node, x, y, handlers) { const menu = document.getElementById("flow-node-menu"); if (!menu || !node) return; hideMsgMenu(); + hideDictateProductMenu(); const stepId = node.getAttribute("data-flow-done"); if (!stepId) return; flowMenuState.stepId = stepId; @@ -330,19 +369,27 @@ const MSG_PEEK_SIZE_KEY = "wa.msg-peek.size"; const MSG_PEEK_KEEP_VISIBLE = 48; /** 标题栏必须可见的顶边距(px)——保证可拖 */ const MSG_PEEK_TOP_MARGIN = 8; +/** 贴边时与视口边缘的最小间距(px) */ +const MSG_PEEK_VIEW_PAD = 8; const MSG_PEEK_MIN_W = 280; const MSG_PEEK_MIN_H = 180; +const MSG_PEEK_CORNER_HANDLES = new Set(["nw", "ne", "sw", "se"]); + const msgPeekState = { wired: false, dragging: false, resizing: false, + resizeHandle: "se", offsetX: 0, offsetY: 0, resizeStartX: 0, resizeStartY: 0, resizeStartW: 0, resizeStartH: 0, + resizeStartLeft: 0, + resizeStartTop: 0, + resizeAspect: 1, lastLeft: null, lastTop: null, }; @@ -386,16 +433,24 @@ function saveMsgPeekSize(width, height) { } } -function defaultMsgPeekSize() { +function msgPeekViewportMaxSize() { return { - width: Math.min(680, Math.max(MSG_PEEK_MIN_W, window.innerWidth * 0.92)), - height: Math.min(760, Math.max(MSG_PEEK_MIN_H, window.innerHeight * 0.82)), + maxW: Math.max(MSG_PEEK_MIN_W, window.innerWidth - MSG_PEEK_VIEW_PAD * 2), + maxH: Math.max(MSG_PEEK_MIN_H, window.innerHeight - MSG_PEEK_TOP_MARGIN - MSG_PEEK_VIEW_PAD), }; } +function defaultMsgPeekSize() { + const { maxW, maxH } = msgPeekViewportMaxSize(); + return { + width: Math.min(680, Math.max(MSG_PEEK_MIN_W, Math.min(maxW, window.innerWidth * 0.92))), + height: Math.min(760, Math.max(MSG_PEEK_MIN_H, Math.min(maxH, window.innerHeight * 0.82))), + }; +} + +/** 全局上下限:不小于最小尺寸,不大于当前视口可容纳尺寸 */ function clampMsgPeekSize(width, height) { - const maxW = Math.max(MSG_PEEK_MIN_W, window.innerWidth - 16); - const maxH = Math.max(MSG_PEEK_MIN_H, window.innerHeight - MSG_PEEK_TOP_MARGIN * 2); + const { maxW, maxH } = msgPeekViewportMaxSize(); return { width: Math.min(Math.max(MSG_PEEK_MIN_W, width), maxW), height: Math.min(Math.max(MSG_PEEK_MIN_H, height), maxH), @@ -441,6 +496,89 @@ function isMsgPeekFullyOffscreen(panel) { ); } +/** + * 按手柄计算下一帧尺寸与位置。 + * - 左右边:只改宽,高度不变(内容区滚动) + * - 上下边:只改高,宽度不变 + * - 四角:保持宽高比同时缩放 + * 尺寸上限贴紧视口,避免直接撑出屏幕。 + */ +function computeMsgPeekResize(handle, start, dw, dh) { + const fromLeft = handle === "w" || handle === "nw" || handle === "sw"; + const fromTop = handle === "n" || handle === "nw" || handle === "ne"; + const edgeW = handle === "e" || handle === "w"; + const edgeH = handle === "n" || handle === "s"; + const aspect = MSG_PEEK_CORNER_HANDLES.has(handle); + + const rightEdge = start.left + start.w; + const bottomEdge = start.top + start.h; + const minLeft = MSG_PEEK_VIEW_PAD; + const maxRight = window.innerWidth - MSG_PEEK_VIEW_PAD; + const minTop = MSG_PEEK_TOP_MARGIN; + const maxBottom = window.innerHeight - MSG_PEEK_VIEW_PAD; + const ratio = start.aspect > 0 ? start.aspect : start.w / Math.max(1, start.h); + + const maxW = fromLeft + ? Math.max(MSG_PEEK_MIN_W, rightEdge - minLeft) + : Math.max(MSG_PEEK_MIN_W, maxRight - start.left); + const maxH = fromTop + ? Math.max(MSG_PEEK_MIN_H, bottomEdge - minTop) + : Math.max(MSG_PEEK_MIN_H, maxBottom - start.top); + + let nextW = start.w; + let nextH = start.h; + + if (edgeW) { + nextW = Math.min(Math.max(MSG_PEEK_MIN_W, start.w + (fromLeft ? -dw : dw)), maxW); + nextH = start.h; // 只改宽,高度保持,溢出由内容区滚动消化 + } else if (edgeH) { + nextW = start.w; // 只改高,宽度保持 + nextH = Math.min(Math.max(MSG_PEEK_MIN_H, start.h + (fromTop ? -dh : dh)), maxH); + } else if (aspect) { + const rawW = start.w + (fromLeft ? -dw : dw); + const rawH = start.h + (fromTop ? -dh : dh); + const useWidth = + Math.abs(rawW - start.w) / Math.max(1, start.w) >= + Math.abs(rawH - start.h) / Math.max(1, start.h); + if (useWidth) { + nextW = rawW; + nextH = nextW / ratio; + } else { + nextH = rawH; + nextW = nextH * ratio; + } + const upScale = Math.min(maxW / Math.max(1, nextW), maxH / Math.max(1, nextH), 1); + nextW *= upScale; + nextH *= upScale; + if (nextW < MSG_PEEK_MIN_W || nextH < MSG_PEEK_MIN_H) { + const downScale = Math.max( + MSG_PEEK_MIN_W / Math.max(1, nextW), + MSG_PEEK_MIN_H / Math.max(1, nextH), + ); + nextW *= downScale; + nextH *= downScale; + } + nextW = Math.min(Math.max(MSG_PEEK_MIN_W, nextW), maxW); + nextH = nextW / ratio; + if (nextH > maxH) { + nextH = maxH; + nextW = Math.min(nextH * ratio, maxW); + nextH = nextW / ratio; + } + if (nextH < MSG_PEEK_MIN_H) { + nextH = MSG_PEEK_MIN_H; + nextW = Math.min(Math.max(MSG_PEEK_MIN_W, nextH * ratio), maxW); + } + } + + return { + width: nextW, + height: nextH, + left: fromLeft ? rightEdge - nextW : start.left, + top: fromTop ? bottomEdge - nextH : start.top, + }; +} + /** 每次打开:记忆尺寸 + 屏幕居中(标题栏保证可见) */ function placeMsgPeek(panel) { const size = applyMsgPeekSize(panel); @@ -480,21 +618,28 @@ function wireMsgPeekPanel() { e.preventDefault(); }); - const resizeHandle = panel.querySelector("[data-peek-resize]"); - resizeHandle?.addEventListener("pointerdown", (e) => { - if (e.button !== 0) return; - if (msgPeekState.dragging) return; - const rect = panel.getBoundingClientRect(); - msgPeekState.resizing = true; - msgPeekState.resizeStartX = e.clientX; - msgPeekState.resizeStartY = e.clientY; - msgPeekState.resizeStartW = rect.width; - msgPeekState.resizeStartH = rect.height; - panel.classList.add("is-resizing"); - resizeHandle.setPointerCapture?.(e.pointerId); - e.preventDefault(); - e.stopPropagation(); - }); + const resizeHandles = panel.querySelectorAll("[data-peek-resize]"); + for (const resizeHandle of resizeHandles) { + resizeHandle.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + if (msgPeekState.dragging) return; + const handle = resizeHandle.getAttribute("data-peek-resize") || "se"; + const rect = panel.getBoundingClientRect(); + msgPeekState.resizing = true; + msgPeekState.resizeHandle = handle; + msgPeekState.resizeStartX = e.clientX; + msgPeekState.resizeStartY = e.clientY; + msgPeekState.resizeStartW = rect.width; + msgPeekState.resizeStartH = rect.height; + msgPeekState.resizeStartLeft = rect.left; + msgPeekState.resizeStartTop = rect.top; + msgPeekState.resizeAspect = rect.width / Math.max(1, rect.height); + panel.classList.add("is-resizing"); + resizeHandle.setPointerCapture?.(e.pointerId); + e.preventDefault(); + e.stopPropagation(); + }); + } const onMove = (e) => { if (msgPeekState.dragging) { @@ -510,14 +655,24 @@ function wireMsgPeekPanel() { return; } if (msgPeekState.resizing) { - const dw = e.clientX - msgPeekState.resizeStartX; - const dh = e.clientY - msgPeekState.resizeStartY; - const size = clampMsgPeekSize( - msgPeekState.resizeStartW + dw, - msgPeekState.resizeStartH + dh, + const frame = computeMsgPeekResize( + msgPeekState.resizeHandle, + { + w: msgPeekState.resizeStartW, + h: msgPeekState.resizeStartH, + left: msgPeekState.resizeStartLeft, + top: msgPeekState.resizeStartTop, + aspect: msgPeekState.resizeAspect, + }, + e.clientX - msgPeekState.resizeStartX, + e.clientY - msgPeekState.resizeStartY, ); - panel.style.width = `${size.width}px`; - panel.style.height = `${size.height}px`; + panel.style.width = `${frame.width}px`; + panel.style.height = `${frame.height}px`; + panel.style.left = `${frame.left}px`; + panel.style.top = `${frame.top}px`; + msgPeekState.lastLeft = frame.left; + msgPeekState.lastTop = frame.top; } }; const onUp = (e) => { @@ -533,7 +688,9 @@ function wireMsgPeekPanel() { if (msgPeekState.resizing) { msgPeekState.resizing = false; panel.classList.remove("is-resizing"); - resizeHandle?.releasePointerCapture?.(e.pointerId); + for (const handle of resizeHandles) { + handle.releasePointerCapture?.(e.pointerId); + } if (!panel.hidden) { const rect = panel.getBoundingClientRect(); const size = clampMsgPeekSize(rect.width, rect.height); @@ -612,6 +769,8 @@ function readDictateProductContent(card) { const dictateProductMenuState = { card: null, wired: false, + openedAt: 0, + docWired: false, }; function hideDictateProductMenu() { @@ -620,22 +779,47 @@ function hideDictateProductMenu() { dictateProductMenuState.card = null; } +/** 右键落在行间/内边距时,按纵向距离就近命中产物行(slack 放宽命中) */ +function nearestDictateProduct(root, clientX, clientY, slack = 28) { + if (!root) return null; + let best = null; + let bestDist = Infinity; + for (const card of root.querySelectorAll("[data-dictate-product]")) { + const r = card.getBoundingClientRect(); + const inX = clientX >= r.left - slack && clientX <= r.right + slack; + const inY = clientY >= r.top - slack && clientY <= r.bottom + slack; + if (!inX || !inY) continue; + const dx = clientX < r.left ? r.left - clientX : clientX > r.right ? clientX - r.right : 0; + const dy = clientY < r.top ? r.top - clientY : clientY > r.bottom ? clientY - r.bottom : 0; + const dist = dx + dy; + if (dist < bestDist) { + bestDist = dist; + best = card; + } + } + return best; +} + +function resolveDictateProductCard(clientX, clientY, fallbackRoot) { + const hit = document.elementFromPoint(clientX, clientY); + let card = + hit instanceof Element ? hit.closest("[data-dictate-product]") : null; + if (card) return card; + const root = + fallbackRoot || + document.getElementById("coord-drawer-body") || + document.querySelector(".dictate-products-list"); + return nearestDictateProduct(root, clientX, clientY); +} + function showDictateProductMenu(card, x, y) { const menu = document.getElementById("dictate-product-menu"); if (!menu || !card) return; hideMsgMenu(); - hideDictateProductMenu(); + hideFlowNodeMenu(); dictateProductMenuState.card = card; - 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`; - } + dictateProductMenuState.openedAt = Date.now(); + placeMenu(menu, x, y); } function wireDictateProductMenu() { @@ -673,23 +857,39 @@ function wireDictateProductMenu() { }); } -function wireDictateProductContextMenu(root) { - if (!root || root.dataset.dictateProductMenuWired) return; - root.dataset.dictateProductMenuWired = "1"; +/** 文档级捕获:整行任意位置右键都能打开(不依赖 summary 命中) */ +function wireDictateProductContextMenu(_root) { wireDictateProductMenu(); - root.addEventListener("contextmenu", (e) => { - const card = e.target.closest(".dictate-product"); - if (!card || !root.contains(card)) return; - e.preventDefault(); - e.stopPropagation(); - showDictateProductMenu(card, e.clientX, e.clientY); - }); + if (dictateProductMenuState.docWired) return; + dictateProductMenuState.docWired = true; + document.addEventListener( + "contextmenu", + (e) => { + const inRail = + e.target instanceof Element && + (e.target.closest("#coord-drawer-body") || + e.target.closest(".dictate-products-list") || + e.target.closest("[data-dictate-product]")); + if (!inRail) return; + const card = resolveDictateProductCard( + e.clientX, + e.clientY, + document.getElementById("coord-drawer-body"), + ); + if (!card) return; + e.preventDefault(); + e.stopPropagation(); + showDictateProductMenu(card, e.clientX, e.clientY); + }, + true, + ); } function showMsgMenu(card, x, y) { const menu = document.getElementById("msg-action-menu"); if (!menu || !card) return; hideFlowNodeMenu(); + hideDictateProductMenu(); const pack = card.closest(".coord-pack"); const rollbackHost = pack || card; // 上下文按单次请求(这条消息)区分,不能回落到整包里的另一条 @@ -716,22 +916,13 @@ function showMsgMenu(card, x, y) { } 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`; msgMenuState.messageId = card.dataset.messageId ?? null; msgMenuState.contextMessageId = contextTarget?.dataset.messageId ?? card.dataset.messageId ?? null; msgMenuState.rollbackMessageId = rollbackMessageId; msgMenuState.rollbackMode = rollbackMode; msgMenuState.restoreDraft = rollbackHost.dataset.restoreDraft ?? ""; msgMenuState.card = card; - 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`; - } + placeMenu(menu, x, y); } function wireMsgActionMenu(handlers) { @@ -804,13 +995,22 @@ function wireMessageContextMenu(feed, handlers) { if (!feed || feed.dataset.contextWired) return; feed.dataset.contextWired = "1"; wireMsgActionMenu(handlers); - feed.addEventListener("contextmenu", (e) => { - const bubble = e.target.closest(".msg-bubble, .workspace-review, .coord-line, .coord-pack"); - const card = e.target.closest("[data-message-id]"); - if (!bubble || !card || card.classList.contains("msg-pending")) return; - e.preventDefault(); - showMsgMenu(card, e.clientX, e.clientY); - }); + // 与右侧产物菜单一致:捕获阶段 + 光标坐标走 placeMenu(含 html zoom) + feed.addEventListener( + "contextmenu", + (e) => { + const bubble = e.target.closest( + ".msg-bubble, .workspace-review, .coord-line, .coord-pack", + ); + const card = e.target.closest("[data-message-id]"); + if (!bubble || !card || card.classList.contains("msg-pending")) return; + if (!feed.contains(card)) return; + e.preventDefault(); + e.stopPropagation(); + showMsgMenu(card, e.clientX, e.clientY); + }, + true, + ); feed.addEventListener("click", (e) => { const contextTrigger = e.target.closest("[data-context-trigger]"); const contextCard = contextTrigger?.closest("[data-message-id]"); @@ -950,6 +1150,11 @@ function wireMessageFeedActions(feed, handlers) { } if (action === "variant-next") { + // 兜底:末页仍走到 next 时改走重 roll + if (btn.getAttribute("data-variant-at-last") === "1") { + h.onRefreshMessage?.(messageId); + return; + } h.onSwitchVariant?.(messageId, "next"); } }); @@ -5483,27 +5688,29 @@ function renderDictateProductsList(view) { .replace(/^设计\./, "") .replace(/^用户\./, "") .replace(/#/g, " · "); - return `
- - - ${esc(shortTag || tag || "未命名")} + return `
+
-
-
`; + + + `; }) .join("")}`; } @@ -5519,19 +5726,33 @@ function hydrateDictateProductBody(card) { delete body.dataset.pending; } +function setDictateProductOpen(card, open) { + if (!card) return; + const sum = card.querySelector(".dictate-product-sum"); + const body = card.querySelector(".dictate-product-body"); + card.classList.toggle("is-open", open); + if (sum) sum.setAttribute("aria-expanded", open ? "true" : "false"); + if (body) body.hidden = !open; + if (open) hydrateDictateProductBody(card); +} + function wireDictateProductAccordion(root) { if (!root || root.dataset.dictateProductAccordionWired) return; root.dataset.dictateProductAccordionWired = "1"; - root.addEventListener("toggle", (e) => { - const card = e.target; - if (!(card instanceof HTMLDetailsElement)) return; - if (!card.classList.contains("dictate-product--rail")) return; - if (!card.open) return; - hydrateDictateProductBody(card); - root.querySelectorAll("details.dictate-product--rail[open]").forEach((other) => { - if (other !== card) other.open = false; + root.addEventListener("click", (e) => { + const sum = e.target.closest(".dictate-product-sum"); + if (!sum || !root.contains(sum)) return; + // 右键菜单用 pointer,这里只处理左键展开 + if (e.button != null && e.button !== 0) return; + const card = sum.closest("[data-dictate-product]"); + if (!card) return; + e.preventDefault(); + const willOpen = !card.classList.contains("is-open"); + root.querySelectorAll("[data-dictate-product].is-open").forEach((other) => { + if (other !== card) setDictateProductOpen(other, false); }); - }, true); + setDictateProductOpen(card, willOpen); + }); } function fillCoordRailProducts(view) { @@ -6101,7 +6322,14 @@ document.addEventListener("keydown", (e) => { hideFlowNodeMenu(); } }); -document.addEventListener("scroll", () => { +document.addEventListener("scroll", (e) => { + // 打开菜单后短时间内忽略(避免焦点/布局引起的误关) + if (Date.now() - dictateProductMenuState.openedAt < 250) return; + // 菜单自身或菜单内滚动不关 + const menu = document.getElementById("dictate-product-menu"); + if (menu && !menu.hidden && e.target instanceof Node && menu.contains(e.target)) { + return; + } hideMsgMenu(); hideDictateProductMenu(); hideFlowNodeMenu(); diff --git a/web/app.js b/web/app.js index 0a75dbb..e4dc49e 100644 --- a/web/app.js +++ b/web/app.js @@ -6,6 +6,7 @@ import { fillStepArtifactDialog, canOfferSaveProduct, canOfferEnterPlay, + placeMenu, } from "./agent-ui.js"; import { downloadMarkdown, sessionToMarkdown } from "./export.js"; import { renderIntakePanel } from "./intake-ui.js"; @@ -1201,12 +1202,83 @@ function endComposerSubmit() { skipComposerStash = false; } +function renderPreferenceReview(view) { + const host = $("preference-review"); + if (!host) return; + const items = Array.isArray(view?.preferenceReview) ? view.preferenceReview : []; + if (!items.length) { + host.hidden = true; + host.innerHTML = ""; + return; + } + host.hidden = false; + host.innerHTML = ""; + items.forEach((c, i) => { + const card = document.createElement("article"); + card.className = "preference-review-card"; + card.dataset.candidateId = c.id; + + const head = document.createElement("header"); + head.className = "preference-review-head"; + const title = document.createElement("strong"); + title.textContent = `偏好候选${items.length > 1 ? ` ${i + 1}/${items.length}` : ""}`; + const hint = document.createElement("span"); + hint.className = "preference-review-hint"; + hint.textContent = "可编辑后收下;本局正文不受影响"; + head.append(title, hint); + + const ta = document.createElement("textarea"); + ta.className = "preference-review-content"; + ta.rows = 3; + ta.value = c.content ?? ""; + + const actions = document.createElement("div"); + actions.className = "preference-review-actions"; + const acceptBtn = document.createElement("button"); + acceptBtn.type = "button"; + acceptBtn.className = "btn-primary"; + acceptBtn.textContent = "收下"; + acceptBtn.addEventListener("click", () => { + resolvePreferenceReview(c.id, "accept", ta.value); + }); + const rejectBtn = document.createElement("button"); + rejectBtn.type = "button"; + rejectBtn.className = "btn-secondary"; + rejectBtn.textContent = "不要"; + rejectBtn.addEventListener("click", () => { + resolvePreferenceReview(c.id, "reject"); + }); + actions.append(acceptBtn, rejectBtn); + + card.append(head, ta, actions); + host.appendChild(card); + }); +} + +async function resolvePreferenceReview(candidateId, action, content) { + if (!sessionId) return; + try { + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/preferences/review`, { + method: "POST", + body: JSON.stringify({ + candidateId, + action, + ...(action === "accept" ? { content } : {}), + }), + }); + renderSession(view); + } catch (err) { + alert(err.message || "审核失败"); + } +} + function renderComposer(view, loading) { stashComposerDraftFromDom(); if (pendingComposerDraft) composerForceInput = true; const host = $("composer"); const root = $("composer-main") || host; if (!root || !host) return; + renderPreferenceReview(view); if (host._enterHandler) { document.removeEventListener("keydown", host._enterHandler); host._enterHandler = null; @@ -1748,18 +1820,8 @@ function showEnterPicker(bookId, anchorEl) { } pop.hidden = false; const rect = anchorEl.getBoundingClientRect(); - const left = rect.right + 8; - let top = rect.top; - pop.style.left = `${left}px`; - pop.style.top = `${top}px`; - const box = pop.getBoundingClientRect(); - if (box.bottom > window.innerHeight - 8) { - top = Math.max(8, window.innerHeight - box.height - 8); - pop.style.top = `${top}px`; - } - if (box.right > window.innerWidth - 8) { - pop.style.left = `${Math.max(8, rect.left - box.width - 8)}px`; - } + // 与右键菜单同一套定位(消化 html zoom) + placeMenu(pop, rect.right + 8, rect.top); } let saveMenuState = { bookId: null, saveId: null, label: "" }; @@ -1770,22 +1832,17 @@ function hideSaveMenu() { saveMenuState = { bookId: null, saveId: null, label: "" }; } +function placeFixedMenu(menu, x, y) { + placeMenu(menu, x, y); +} + function showSaveMenu(bookId, saveId, label, x, y) { const menu = $("save-action-menu"); if (!menu) return; hideEnterPicker(); 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`; - } + placeFixedMenu(menu, x, y); } async function openSnapshotProductsEditor(bookId, saveId, label) { @@ -1896,16 +1953,7 @@ function showBookMenu(bookId, x, y) { hideSaveMenu(); bookMenuBookId = bookId; refreshBookMenuLabels(); - 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`; - } + placeFixedMenu(menu, x, y); } function isCatalogNav() { diff --git a/web/index.html b/web/index.html index 011edc9..8b26ba8 100644 --- a/web/index.html +++ b/web/index.html @@ -10,7 +10,7 @@ href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap" rel="stylesheet" /> - + + diff --git a/web/styles.css b/web/styles.css index 567388c..45920a5 100644 --- a/web/styles.css +++ b/web/styles.css @@ -2372,7 +2372,7 @@ body.design-workspace .flow-step[data-run="done"] .flow-name { border-radius: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); } -.book-enter-pop[hidden] { display: none; } +.book-enter-pop[hidden] { display: none !important; } .book-enter-item { display: block; width: 100%; @@ -3119,6 +3119,80 @@ body.rail-collapsed .message-feed { border-top: 1px solid var(--border); margin: 0.75em 0; } +.msg-body .md-table-wrap, +.dictate-product-body .md-table-wrap, +.present-region-body .md-table-wrap { + margin: 0.55em 0; + overflow-x: auto; +} +.msg-body .md-table, +.dictate-product-body .md-table, +.present-region-body .md-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92em; + line-height: 1.45; +} +.msg-body .md-table th, +.msg-body .md-table td, +.dictate-product-body .md-table th, +.dictate-product-body .md-table td, +.present-region-body .md-table th, +.present-region-body .md-table td { + border: 1px solid var(--border); + padding: 0.35em 0.55em; + text-align: left; + vertical-align: top; +} +.msg-body .md-table th, +.dictate-product-body .md-table th, +.present-region-body .md-table th { + font-weight: 650; + background: color-mix(in srgb, var(--surface) 88%, var(--accent)); +} +.msg-body .md-details, +.dictate-product-body .md-details, +.present-region-body .md-details { + margin: 0.55em 0; + border: 1px solid var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--bg) 70%, var(--surface)); +} +.msg-body .md-summary, +.dictate-product-body .md-summary, +.present-region-body .md-summary { + cursor: pointer; + list-style: none; + padding: 0.45em 0.65em; + font-weight: 600; + user-select: none; +} +.msg-body .md-summary::-webkit-details-marker, +.dictate-product-body .md-summary::-webkit-details-marker, +.present-region-body .md-summary::-webkit-details-marker { + display: none; +} +.msg-body .md-summary::before, +.dictate-product-body .md-summary::before, +.present-region-body .md-summary::before { + content: "▸"; + display: inline-block; + width: 1em; + margin-right: 0.25em; + color: var(--muted); + transition: transform 0.12s ease; +} +.msg-body .md-details[open] > .md-summary::before, +.dictate-product-body .md-details[open] > .md-summary::before, +.present-region-body .md-details[open] > .md-summary::before { + transform: rotate(90deg); +} +.msg-body .md-details-body, +.dictate-product-body .md-details-body, +.present-region-body .md-details-body { + padding: 0 0.65em 0.55em; + border-top: 1px solid var(--border); +} .more-theme-icon { flex: 0 0 auto; @@ -5350,7 +5424,8 @@ body.design-workspace .af-mosaic { .ws-task-planned .ws-task-status { color: var(--accent); } .ws-open { color: var(--warn); } -/* Floating peek: single entry, no backdrop, drag via header, resize SE */ +/* Floating peek: single entry, no backdrop, drag via header; + edges = width/height only, corners = aspect-locked */ .msg-peek-panel { position: fixed; z-index: 1100; @@ -5410,6 +5485,8 @@ body.design-workspace .af-mosaic { -webkit-box-orient: vertical; } .msg-peek-close { + position: relative; + z-index: 3; flex: 0 0 auto; width: 28px; height: 28px; @@ -5459,27 +5536,92 @@ body.design-workspace .af-mosaic { } .msg-peek-resize { position: absolute; - right: 0; - bottom: 0; - width: 16px; - height: 16px; - cursor: nwse-resize; touch-action: none; z-index: 2; } -.msg-peek-resize::after { +.msg-peek-resize--n { + top: 0; + left: 14px; + right: 14px; + height: 6px; + cursor: ns-resize; +} +.msg-peek-resize--s { + bottom: 0; + left: 14px; + right: 14px; + height: 6px; + cursor: ns-resize; +} +.msg-peek-resize--e { + right: 0; + top: 14px; + bottom: 14px; + width: 6px; + cursor: ew-resize; +} +.msg-peek-resize--w { + left: 0; + top: 14px; + bottom: 14px; + width: 6px; + cursor: ew-resize; +} +.msg-peek-resize--nw, +.msg-peek-resize--ne, +.msg-peek-resize--sw, +.msg-peek-resize--se { + width: 14px; + height: 14px; + z-index: 3; +} +.msg-peek-resize--nw { top: 0; left: 0; cursor: nwse-resize; } +.msg-peek-resize--ne { top: 0; right: 0; cursor: nesw-resize; } +.msg-peek-resize--sw { bottom: 0; left: 0; cursor: nesw-resize; } +.msg-peek-resize--se { bottom: 0; right: 0; cursor: nwse-resize; } +.msg-peek-resize--nw::after, +.msg-peek-resize--ne::after, +.msg-peek-resize--sw::after, +.msg-peek-resize--se::after { content: ""; position: absolute; - right: 3px; - bottom: 3px; width: 9px; height: 9px; - border-right: 2px solid color-mix(in srgb, var(--muted, #888) 70%, transparent); - border-bottom: 2px solid color-mix(in srgb, var(--muted, #888) 70%, transparent); - border-radius: 0 0 2px 0; + border: 2px solid color-mix(in srgb, var(--muted, #888) 70%, transparent); pointer-events: none; } -.msg-peek-panel.is-resizing .msg-peek-resize::after { +.msg-peek-resize--nw::after { + top: 3px; + left: 3px; + border-right: none; + border-bottom: none; + border-radius: 2px 0 0 0; +} +.msg-peek-resize--ne::after { + top: 3px; + right: 3px; + border-left: none; + border-bottom: none; + border-radius: 0 2px 0 0; +} +.msg-peek-resize--sw::after { + bottom: 3px; + left: 3px; + border-right: none; + border-top: none; + border-radius: 0 0 0 2px; +} +.msg-peek-resize--se::after { + right: 3px; + bottom: 3px; + border-left: none; + border-top: none; + border-radius: 0 0 2px 0; +} +.msg-peek-panel.is-resizing .msg-peek-resize--nw::after, +.msg-peek-panel.is-resizing .msg-peek-resize--ne::after, +.msg-peek-panel.is-resizing .msg-peek-resize--sw::after, +.msg-peek-panel.is-resizing .msg-peek-resize--se::after { border-color: var(--accent, #2563eb); } .msg-peek-body .json-pretty, @@ -5505,12 +5647,26 @@ body.design-workspace .af-mosaic { } } -/* Context menu */ +/* Context menu — fixed to viewport; follow cursor via placeMenu */ +menu.context-menu, .context-menu { - position: fixed; z-index: 1000; min-width: 120px; - margin: 0; padding: 4px; list-style: none; - background: var(--surface); border: 1px solid var(--border); - border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); + position: fixed; + z-index: 1200; + inset: auto; + min-width: 120px; + margin: 0; + padding: 4px; + list-style: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.12); + display: block; + box-sizing: border-box; +} +menu.context-menu[hidden], +.context-menu[hidden] { + display: none !important; } .context-menu-item { display: block; width: 100%; text-align: left; @@ -5554,9 +5710,54 @@ body.design-workspace .af-mosaic { z-index: 20; display: flex; flex-direction: row; + flex-wrap: wrap; align-items: flex-end; gap: 8px; } +.preference-review { + flex: 1 1 100%; + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 4px; +} +.preference-review[hidden] { + display: none !important; +} +.preference-review-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + background: var(--surface, #fff); +} +.preference-review-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + margin-bottom: 6px; +} +.preference-review-hint { + font-size: 12px; + color: var(--muted, #71717a); +} +.preference-review-content { + width: 100%; + box-sizing: border-box; + resize: vertical; + min-height: 64px; + font: inherit; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: inherit; +} +.preference-review-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} .composer:has(.composer-more-menu[open]) { z-index: 120; } @@ -6959,27 +7160,42 @@ body.design-workspace .af-mosaic { border-bottom: none; } .dictate-product--rail > .dictate-product-sum { + display: block; + margin: 0; + padding: 0; + width: 100%; + box-sizing: border-box; + border: none; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: context-menu; + user-select: none; + min-width: 0; + position: relative; + appearance: none; + -webkit-appearance: none; +} +.dictate-product-sum-inner { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px 8px 12px; - cursor: pointer; - list-style: none; + width: 100%; + box-sizing: border-box; min-width: 0; - position: relative; + pointer-events: none; /* 整行命中落在 button 上,避免子节点吞事件 */ transition: background 0.1s ease; } -.dictate-product--rail > .dictate-product-sum::-webkit-details-marker { - display: none; -} -.dictate-product--rail > .dictate-product-sum:hover { +.dictate-product--rail > .dictate-product-sum:hover .dictate-product-sum-inner { background: color-mix(in srgb, var(--accent-soft) 50%, transparent); } .dictate-product--rail > .dictate-product-sum:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } -.dictate-product--rail[open] > .dictate-product-sum { +.dictate-product--rail.is-open > .dictate-product-sum .dictate-product-sum-inner { background: color-mix(in srgb, var(--bg) 70%, var(--surface)); border-bottom: 1px solid var(--border); } @@ -7026,7 +7242,7 @@ body.design-workspace .af-mosaic { .dictate-product--rail > .dictate-product-sum:hover .dictate-product-chevron { opacity: 0.7; } -.dictate-product--rail[open] .dictate-product-chevron { +.dictate-product--rail.is-open .dictate-product-chevron { transform: rotate(180deg); opacity: 0.75; } @@ -7046,7 +7262,7 @@ body.design-workspace .af-mosaic { font-style: italic; opacity: 0.75; } -.dictate-product--rail[open] .dictate-product-preview { +.dictate-product--rail.is-open .dictate-product-preview { display: none; } .dictate-product--rail .dictate-product-body { @@ -7067,6 +7283,10 @@ body.design-workspace .af-mosaic { color: var(--text); background: var(--bg); border-top: none; + cursor: auto; +} +.dictate-product--rail .dictate-product-body[hidden] { + display: none !important; } .dictate-product--rail .dictate-product-body[data-pending="1"]:empty { display: none;