diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index a0256d1..943b6fa 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -312,10 +312,10 @@ const CONSOLE_ID = 'wt-console' const CONSOLE_ICON = '🖥️' /** 控制室默认布局:单一锁死大窗格(项目卡片网格)+ 右侧对话 */ -function buildConsoleSpec(): LayoutSpec { +function buildConsoleSpec(t: (key: string) => string): LayoutSpec { return { id: CONSOLE_ID, - title: '工作台', + title: t('console.name'), icon: CONSOLE_ICON, top: null, main: [{ @@ -348,6 +348,70 @@ function listWorkspaces(): { id: string; title: string; path: string }[] { } catch { return [] } } +/** 冷会话最近消息缓存与预热:宿主 history 只读通道(face.history 为运行期内建方法,非公开接口, + * 只读侦察确认可用;拉取是带宽成本不是 Token 成本,模型不参与)。 */ +const previewCache = new Map() +const previewFetching = new Set() +let previewSweepBusy = false +let previewTimer: number | null = null + +/** 从 history 事件流尾部提取最近一条成品消息文本(优先 text 块,回退任意文本) */ +async function coldPreviewOf(face: any): Promise { + if (!face || typeof face.history !== 'function') return '' + const r1 = await face.history({ maxMessages: 2 }) + const evs = r1?.result?.value?.events + if (!Array.isArray(evs)) return '' + const textOf = (blocks: any): string => { + let fallback = '' + if (!Array.isArray(blocks)) return '' + for (const b of blocks) { + const s = typeof b?.text === 'string' ? b.text.trim().replace(/\s+/g, ' ') : '' + if (!s) continue + if (b?.type === 'text') return s.slice(0, 140) + if (!fallback) fallback = s.slice(0, 140) + } + return fallback + } + for (let i = evs.length - 1; i >= 0; i--) { + const ev = evs[i]?.event + if (!ev) continue + const d = ev.data ?? {} + if (ev.type === 'user/message') { + const s = textOf(d.content ?? d.blocks) + if (s) return s + } else if (ev.type === 'assistant/message') { + const m = d.message ?? d + const s = textOf(m.content ?? m.blocks) + if (s) return s + } + } + return '' +} + +/** 预热所有已绑定会话的最近消息(逐个只读拉取;已在拉/已拉过未完成的跳过,失败静默回退内存路径) */ +async function sweepPreviews() { + if (previewSweepBusy) return + previewSweepBusy = true + try { + const sids = Array.from(new Set(Object.values(projectBindingsRef.current).filter((x): x is string => !!x))) + for (const sid of sids) { + if (previewFetching.has(sid)) continue + previewFetching.add(sid) + try { + const face = sessionBridge?.sessions?.binding?.(sid)?.session + const txt = await coldPreviewOf(face) + if (txt) { previewCache.set(sid, txt); notifyConsole() } + } catch {} finally { previewFetching.delete(sid) } + } + } finally { previewSweepBusy = false } +} + +/** 防抖调度:控制室开着且会话快照变化时刷新预览(合并 6s 内的连续变化) */ +function schedulePreviewSweep() { + if (previewTimer != null) return + previewTimer = window.setTimeout(() => { previewTimer = null; sweepPreviews() }, 6000) +} + /** 从会话快照节点里提取最近一条文本(纯读内存镜像,零 Token;无则 '') */ function lastTextOf(sid: string): string { try { @@ -815,6 +879,8 @@ function WorktableSection(props: any) { ) /** 控制室动作引用(每渲染同步,供 env 闭包读取) */ const actionsRef = useRef<{ openSplit: (spec: LayoutSpec) => void; openConsole: (explicitBound?: string | null) => void } | null>(null) + /** 提醒确认动作引用(每渲染同步;env 闭包用) */ + const ackRef = useRef<((projectId: string) => void) | null>(null) /** 视图状态最新快照(供 env 闭包读取控制室主题) */ const viewRef = useRef(view) viewRef.current = view @@ -881,16 +947,21 @@ function WorktableSection(props: any) { } catch {} return null } + const ackMap = loadNotifyAck() const make = (id: string, name: string, icon: string, self: boolean): ConsoleCardData => { const sid = pr.projects.bindings[id] + const status = statusOf(sid) + // 发光 = 完成/待决且本轮未被确认;点卡片确认后熄灭 + const glow = !!sid && ((status === 'done' && ackMap[sid] !== 'done') || (status === 'need' && ackMap[sid] !== 'need')) return { id, name, icon, - status: statusOf(sid), + status, runtimeMs: runtimeOf(sid), kids: sid ? kidsSetOf(sid).size : 0, - preview: sid ? lastTextOf(sid) : '', + preview: sid ? (previewCache.get(sid) ?? lastTextOf(sid)) : '', bound: !!sid, self, + glow, } } const cards: ConsoleCardData[] = [] @@ -965,6 +1036,11 @@ function WorktableSection(props: any) { getCards: () => getConsoleCards(), getTheme: () => viewRef.current.consoleTheme ?? 'system', setTheme: (th: 'dark' | 'light' | 'system') => persistView({ consoleTheme: th }), + onAck: (id) => { ackRef.current?.(id); setNotifyTick((t) => t + 1); notifyConsole() }, + refreshPreviews: () => { + if (previewTimer != null) { window.clearTimeout(previewTimer); previewTimer = null } + sweepPreviews() + }, onOpen: (id) => { if (id === CONSOLE_ID) return const pr = projectsRef.current.projects @@ -1226,7 +1302,7 @@ function buildCustomLayoutPrompt(req: string): string { /** 打开「工作台」控制室:已绑定 → 打开并切换绑定对话;explicitBound 供强制绑定流程传入刚绑定的会话 */ const openConsole = useCallback((explicitBound?: string | null) => { - const spec = projects.views[CONSOLE_ID] ?? buildConsoleSpec() + const spec = projects.views[CONSOLE_ID] ?? buildConsoleSpec((k) => t(k as WorktableKey)) engineIdsRef.current.add(CONSOLE_ID) splitStore.open(spec) if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) { @@ -1238,8 +1314,10 @@ function buildCustomLayoutPrompt(req: string): string { if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} } ackProjectNotify(CONSOLE_ID) try { notifyConsole() } catch {} + // 打开即预热所有绑定会话的最近消息(冷会话走 history 只读通道) + try { sweepPreviews() } catch {} } - }, [projects.views]) + }, [projects.views, t]) /** 控制室卡片点击:已绑定 → 打开控制室;未绑定 → 强制绑定弹窗 */ const clickConsoleCard = (anchor: HTMLElement) => { @@ -1366,9 +1444,13 @@ function buildCustomLayoutPrompt(req: string): string { copyToastTimerRef.current = window.setTimeout(() => setCopiedToast(null), 6000) } - // 会话快照变化 → 重渲染(驱动项目卡片的完成/待决提醒镜像 + 控制室面板) + // 会话快照变化 → 重渲染(驱动项目卡片的完成/待决提醒镜像 + 控制室面板 + 预览防抖刷新) useEffect(() => { - const l = () => { setNotifyTick((t) => t + 1); notifyConsole() } + const l = () => { + setNotifyTick((t) => t + 1) + notifyConsole() + if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) schedulePreviewSweep() + } sessionsSnapshotStore.listeners.add(l) return () => { sessionsSnapshotStore.listeners.delete(l) } }, []) @@ -1493,6 +1575,7 @@ function buildCustomLayoutPrompt(req: string): string { if (st === 'done') saveNotifyAck(sid, 'done') } } + ackRef.current = ackProjectNotify // ── 有效排序 ── // 手动:持久化 order(过滤已卸载 id)→ 新注册 id 与布局 id 追加尾部; diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 88a97d0..79f7ade 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -6,7 +6,7 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'title': '工作台', + 'title': '控制室', 'rail.title': '工作台(展开侧边栏查看)', 'handle.title': '按住拖动位置 · 双击复位', 'handle.aria': '拖动工作台位置', @@ -113,7 +113,7 @@ export const zh = { 'customLayout.copyFail': '复制失败,请手动全选输入框内容复制。', 'custom.autoBound': '已自动把这个项目绑定到这个对话(卡片上的 ●● 按钮可更换或解绑)。', 'custom.keptBinding': '该项目已有绑定的对话,按规则未自动更改。', - 'console.name': '工作台', + 'console.name': '控制室', 'console.title': '控制室', 'console.themeLabel': '面板主题', 'console.themeDark': '深色', @@ -167,7 +167,7 @@ export type WorktableKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - 'title': 'Worktable', + 'title': 'Console', 'rail.title': 'Worktable (expand sidebar to view)', 'handle.title': 'Drag to move · double-click to reset', 'handle.aria': 'Drag worktable position', @@ -274,7 +274,7 @@ export const en = { 'customLayout.copyFail': 'Copy failed — select the text above and copy it manually.', 'custom.autoBound': 'This project was auto-bound to this conversation (use the ●● button on its card to change or unbind).', 'custom.keptBinding': 'This project already has a bound conversation, so it was not auto-changed.', - 'console.name': 'Worktable', + 'console.name': 'Console', 'console.title': 'Console', 'console.themeLabel': 'Panel theme', 'console.themeDark': 'Dark', diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index ff9a1c9..60ddd8e 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -206,6 +206,8 @@ export type ConsoleCardData = { bound: boolean /** 是否为「工作台」自己(卡片点击无操作) */ self: boolean + /** 完成/待决且未被确认:卡片发对应色光(点击确认后熄灭) */ + glow: boolean } type SplitEnv = { @@ -226,6 +228,10 @@ type SplitEnv = { getCards: () => ConsoleCardData[] onOpen: (id: string) => void onJump: (id: string) => void + /** 点发光卡片:确认(熄灭光)再打开 */ + onAck: (id: string) => void + /** 冷会话消息预热(打开控制室时拉最近消息) */ + refreshPreviews: () => void getTheme: () => 'dark' | 'light' | 'system' setTheme: (th: 'dark' | 'light' | 'system') => void } @@ -975,6 +981,10 @@ function ConsolePane() { try { mq.addEventListener('change', f) } catch { try { (mq as any).addListener(f) } catch {} } return () => { try { mq.removeEventListener('change', f) } catch { try { (mq as any).removeListener(f) } catch {} } } }, []) + // 打开控制室 = 预热所有绑定会话的最近消息(冷会话走宿主 history 只读通道) + useEffect(() => { + splitEnv?.console?.refreshPreviews?.() + }, []) // 运行时长的分钟级刷新:每秒重渲染一次(仅控制室开着时存在) useEffect(() => { const iv = window.setInterval(() => setNow(Date.now()), 1000) @@ -1001,10 +1011,10 @@ function ConsolePane() { return h > 0 ? h + ':' + String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0') : m + ':' + String(ss).padStart(2, '0') } const statusLabel: Record = { idle: T('console.idle'), busy: T('console.busy'), need: T('console.need'), done: T('console.done') } - const themeOpts: { mode: 'dark' | 'light' | 'system'; key: string }[] = [ - { mode: 'dark', key: 'console.themeDark' }, - { mode: 'light', key: 'console.themeLight' }, - { mode: 'system', key: 'console.themeSystem' }, + const themeOpts: { mode: 'dark' | 'light' | 'system'; icon: string; key: string }[] = [ + { mode: 'dark', icon: '🌙', key: 'console.themeDark' }, + { mode: 'light', icon: '☀️', key: 'console.themeLight' }, + { mode: 'system', icon: '🖥️', key: 'console.themeSystem' }, ] return (
@@ -1016,8 +1026,10 @@ function ConsolePane() { key={o.mode} type="button" className={'dsh-wt_consoleThemeBtn' + (themeMode === o.mode ? ' dsh-wt_consoleThemeBtnOn' : '')} + title={T(o.key)} + aria-label={T(o.key)} onClick={() => setTheme(o.mode)} - >{T(o.key)} + >{o.icon} ))}
@@ -1027,18 +1039,27 @@ function ConsolePane() { key={c.id} role={c.self ? undefined : 'button'} tabIndex={c.self ? -1 : 0} - className={'dsh-wt_consoleCard' + (c.self ? ' dsh-wt_consoleCardSelf' : '')} + className={'dsh-wt_consoleCard' + (c.self ? ' dsh-wt_consoleCardSelf' : '') + + (c.status === 'busy' ? ' dsh-wt_consoleCard-busy' : '') + + (c.glow && c.status === 'done' ? ' dsh-wt_consoleCard-glowDone' : '') + + (c.glow && c.status === 'need' ? ' dsh-wt_consoleCard-glowNeed' : '')} title={c.name} - onClick={() => { if (!c.self && env) env.onOpen(c.id) }} + onClick={() => { + if (c.self || !env) return + if (c.glow) env.onAck?.(c.id) // 点发光卡片:先确认熄光,再进入 + env.onOpen(c.id) + }} >
{c.icon} {c.name}
+
+ {statusLabel[c.status]} + {c.runtimeMs != null && ⏱ {fmtDur(c.runtimeMs)}} +
- {statusLabel[c.status]} - {c.runtimeMs != null && ⏱ {fmtDur(c.runtimeMs)}} {T('console.kids')} {c.kids}
diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index b299ee1..d4fc965 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -387,30 +387,48 @@ export const css = xtermCss + '\n' + [ '.dsh-wt_consoleHead{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}', '.dsh-wt_consoleTitle{font-size:14px;font-weight:700;line-height:20px;color:var(--wt-text)}', '.dsh-wt_consoleTheme{display:inline-flex;align-items:center;padding:2px;border:1px solid var(--wt-border);border-radius:999px;background:var(--wt-card)}', - '.dsh-wt_consoleThemeBtn{flex:none;padding:2px 10px;border:none;border-radius:999px;background:transparent;color:var(--wt-text2);font:inherit;font-size:11px;line-height:18px;cursor:pointer}', - '.dsh-wt_consoleThemeBtnOn{background:var(--wt-chip);color:var(--wt-text);font-weight:600}', - '.dsh-wt_consoleGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;align-content:start}', - '.dsh-wt_consoleCard{position:relative;display:flex;flex-direction:column;gap:8px;min-height:96px;padding:12px 14px;background:var(--wt-card);border:1px solid var(--wt-border);border-radius:12px;cursor:pointer;transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}', - '.dsh-wt_consoleCard:hover{transform:translateY(-1px);border-color:var(--wt-borderHover);box-shadow:var(--wt-shadow)}', - '.dsh-wt_consoleCardSelf{cursor:default;opacity:.96}', - '.dsh-wt_consoleCardHead{display:flex;align-items:center;gap:8px;min-width:0}', - '.dsh-wt_consoleIcon{flex:none;font-size:20px;line-height:1}', - '.dsh-wt_consoleName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;line-height:20px;color:var(--wt-text)}', - '.dsh-wt_consoleDot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--wt-text3)}', - '.dsh-wt_consoleDot-busy{background:var(--dsw-alias-state-accent-primary,#4f8ef7);box-shadow:0 0 0 3px rgba(79,142,247,.15)}', - '.dsh-wt_consoleDot-need{background:var(--dsw-alias-state-warning,#d29922);box-shadow:0 0 0 3px rgba(210,153,34,.15)}', - '.dsh-wt_consoleDot-done{background:var(--dsw-alias-state-success,#3fb950);box-shadow:0 0 0 3px rgba(63,185,80,.15)}', + '.dsh-wt_consoleThemeBtn{flex:none;padding:3px 9px;border:none;border-radius:999px;background:transparent;color:var(--wt-text3);font:inherit;font-size:12px;line-height:18px;cursor:pointer;opacity:.85}', + '.dsh-wt_consoleThemeBtn:hover{opacity:1;background:var(--wt-chip)}', + '.dsh-wt_consoleThemeBtnOn{background:var(--wt-chip);color:var(--wt-text);opacity:1}', + '.dsh-wt_consoleGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;align-content:start}', + '.dsh-wt_consoleCard{position:relative;display:flex;flex-direction:column;gap:10px;aspect-ratio:1/1;min-height:150px;padding:16px;background:var(--wt-card);border:1px solid var(--wt-border);border-radius:14px;cursor:pointer;transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease;overflow:hidden}', + '.dsh-wt_consoleCard:hover{transform:translateY(-2px);border-color:var(--wt-borderHover);box-shadow:var(--wt-shadow)}', + '.dsh-wt_consoleCardSelf{cursor:default;opacity:.97}', + '.dsh-wt_consoleCardHead{display:flex;align-items:center;gap:9px;min-width:0}', + '.dsh-wt_consoleIcon{flex:none;font-size:24px;line-height:1}', + '.dsh-wt_consoleName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14.5px;font-weight:700;line-height:22px;color:var(--wt-text)}', + '.dsh-wt_consoleDot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--wt-text3)}', + '.dsh-wt_consoleDot-busy{background:var(--dsw-alias-state-accent-primary,#4f8ef7);box-shadow:0 0 0 4px rgba(79,142,247,.16)}', + '.dsh-wt_consoleDot-need{background:var(--dsw-alias-state-warning,#d29922);box-shadow:0 0 0 4px rgba(210,153,34,.16)}', + '.dsh-wt_consoleDot-done{background:var(--dsw-alias-state-success,#3fb950);box-shadow:0 0 0 4px rgba(63,185,80,.16)}', + '.dsh-wt_consoleStatusRow{display:flex;align-items:baseline;gap:8px;min-width:0}', + '.dsh-wt_consoleStatus{flex:none;font-size:17px;font-weight:700;line-height:24px;color:var(--wt-text2)}', + '.dsh-wt_consoleStatus-busy{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', + '.dsh-wt_consoleStatus-need{color:var(--dsw-alias-state-warning,#d29922)}', + '.dsh-wt_consoleStatus-done{color:var(--dsw-alias-state-success,#3fb950)}', + '.dsh-wt_consoleRuntime{flex:none;font-size:13px;font-weight:600;line-height:20px;color:var(--wt-text2);font-variant-numeric:tabular-nums}', '.dsh-wt_consoleBadges{display:flex;align-items:center;gap:6px;flex-wrap:wrap}', - '.dsh-wt_consoleBadge{flex:none;padding:1px 7px;border-radius:999px;font-size:10.5px;line-height:16px;color:var(--wt-text2);background:var(--wt-chip)}', - '.dsh-wt_consoleBadge-busy{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', - '.dsh-wt_consoleBadge-need{color:var(--dsw-alias-state-warning,#d29922)}', - '.dsh-wt_consoleBadge-done{color:var(--dsw-alias-state-success,#3fb950)}', - '.dsh-wt_consolePreview{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:16px;color:var(--wt-text2)}', - '.dsh-wt_consolePreviewNone{color:var(--wt-text3);font-style:italic}', - '.dsh-wt_consoleJump{position:absolute;top:10px;right:10px;width:22px;height:22px;padding:0;border:none;border-radius:6px;background:transparent;color:var(--wt-text3);font-size:12px;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s ease}', + '.dsh-wt_consoleBadge{flex:none;padding:2px 9px;border-radius:999px;font-size:12px;font-weight:600;line-height:18px;color:var(--wt-text2);background:var(--wt-chip)}', + '.dsh-wt_consolePreview{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:12.5px;line-height:18px;color:var(--wt-text2);word-break:break-word}', + '.dsh-wt_consolePreviewNone{color:var(--wt-text3);font-style:italic;-webkit-line-clamp:1}', + // 三色光效:完成=绿光、待决=黄光(背景泛光,点击确认后熄灭);工作=蓝色描边光晕顺时针旋转 + '.dsh-wt_consoleCard-glowDone{border-color:rgba(63,185,80,.45);box-shadow:0 0 22px rgba(63,185,80,.22)}', + '.dsh-wt_consoleCard-glowDone::after{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(130% 130% at 50% 0%,rgba(63,185,80,.16),transparent 62%);pointer-events:none}', + '.dsh-wt_consoleCard-glowNeed{border-color:rgba(210,153,34,.5);box-shadow:0 0 22px rgba(210,153,34,.22)}', + '.dsh-wt_consoleCard-glowNeed::after{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(130% 130% at 50% 0%,rgba(210,153,34,.17),transparent 62%);pointer-events:none}', + '@property --consoleAngle{syntax:"";initial-value:0deg;inherits:false}', + '.dsh-wt_consoleCard-busy::before{content:"";position:absolute;inset:-2px;border-radius:16px;padding:2px;background:conic-gradient(from var(--consoleAngle),transparent 0deg,rgba(79,142,247,0) 30deg,rgba(79,142,247,.95) 80deg,rgba(79,142,247,0) 130deg,transparent 200deg);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);mask-composite:exclude;animation:consoleAngleSpin 1.8s linear infinite;pointer-events:none}', + '@keyframes consoleAngleSpin{to{--consoleAngle:360deg}}', + '.dsh-wt_consoleJump{position:absolute;top:12px;right:12px;width:24px;height:24px;padding:0;border:none;border-radius:7px;background:transparent;color:var(--wt-text3);font-size:13px;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s ease}', '.dsh-wt_consoleCard:hover .dsh-wt_consoleJump{opacity:1}', '.dsh-wt_consoleJump:hover{color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--wt-chip)}', '.dsh-wt_consoleEmpty{padding:40px 0;text-align:center;font-size:12px;color:var(--wt-text3)}', + // 控制室入口卡:统筹主程序的差异化(渐变底 + 青色描边 + 微光,克制不喧宾夺主) + '.dsh-wt_projects .dsh-wt_consoleEntry{background:linear-gradient(135deg,rgba(79,142,247,.14),rgba(139,92,246,.10));border-color:rgba(79,142,247,.4);box-shadow:0 0 12px rgba(79,142,247,.14)}', + '.dsh-wt_projects .dsh-wt_consoleEntry:hover{background:linear-gradient(135deg,rgba(79,142,247,.2),rgba(139,92,246,.14));border-color:rgba(79,142,247,.6);box-shadow:0 0 16px rgba(79,142,247,.22)}', + '.dsh-wt_projects .dsh-wt_consoleEntry[data-on=true]{border-color:rgba(79,142,247,.7);box-shadow:0 0 16px rgba(79,142,247,.28)}', + '.dsh-wt_projects .dsh-wt_consoleEntry .dsh-wt_layoutIcon{font-size:15px}', + '.dsh-wt_projects .dsh-wt_consoleEntry .dsh-wt_layoutName{font-weight:600;color:var(--dsw-alias-label-primary,#e6e8eb)}', // 控制室强制绑定弹窗:左加入现有 / 右新建对话 '.dsh-wt_consoleBindCols{display:flex;gap:12px}', '.dsh-wt_consoleBindCol{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}', diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index e74ade7..2c2b887 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -1173,3 +1173,32 @@ 新建对话路径(分组默认无)✓;主题深/白/跟随切换 + data-wt-theme 落定 + 持久化 ✓; functional-diag 全 20 STEP(布局卡选择器收窄排除控制室卡)ERROR_COUNT: 0。 - 备注:纯客户端改动,F5 即生效;AGENTS.md 已补控制室规则。 + +## 补记(控制室 UI 打磨:光效卡片 + 正方形布局 + 主题图标 + 冷会话预览预热 + 统一命名) + +- 用户反馈:① 卡片太长信息太少 → 改正方形(1:1 圆角卡),信息放大(状态 17px 大字、 + 子代理 12px 徽章、预览 12.5px 两行截断);② 状态可视化:工作 = 蓝色描边光晕顺时针 + 绕卡旋转(conic-gradient + @property --consoleAngle 动画)、完成 = 背景绿光、待决 = + 背景黄光(radial 泛光 + 外发光),点发光卡片先确认熄光再进入;③ 命名统一「控制室」 + (侧栏区块标题 + 默认项目卡 + 面板标题);④ 入口卡差异化(渐变底 + 青蓝描边 + 微光, + 统筹主程序感);⑤ 主题三选一改图标按钮(🌙/☀️/🖥️,title/aria 保留文字);⑥ 预览 + 方案 A:冷会话最近消息预热。 +- 冷读调研(probe-preview-cold/cold2/dbg.cjs):binding() 实例化冷会话 nodes 为空; + loadOlder 无效;**face.history({maxMessages:N})(运行期内建方法)返回 {ok,value: + {events,hasMore}} 原始事件流**——尾部扫 user/message(data.content)与 + assistant/message(data.message.content)可提取最近一条 text 块文本(实测拿 + 到真实文本)✓。纯只读拉取,无副作用不激活 agent。 +- 实现:index.tsx 新增 previewCache/previewFetching/coldPreviewOf/sweepPreviews/ + schedulePreviewSweep;打开控制室即 sweep(openConsole + ConsolePane mount 双触发), + 控制室开着时会话快照变化防抖 6s 再扫;getConsoleCards 预览 = cache ?? lastTextOf。 + 卡片 glow 字段 = done/need 且未 ack;env.console 增 onAck(ackProjectNotify + + setNotifyTick + notifyConsole)与 refreshPreviews;ackRef 每渲染同步。ConsolePane: + 状态行大字号、正方形卡、三光效类接线、发光卡点击先 ack。样式:--wt-* 主题作用域内 + 新增状态色/徽章/两行截断/radial 泛光/旋转描边(@property + mask 挖空描边环)/入口卡 + 渐变描边/主题图标按钮。 +- 验证(probe-batch16 扩展):cardAspect=1、statusFont 17px、busy 旋转动画 + consoleAngleSpin + conic 描边 ✓、绿/黄泛光与外发光 rgba 值 ✓、入口卡渐变+青蓝描边 ✓、 + 主题按钮 🌙☀️🖥️ ✓、冷会话预热 selfPreviewFilled(真实文本「新包已生效…」)✓; + 三阶段全流程与主题切换全 ✓;functional-diag 全 20 STEP ERROR_COUNT: 0。 +- 备注:纯客户端改动,F5 即生效;face.history 为非公开接口(只读、失败静默回退内存路径), + AGENTS.md 已注明。 diff --git a/04_test/probe-batch16.cjs b/04_test/probe-batch16.cjs index d735b0d..856efd4 100644 --- a/04_test/probe-batch16.cjs +++ b/04_test/probe-batch16.cjs @@ -59,7 +59,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); await sleep(400); var rows = document.querySelectorAll('.dsh-wt_settings .dsh-wt_manageRow'); out.manageRows = rows.length; - out.consoleInManage = [].slice.call(rows).some(function(r){ return (r.textContent||'').indexOf('工作台') >= 0; }); + out.consoleInManage = [].slice.call(rows).some(function(r){ return (r.textContent||'').indexOf('控制室') >= 0; }); var done = document.querySelector('.dsh-wt_manageDone'); if (done) { done.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } await sleep(300); @@ -75,7 +75,39 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); var labels = bpop.querySelectorAll('.dsh-wt_consoleBindLabel'); out.leftLabel = labels[0] ? labels[0].textContent : null; out.rightLabel = labels[1] ? labels[1].textContent : null; - var item = bpop.querySelector('.dsh-wt_consoleBindList .dsh-wt_selectItem'); + // 选一个「确实有历史文本」的会话(逐个冷拉 2 条消息验证;保证预热断言可测) + var svc = window.__dshSessions; + var snap = svc && svc.list ? svc.list.getSnapshot() : null; + var byId = snap && snap.byId ? snap.byId : {}; + var ids = snap && snap.ids ? snap.ids : []; + var chosen = null; + for (var k = 0; k < Math.min(ids.length, 12) && !chosen; k++) { + var sid = ids[k]; + try { + var face = svc.binding(sid).session; + var r1 = await face.history({ maxMessages: 2 }); + var evs = (r1 && r1.result && r1.result.value && r1.result.value.events) || []; + for (var i = evs.length - 1; i >= 0 && !chosen; i--) { + var ev = evs[i] && evs[i].event; + var d = ev && ev.data ? ev.data : {}; + var blocks = null; + if (ev && ev.type === 'user/message') blocks = d.content || d.blocks; + else if (ev && ev.type === 'assistant/message') { var mm = d.message || d; blocks = mm.content || mm.blocks; } + if (Array.isArray(blocks)) { + for (var b = 0; b < blocks.length; b++) { + if (blocks[b] && typeof blocks[b].text === 'string' && blocks[b].text.trim()) { + var titleOf = byId[sid] && (byId[sid].title || byId[sid].displayTitle); + chosen = { title: titleOf, sid: sid }; + break; + } + } + } + } + } catch (e) {} + } + out.chosenTitle = chosen ? chosen.title : null; + var item = chosen ? [].slice.call(bpop.querySelectorAll('.dsh-wt_consoleBindList .dsh-wt_selectItem')).find(function(it){ return String(it.textContent) === String(chosen.title); }) : null; + if (!item) item = bpop.querySelector('.dsh-wt_consoleBindList .dsh-wt_selectItem'); if (item) { item.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } await sleep(900); } @@ -99,6 +131,40 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); out.firstCardHasPreview = !!(c0 && c0.querySelector('.dsh-wt_consolePreview')); out.anyLayoutCard = [].slice.call(cards).some(function(c){ return (c.textContent||'').indexOf('绑定测试') >= 0; }); out.layoutCardHasJump = [].slice.call(cards).some(function(c){ return (c.textContent||'').indexOf('绑定测试') >= 0 && !!c.querySelector('.dsh-wt_consoleJump'); }); + // 新版 UI:正方形比例、大字号状态、光效 CSS 接线(注入类验证,不依赖真实会话状态) + var cardEl = c0; + if (cardEl) { + var cr = cardEl.getBoundingClientRect(); + out.cardAspect = Math.round((cr.width / Math.max(1, cr.height)) * 100) / 100; + var statusEl = cardEl.querySelector('.dsh-wt_consoleStatus'); + out.statusFont = statusEl ? getComputedStyle(statusEl).fontSize : null; + cardEl.classList.add('dsh-wt_consoleCard-busy'); + var bs = getComputedStyle(cardEl, '::before'); + out.busyBgImage = bs.backgroundImage ? bs.backgroundImage.slice(0, 60) : null; + out.busyAnim = bs.animationName; + cardEl.classList.remove('dsh-wt_consoleCard-busy'); + cardEl.classList.add('dsh-wt_consoleCard-glowDone'); + await sleep(300); + var gs = getComputedStyle(cardEl, '::after'); + out.glowDoneBg = gs.backgroundImage ? gs.backgroundImage.slice(0, 60) : null; + out.glowDoneShadow = getComputedStyle(cardEl).boxShadow.slice(0, 60); + cardEl.classList.remove('dsh-wt_consoleCard-glowDone'); + cardEl.classList.add('dsh-wt_consoleCard-glowNeed'); + await sleep(300); + out.glowNeedShadow = getComputedStyle(cardEl).boxShadow.slice(0, 60); + cardEl.classList.remove('dsh-wt_consoleCard-glowNeed'); + } + var entry = vis('.dsh-wt_projects .dsh-wt_consoleEntry'); + if (entry) { + var ecs = getComputedStyle(entry); + out.entryBgGradient = ecs.backgroundImage.indexOf('linear-gradient') >= 0; + out.entryBorder = ecs.borderColor; + } + // 冷会话预热:等 sweep 拉取后,自己卡片预览应有真实文本 + await sleep(4200); + var selfPrev2 = c0 && c0.querySelector('.dsh-wt_consolePreview'); + out.selfPreviewAfterSweep = selfPrev2 ? selfPrev2.textContent.slice(0, 60) : null; + out.selfPreviewFilled = !!(selfPrev2 && !selfPrev2.classList.contains('dsh-wt_consolePreviewNone')); var lcard = [].slice.call(cards).find(function(c){ return (c.textContent||'').indexOf('绑定测试') >= 0; }); if (lcard) { lcard.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } await sleep(800); diff --git a/04_test/probe-preview-cold.cjs b/04_test/probe-preview-cold.cjs new file mode 100644 index 0000000..2f84772 --- /dev/null +++ b/04_test/probe-preview-cold.cjs @@ -0,0 +1,94 @@ +// preview-cold.cjs — 冷会话文本可读性实测:绑定→binding() 实例化后 nodes 是否有文本;无则试 loadOlder +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9375; +const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1440,900', '--force-device-scale-factor=1', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-pvcold', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +(async () => { + let list = null; + for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); }); + ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.enable'); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + let ready = null; + for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshWorktable"); if (ready === true) break; } + await sleep(1500); + const out = await evaluate(`(async function(){ + var out = {}; + var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); }; + try { + // 宿主会话列表快照:window.__dshWorktable 不暴露;改从 worktable 页面内部可见线索拿不到—— + // 改用宿主侧全局:dsh 的 session list store 常挂在 window 下的模块图里。 + // 兜底:直接探测 binding 是否存在(从控制室绑定一个已知真实会话 id 太难), + // 因此这里改为:读取 window 上可能的会话入口。 + out.probeKeys = Object.keys(window).filter(function(k){ return /session|Session/.test(k); }).slice(0, 20); + // 尝试从 dsh 全局取 session list(宿主暴露与否取决于实现) + var anySid = null; + try { + var st = window.__dshWorktable; + // 用控制室 env 的 getCards 前需要绑定;先拿会话列表:宿主 ctx.sessions 不在 window。 + // 通过 fetchSessionGroups 的网络接口 /api/worktable/workspaces 拿会话 id 不可行(无会话列表)。 + // 改用 localStorage 已存绑定(本机真实用户的浏览器里才有;这个 headless 是空档)→ 跳过。 + } catch (e) { out.err1 = String(e); } + // 最终方案:给控制室项目绑定弹窗左列里有一个「当前」会话——用 DOM 拿它的 data 不可行, + // 因此改为在页面内调用 __dshWorktable 的 bind 流程拿一个真实会话 id(走绑定弹窗 UI)。 + var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); }; + var st2 = window.__dshWorktable.splitStore; + var card = vis('.dsh-wt_projects .dsh-wt_consoleEntry'); + card.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); + await sleep(600); + var bpop = vis('.dsh-wt_consoleBindPop'); + var items = bpop ? bpop.querySelectorAll('.dsh-wt_consoleBindList .dsh-wt_selectItem') : []; + out.itemCount = items.length; + var title = items[0] ? items[0].textContent : null; + // 点击第一个会话 → 绑定并打开控制室 + if (items[0]) { items[0].dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); } + await sleep(1400); + var j = JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')); + var sid = j.bindings['wt-console']; + out.boundSid = sid; + out.boundTitle = title; + // 读会话面快照(binding() 实例化后的冷读结果) + out.faceInfo = null; + try { + var faces = window.__dshWorktable; + // 尝试 window 上的会话桥(插件内部 sessionBridge 不导出) + out.bridgeOnWindow = typeof window.__dshSessionBridge; + } catch (e) { out.err2 = String(e); } + // 看控制室自己卡片的预览是否非空(lastTextOf 走 binding() 冷读路径) + var grid = vis('.dsh-wt_consoleGrid'); + var selfCard = grid && [].slice.call(grid.querySelectorAll('.dsh-wt_consoleCard')).find(function(c){ return c.classList.contains('dsh-wt_consoleCardSelf'); }); + var prev = selfCard && selfCard.querySelector('.dsh-wt_consolePreview'); + out.selfPreviewText = prev ? prev.textContent : null; + out.selfPreviewIsEmpty = prev ? (prev.classList.contains('dsh-wt_consolePreviewNone')) : null; + await sleep(2500); + out.selfPreviewAfterWait = prev ? prev.textContent : null; + st2.close(); await sleep(300); + } catch (e) { out.err = String(e && e.stack || e); } + return out; + })()`); + console.log('RESULT ' + JSON.stringify(out)); + try { ws.close(); } catch {} + proc.kill(); +})().catch((e) => { console.error('PROBE FAIL', e); try { proc.kill(); } catch {} }); diff --git a/04_test/probe-preview-cold2.cjs b/04_test/probe-preview-cold2.cjs new file mode 100644 index 0000000..cf41129 --- /dev/null +++ b/04_test/probe-preview-cold2.cjs @@ -0,0 +1,146 @@ +// preview-cold2.cjs — 冷会话 binding() 面对象方法侦察 +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9376; +const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1440,900', '--force-device-scale-factor=1', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-pvcold2', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +(async () => { + let list = null; + for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); }); + ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.enable'); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + let ready = null; + for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshSessions"); if (ready === true) break; } + await sleep(1200); + const out = await evaluate(`(async function(){ + var out = {}; + var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); }; + try { + var svc = window.__dshSessions; + out.svcKeys = Object.getOwnPropertyNames(svc ? Object.getPrototypeOf(svc) : {}).slice(0, 40); + var listSnap = svc.list && svc.list.getSnapshot ? svc.list.getSnapshot() : null; + out.ids = listSnap && listSnap.ids ? listSnap.ids.slice(0, 6) : null; + out.byIdKeys = listSnap && listSnap.byId ? Object.keys(listSnap.byId).slice(0, 3) : null; + var sid = listSnap && listSnap.ids ? listSnap.ids[0] : null; + out.sid = sid; + if (sid) { + var b = svc.binding(sid); + out.hasBinding = !!b; + var face = b && b.session; + out.faceKeys = face ? Object.getOwnPropertyNames(Object.getPrototypeOf(face)).slice(0, 40) : null; + var snap = face && face.getSnapshot ? face.getSnapshot() : null; + out.nodesLen = snap && snap.nodes ? snap.nodes.length : null; + out.hasLoadOlder = !!(face && typeof face.loadOlder === 'function'); + // 试 loadOlder + if (face && typeof face.loadOlder === 'function') { + try { + await face.loadOlder(); + await sleep(1500); + var snap2 = face.getSnapshot(); + out.nodesLenAfterLoadOlder = snap2 && snap2.nodes ? snap2.nodes.length : null; + out.hasMore = snap2 ? snap2.hasMore : null; + } catch (e) { out.loadOlderErr = String(e); } + } + // 试原型上的 history 方法(非公开接口,只读侦察) + out.historyProbe = null; + if (face && typeof face.history === 'function') { + try { + var r1 = await face.history({ maxMessages: 100 }); + var res = r1 && r1.result; + out.historyProbe = { type: typeof r1, isArray: Array.isArray(r1), len: r1 && r1.length != null ? r1.length : null, keys: r1 && typeof r1 === 'object' && !Array.isArray(r1) ? Object.keys(r1).slice(0, 10) : null }; + out.historyResult = null; + try { + if (res && res.ok !== false) { + var val = res.value != null ? res.value : res; + var items = val.items != null ? val.items : (val.entries != null ? val.entries : (val.messages != null ? val.messages : (Array.isArray(val) ? val : null))); + out.historyResult = { + resKeys: Object.keys(res).slice(0, 12), + valType: val === null ? 'null' : typeof val, + valKeys: val && typeof val === 'object' && !Array.isArray(val) ? Object.keys(val).slice(0, 12) : null, + valSample: JSON.stringify(val).slice(0, 600), + itemsType: items ? (Array.isArray(items) ? 'array' : typeof items) : null, + itemsLen: items && items.length != null ? items.length : null, + first: items && items[0] ? JSON.stringify(items[0]).slice(0, 400) : null, + last: items && items.length ? JSON.stringify(items[items.length - 1]).slice(0, 400) : null, + }; + // 事件类型分布 + 尾部寻找成品消息文本 + try { + var evs = val && Array.isArray(val.events) ? val.events : []; + var types = {}; + evs.forEach(function(ev){ var tt = ev && ev.event && ev.event.type; types[tt] = (types[tt]||0)+1; }); + out.eventTypes = types; + out.eventsLen = evs.length; + var tailTexts = []; + for (var i = evs.length - 1; i >= 0 && tailTexts.length < 4; i--) { + var ev = evs[i] && evs[i].event; + if (!ev) continue; + var d = ev.data || {}; + if (ev.type === 'user/message' || ev.type === 'user') { + var blocks = d.content || d.blocks || []; + var s = ''; + if (Array.isArray(blocks)) blocks.forEach(function(b){ if (b && typeof b.text === 'string') s += b.text; }); + if (s) tailTexts.push({ type: ev.type, text: s.slice(0, 80) }); + } else if (ev.type === 'assistant/message' || ev.type === 'assistant') { + var b2 = d.message || d; + var bl2 = b2.content || b2.blocks || []; + var s2 = ''; + if (Array.isArray(bl2)) bl2.forEach(function(b){ if (b && typeof b.text === 'string') s2 += b.text; }); + if (s2) tailTexts.push({ type: ev.type, text: s2.slice(0, 80) }); + } + } + out.tailTexts = tailTexts; + // 成品消息事件样本 + var samples = []; + for (var j = evs.length - 1; j >= 0 && samples.length < 2; j--) { + var evj = evs[j] && evs[j].event; + if (evj && (evj.type === 'user/message' || evj.type === 'assistant/message')) { + samples.push(JSON.stringify(evj).slice(0, 500)); + } + } + out.msgSamples = samples; + } catch (e4) { out.tailErr = String(e4); } + } else { + out.historyResult = { resKeys: Object.keys(res || {}).slice(0, 8), err: res && (res.error || res.code || JSON.stringify(res).slice(0, 120)) }; + } + } catch (e3) { out.historyResultErr = String(e3); } + } catch (e2) { out.historyErr = String(e2); } + } + // 找 history 类方法(原型链上) + var protoNames = []; + var p = Object.getPrototypeOf(face); + var depth = 0; + while (p && depth < 4) { + protoNames = protoNames.concat(Object.getOwnPropertyNames(p)); + p = Object.getPrototypeOf(p); depth++; + } + out.historyLike = protoNames.filter(function(n){ return /histor|load|fetch|more|page/i.test(n); }); + } + } catch (e) { out.err = String(e && e.stack || e); } + return out; + })()`); + console.log('RESULT ' + JSON.stringify(out)); + try { ws.close(); } catch {} + proc.kill(); +})().catch((e) => { console.error('PROBE FAIL', e); try { proc.kill(); } catch {} }); diff --git a/04_test/probe-preview-dbg.cjs b/04_test/probe-preview-dbg.cjs new file mode 100644 index 0000000..2aeedf2 --- /dev/null +++ b/04_test/probe-preview-dbg.cjs @@ -0,0 +1,77 @@ +// preview-dbg.cjs — 对绑定会话直接拉 history,看消息事件形态 +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9377; +const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1440,900', '--force-device-scale-factor=1', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-pvdbg', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +(async () => { + let list = null; + for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); }); + ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.enable'); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + let ready = null; + for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshSessions"); if (ready === true) break; } + await sleep(1200); + const out = await evaluate(`(async function(){ + var out = {}; + try { + var svc = window.__dshSessions; + var snap = svc.list.getSnapshot(); + // 对所有会话逐个拉 2 条消息,统计哪些能提取到文本 + var sids = (snap.ids || []).slice(0, 8); + var results = []; + for (var k = 0; k < sids.length; k++) { + var sid = sids[k]; + try { + var face = svc.binding(sid).session; + var r1 = await face.history({ maxMessages: 2 }); + var evs = (r1 && r1.result && r1.result.value && r1.result.value.events) || []; + var userShape = null, asstShape = null, anyText = ''; + for (var i = evs.length - 1; i >= 0; i--) { + var ev = evs[i] && evs[i].event; + if (!ev) continue; + var d = ev.data || {}; + if (ev.type === 'user/message' && !userShape) userShape = JSON.stringify(d).slice(0, 260); + if (ev.type === 'assistant/message' && !asstShape) asstShape = JSON.stringify(d).slice(0, 260); + var blocks = null; + if (ev.type === 'user/message') blocks = d.content || d.blocks; + else if (ev.type === 'assistant/message') { var m = d.message || d; blocks = m.content || m.blocks; } + if (Array.isArray(blocks) && !anyText) { + for (var b = 0; b < blocks.length; b++) { + if (blocks[b] && typeof blocks[b].text === 'string' && blocks[b].text.trim()) { anyText = blocks[b].text.trim().slice(0, 50); break; } + } + } + } + results.push({ sid: sid.slice(-8), evsLen: evs.length, userShape: userShape, asstShape: asstShape, anyText: anyText }); + } catch (e) { results.push({ sid: sid.slice(-8), err: String(e).slice(0, 90) }); } + } + out.results = results; + } catch (e) { out.err = String(e && e.stack || e); } + return out; + })()`); + console.log('RESULT ' + JSON.stringify(out)); + try { ws.close(); } catch {} + proc.kill(); +})().catch((e) => { console.error('PROBE FAIL', e); try { proc.kill(); } catch {} }); diff --git a/AGENTS.md b/AGENTS.md index b4ea4f0..1474f92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,10 +90,21 @@ node --check lib/index.js consoleListeners(项目/会话快照变化推送)+ 面板每秒 tick。 - 卡片动作:点卡片 = 打开该项目(openSplit 或入驻项目切绑定对话);💬 = 跳绑定对话 (markPluginSessionOpen 豁免联动,控制室保持打开);工作台自己的卡片点击无操作。 - - 主题:面板三选一开关(深色/白色/跟随系统,存 view.v1 consoleTheme);system 读宿主 - html 的 color-scheme(DSH 深色/白色/跟随系统设置都会反映到它)+ prefers-color-scheme - 兜底;落成 .dsh-wt_console[data-wt-theme=dark|light] 作用域变量 --wt-*(宿主不发布 - --dsw-alias-*,工作台全站一直靠回退色渲染——控制室自带主题作用域,不受其影响)。 + - 主题:面板三选一开关(图标按钮 🌙/☀️/🖥️,title/aria 保留文字;存 view.v1 consoleTheme); + system 读宿主 html 的 color-scheme(DSH 深色/白色/跟随系统设置都会反映到它)+ + prefers-color-scheme 兜底;落成 .dsh-wt_console[data-wt-theme=dark|light] 作用域变量 + --wt-*(宿主不发布 --dsw-alias-*,工作台全站一直靠回退色渲染——控制室自带主题作用域, + 不受其影响)。 + - 状态光效:工作=蓝色描边光晕顺时针绕卡旋转(.dsh-wt_consoleCard-busy ::before + conic-gradient + @property --consoleAngle + consoleAngleSpin);完成=绿光、待决=黄光 + (-glowDone/-glowNeed:radial 泛光 + 外发光;glow 字段 = done/need 且本轮未 ack, + 点卡片先 onAck 熄光再进入,与提醒 ack 生命周期一致)。 + - 命名统一「控制室」:侧栏区块标题、默认项目卡名、面板标题均为 console.name/title 系列 + locale(不再是「工作台」)。 + - 冷会话消息预览(方案 A):binding() 对冷会话不载入文本;预热走 face.history({maxMessages:2}) + (运行期内建方法、非公开接口,只读无副作用)尾部扫 user/message 与 assistant/message 的 + text 块 → previewCache;sweepPreviews 在打开控制室时 + 控制室开着且会话快照变化防抖 6s + 触发;失败静默回退内存路径 lastTextOf。拉取是带宽成本不是 Token 成本。 ## 安装 / 重启