From 2e2780225914ee924bd17015b487fc777af2933c Mon Sep 17 00:00:00 2001 From: micklife Date: Thu, 20 Aug 2026 14:48:08 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20dsh=20=E6=8F=92=E4=BB=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20meX=20=E8=AE=B0=E5=BF=86=E9=9D=A2=E6=9D=BF=EF=BC=88?= =?UTF-8?q?=E5=8F=B3=E4=B8=8B=E8=A7=92=20+=20Add=20to=20MeX=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=8A=BD=E5=8F=96=E8=AE=B0=E5=BF=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Client half(client.js):注册 shell.overlay 浮动层,默认停靠右下角, 显示本轮对话状态,点击 Add to MeX 触发抽取; - Host half(panel.js):监听 agent/turn-stopping 缓存每轮对话, 注册 /mex/panel-state 与 /mex/extract 接口,通过 spawn 子代理执行抽取, 子代理用 mex_* 工具写入记忆; - 抽取 prompt 参考 llm/prompts.py 规范精简:画像/画像外、置信度五档、 质量标准、时间规范,并引导先 profile/list 查重; - package.json 声明 dsh.client 入口;integrate.py 文件清单扩展为六件套; - 测试覆盖 dsh bundle 生成与文件内容。 --- src/mex/integration/dsh/README.md | 25 ++- src/mex/integration/dsh/client.js | 195 ++++++++++++++++++++++++ src/mex/integration/dsh/index.js | 5 + src/mex/integration/dsh/package.json | 16 +- src/mex/integration/dsh/panel.js | 217 +++++++++++++++++++++++++++ src/mex/services/integrate.py | 8 +- tests/services/test_integrate.py | 19 ++- 7 files changed, 472 insertions(+), 13 deletions(-) create mode 100644 src/mex/integration/dsh/client.js create mode 100644 src/mex/integration/dsh/panel.js diff --git a/src/mex/integration/dsh/README.md b/src/mex/integration/dsh/README.md index ea0f0e1..a0df028 100644 --- a/src/mex/integration/dsh/README.md +++ b/src/mex/integration/dsh/README.md @@ -55,6 +55,25 @@ dsh web 新开一个会话,让 agent"读一下我的画像",应能返回 `mex profile` 的内容(对应 `mex_profile` 工具)。 +## meX 记忆面板(Client half) + +插件自带一个浏览器 UI 面板,默认停靠在页面**右下角**: + +- 每轮对话(dsh 的 Turn)结束后,面板提示"本轮对话已结束,可加入记忆"; +- 点击 **Add to MeX** 按钮:插件在后台触发一轮全新的 agent 对话,自动把 + **抽取记忆 prompt + 本轮对话文本**交给该 agent,由 agent 调用 `mex_add` / + `mex_update` 等工具把值得记住的信息写入 meX; +- 抽取 prompt 依据 `src/mex/llm/prompts.py` 的抽取规范精简而成:画像 vs + 画像外区分、置信度五档(禁止 confirmed)、质量标准(具体/独立/有用/不重复)、 + 时间规范(具体日期),并引导 agent 先用 `mex_profile` / `mex_list` 查重。 + +实现:Host half(`panel.js`)监听 `agent/turn-stopping` 事件缓存本轮对话, +注册 `/mex/panel-state`(状态轮询)与 `/mex/extract`(触发抽取)两个 HTTP 接口; +Client half(`client.js`)注册到 `shell.overlay` 浮动层并调用这两个接口。 + +> **注意**:抽取依赖 spawn 子代理(`dsh-subagent-spawn-in-process`,dsh 默认 +> 装配),且每次抽取以"最近一轮对话"为单位——多轮未点只抽取最近一轮,点一次抽一轮。 + ## 提供的工具(13 个) | 工具 | 对应 mex 命令 | 说明 | @@ -83,11 +102,11 @@ dsh web ## 开发与更新插件(mex 开发者) -插件的**唯一源码位置**是 `src/mex/integration/dsh/` 下的四个模板文件(`index.js` / `package.json` / `cordis.patch.yml` / `README.md`)。`mex-dsh-plugin/` 是 `mex integrate dsh` 的**生成产物**(已 gitignore),**不要直接改它**——改了会在下次重新生成时被覆盖丢失。 +插件的**唯一源码位置**是 `src/mex/integration/dsh/` 下的六个模板文件(`index.js` / `panel.js` / `client.js` / `package.json` / `cordis.patch.yml` / `README.md`)。`mex-dsh-plugin/` 是 `mex integrate dsh` 的**生成产物**(已 gitignore),**不要直接改它**——改了会在下次重新生成时被覆盖丢失。 ### 更新生效三步 -1. **改模板**:编辑 `src/mex/integration/dsh/index.js`(工具实现)或 `cordis.patch.yml`(补丁层)。 +1. **改模板**:编辑 `src/mex/integration/dsh/` 下的源码(`index.js` 工具实现、`panel.js` Host 面板逻辑、`client.js` 浏览器面板)或 `cordis.patch.yml`(补丁层)。 开发环境是 `pip install -e .`(editable 安装),改完即生效,无需重装。 2. **重新生成**:在 mex 项目根目录执行: @@ -108,7 +127,7 @@ dsh web - **一致性检查**:重新生成后确认模板与产物无漂移: ```sh - diff -q src/mex/integration/dsh/index.js mex-dsh-plugin/index.js + diff -rq src/mex/integration/dsh/ mex-dsh-plugin/ # 排除 node_modules/package-lock.json ``` 若不一致,说明有人改过生成产物或模板未重新生成——以模板为准,重新生成一次。 diff --git a/src/mex/integration/dsh/client.js b/src/mex/integration/dsh/client.js new file mode 100644 index 0000000..d672501 --- /dev/null +++ b/src/mex/integration/dsh/client.js @@ -0,0 +1,195 @@ +// meX × DeepSeek Harness 插件 · Client half(浏览器 UI 面板)。 +// +// 打包产物格式(window.__ModuleLoader__.load),依赖从 dsh 的模块表解析: +// - react:staticModules 静态提供; +// - slots:通过 inject 声明,运行时由 dsh-client-runtime 提供。 +// +// 面板注册到 shell.overlay(全屏浮动层,additive),默认停靠在页面右下角; +// 每轮对话结束后面板提示"可加入记忆",点击"Add to MeX"通过 HTTP 接口 +// 通知 Host 触发一轮新的抽取 agent 对话(见 index.js)。 +window.__ModuleLoader__.load({ + id: 'mex-dsh-plugin', + factory: (require) => { + var module = { exports: {} } + var exports = module.exports + Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }) + + var React = require('react') + + // 面板需要 slots 服务(注册 UI)。 + var inject = ['slots'] + + // 轮询间隔(毫秒)。 + var POLL_MS = 3000 + + /** 解析面板状态响应:{ hasNew, lastTurn, extractedSeq, error }。 */ + function parseState(data) { + if (data === null || typeof data !== 'object') return null + return data + } + + /** + * meX 面板:右下角浮动卡片。 + * 从 shell.overlay 的标准 props 取当前 session(useSessions), + * 轮询 Host 的面板状态接口,显示"可加入记忆"提示与触发按钮。 + */ + function MexPanel(props) { + var useSessions = props.useSessions + var sessionId = useSessions(function (s) { return s.current }) + + var state = React.useState(null) + var panel = state[0] + var setPanel = state[1] + + var busyState = React.useState(false) + var busy = busyState[0] + var setBusy = busyState[1] + + var resultState = React.useState(null) + var result = resultState[0] + var setResult = resultState[1] + + // 轮询面板状态:session 切换或每 POLL_MS 刷新一次。 + React.useEffect(function () { + var alive = true + var timer = null + var tick = function () { + if (!sessionId) return + fetch('/mex/panel-state?sessionId=' + encodeURIComponent(sessionId)) + .then(function (res) { return res.json() }) + .then(function (data) { + if (alive) setPanel(parseState(data)) + }) + .catch(function () { /* 网络瞬断忽略,下一轮重试 */ }) + } + tick() + timer = setInterval(tick, POLL_MS) + return function () { + alive = false + if (timer !== null) clearInterval(timer) + } + }, [sessionId]) + + /** 点击 Add to MeX:通知 Host 触发抽取 agent 对话。 */ + var onAddToMex = function () { + if (!sessionId || busy) return + setBusy(true) + setResult(null) + fetch('/mex/extract', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId: sessionId }), + }) + .then(function (res) { return res.json() }) + .then(function (data) { setResult(data) }) + .catch(function (err) { + setResult({ ok: false, error: String(err) }) + }) + .finally(function () { setBusy(false) }) + } + + var hasNew = panel !== null && panel.ok === true && panel.hasNew === true + var canRun = sessionId !== undefined && !busy + + // 面板卡片:固定右下角,深色底,紧凑布局。 + var cardStyle = { + position: 'fixed', + right: '16px', + bottom: '16px', + zIndex: 9999, + display: 'flex', + flexDirection: 'column', + gap: '8px', + width: '240px', + padding: '12px', + borderRadius: '12px', + background: 'rgba(24, 24, 27, 0.92)', + color: '#e4e4e7', + fontFamily: 'system-ui, sans-serif', + fontSize: '13px', + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)', + pointerEvents: 'auto', + } + + var titleStyle = { + display: 'flex', + alignItems: 'center', + gap: '6px', + fontWeight: 600, + fontSize: '14px', + } + + var badgeStyle = { + width: '8px', + height: '8px', + borderRadius: '50%', + background: hasNew ? '#4ade80' : '#52525b', + } + + var hintStyle = { + color: hasNew ? '#bbf7d0' : '#a1a1aa', + lineHeight: '18px', + } + + var buttonStyle = { + width: '100%', + padding: '8px 10px', + border: 'none', + borderRadius: '8px', + background: hasNew ? '#16a34a' : '#3f3f46', + color: hasNew ? '#ffffff' : '#a1a1aa', + cursor: canRun ? 'pointer' : 'not-allowed', + fontWeight: 600, + fontSize: '13px', + } + + var resultText = '' + if (result !== null) { + if (result.ok) { + resultText = '已写入 ' + (result.written || 0) + ' 条记忆' + if (result.notes) resultText += '(' + result.notes + ')' + } else { + resultText = '抽取失败:' + (result.error || '未知错误') + } + } + + return React.createElement( + 'div', + { style: cardStyle, 'data-plugin': 'mex-dsh-plugin' }, + React.createElement( + 'div', + { style: titleStyle }, + React.createElement('span', { style: badgeStyle }), + React.createElement('span', null, 'meX 记忆'), + ), + React.createElement( + 'div', + { style: hintStyle }, + hasNew ? '本轮对话已结束,可加入记忆' : busy ? '正在抽取记忆…' : result !== null ? resultText : '对话结束后点击下方按钮抽取记忆', + ), + React.createElement( + 'button', + { style: buttonStyle, onClick: onAddToMex, disabled: !canRun }, + busy ? '抽取中…' : 'Add to MeX', + ), + ) + } + + /** + * Client 插件入口:注册 shell.overlay 面板。 + * @param ctx - client 根上下文(注入 slots)。 + */ + function apply(ctx) { + ctx.slots.inject('shell.overlay', function () { + return ctx.slots.register( + { name: 'shell.overlay', id: 'mex-panel', order: 9999, label: 'meX 记忆' }, + MexPanel, + ) + }) + } + + exports.apply = apply + exports.inject = inject + return module.exports + }, +}) diff --git a/src/mex/integration/dsh/index.js b/src/mex/integration/dsh/index.js index a321b8c..23a18a0 100644 --- a/src/mex/integration/dsh/index.js +++ b/src/mex/integration/dsh/index.js @@ -13,6 +13,7 @@ */ import { defineTool } from '@deepseek-ai/dsh-tools' +import { applyMexPanel } from './panel.js' export const name = 'mex-dsh-plugin' @@ -306,4 +307,8 @@ export function apply(ctx) { for (const spec of toolsSpecs) { tools.register(makeTool({ ...spec, subprocess })) } + + // 面板逻辑:注册 /mex/panel-state 与 /mex/extract 接口, + // 供 Client half 的右下角面板轮询状态与触发抽取 agent 对话。 + return applyMexPanel(ctx) } diff --git a/src/mex/integration/dsh/package.json b/src/mex/integration/dsh/package.json index 1cde95a..db91a77 100644 --- a/src/mex/integration/dsh/package.json +++ b/src/mex/integration/dsh/package.json @@ -1,21 +1,29 @@ { "name": "mex-dsh-plugin", - "version": "0.1.0", - "description": "meX 个人记忆系统的 DeepSeek Harness 插件:把 mex CLI 命令镜像成原生工具(读/写/审查)。", + "version": "0.2.0", + "description": "meX 个人记忆系统的 DeepSeek Harness 插件:把 mex CLI 命令镜像成原生工具(读/写/审查),并提供右下角 meX 记忆面板(每轮对话后可一键抽取记忆入库)。", "type": "module", "main": "./index.js", "exports": { ".": "./index.js", + "./client": "./client.js", "./package.json": "./package.json" }, - "files": ["index.js", "cordis.patch.yml", "README.md"], + "files": ["index.js", "panel.js", "client.js", "cordis.patch.yml", "README.md"], "dsh": { "bundle": { "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": ["@deepseek-ai/dsh-client-runtime"] } }, "peerDependencies": { - "cordis": ">=4.0.0-rc <5" + "cordis": ">=4.0.0-rc <5", + "react": "^18.2.0", + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7" }, "license": "MIT" } diff --git a/src/mex/integration/dsh/panel.js b/src/mex/integration/dsh/panel.js new file mode 100644 index 0000000..317f869 --- /dev/null +++ b/src/mex/integration/dsh/panel.js @@ -0,0 +1,217 @@ +/** + * meX × DeepSeek Harness 插件 · Host 面板逻辑(抽取 agent 触发)。 + * + * 职责: + * 1. 监听 `agent/turn-stopping`(每轮对话即将关闭)——记录该会话"最近一轮 + * 已结束"的对话文本(user/assistant 消息),供面板提示与抽取使用; + * 2. 面板状态查询:client 轮询该会话是否有未抽取的已结束轮次; + * 3. 触发抽取:点击"Add to MeX"后,把未抽取轮次的对话文本 + 抽取 prompt + * 交给一个全新的子代理(spawn),子代理调用 mex_* 工具写入记忆。 + * + * 通信:通过 webServer 注册两个同源 HTTP 接口(/mex/panel-state、 + * /mex/extract),client half 用 fetch 调用。webServer / subagents / + * agents / timer 均为可选依赖:未装配时面板相关功能静默降级。 + */ + +/** 抽取 prompt:参考 src/mex/llm/prompts.py 的抽取规范,精简为工具指令。 */ +const EXTRACT_PROMPT = `你是"个人记忆抽取器"。以下是用户与 AI 助手的一段对话,请从中抽取值得长期记住的个人信息,并调用 meX 提供的工具写入记忆库。 + +抽取规则(必须遵守): +1. 画像 vs 画像外:能归入某个画像字段(topic.sub_topic 均非空且被 schema 声明)的信息 → 写入画像(mex_add 的 topic/sub_topic 参数);归入不了任何槽位的临时信息(情绪、想法、计划、当天完成的事)→ 写为画像外记录(sub_topic 省略,topic 可填领域或省略)。 +2. 置信度五档:explicit(用户原话明确陈述)/ inferred(由多条事实合理推导)/ speculated(少量线索推测)/ uncertain(几乎无证据)。禁止 confirmed(仅供人工审查)。推断信息必须用后三档之一。 +3. 质量标准:每条候选记忆必须同时满足:具体(保留名称/数字/日期/角色/原因)、独立(脱离对话也能理解)、有用(影响未来对话)、不重复(与已有记忆不重复)。 +4. 时间规范:用具体日期(YYYY-MM-DD),禁止"最近/上周/昨天"等相对表述。 +5. 写作规范:代词指代明确(禁止"它/这个/那家"等依赖上下文的指代)、名称用全称、内容自包含。 + +操作步骤: +1. 先用 mex_profile / mex_list 查看现有画像与记忆(了解字段与去重)。 +2. 新信息用 mex_add 写入;已有信息有变化用 mex_update 更新。 +3. 若无值得抽取的内容,不要调用任何写工具,直接回复"无新增记忆"。 + +最后一行必须输出:已写入N条记忆(N 为实际写入条数,未写入则为 0)。 + +对话内容: +{dialogue}` + +/** 每会话最近一轮已结束的对话缓存:sessionId → { turn, dialogue }。 */ +const pendingTurns = new Map() + +/** 每会话已抽取到的最大 turn 号(内存态;重启后未抽取轮次需重新对话才会补录)。 */ +const extractedTurns = new Map() + +/** 从 ContentBlock 数组中提取纯文本。 */ +function textOf(blocks) { + if (!Array.isArray(blocks)) return '' + return blocks + .filter((b) => b !== null && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('') +} + +/** + * 从 agent 的事件流中提取"第 targetTurn 轮"的用户/AI 对话文本。 + * 在 agent/turn-stopping 事件里调用:此时该轮 user/message 与 + * assistant/message 已全部 append,而 turn/end 尚未写入。 + * user/message 事件本身不带 turn 字段,按 turn/start 边界归属当前轮。 + */ +function extractTurnDialogue(events, targetTurn) { + const lines = [] + let currentTurn = 0 + for (const ev of events || []) { + if (!ev || !ev.data) continue + if (ev.type === 'turn/start' && typeof ev.data.turn === 'number') { + currentTurn = ev.data.turn + continue + } + if (ev.type === 'turn/end' && typeof ev.data.turn === 'number') { + currentTurn = 0 + continue + } + if (currentTurn !== targetTurn) continue + if (ev.type === 'user/message') { + const text = textOf(ev.data.content) + if (text) lines.push(`用户:${text}`) + } else if (ev.type === 'assistant/message' && ev.data.turn === targetTurn) { + const text = textOf(ev.data.message && ev.data.message.content) + if (text) lines.push(`AI:${text}`) + } + } + return lines.join('\n') +} + +/** 从抽取 agent 的输出中解析"已写入N条记忆"。 */ +function parseWritten(outputBlocks) { + const text = (outputBlocks || []) + .filter((b) => b !== null && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('\n') + const match = /已写入\s*(\d+)\s*条记忆/.exec(text) + return match ? Number(match[1]) : 0 +} + +/** 读取 node http 请求体(JSON),返回解析后的对象或 null。 */ +function readJsonBody(req) { + return new Promise((resolve) => { + let raw = '' + req.on('data', (chunk) => { + raw += chunk + if (raw.length > 1024 * 1024) { + req.destroy() + resolve(null) + } + }) + req.on('end', () => { + try { + resolve(JSON.parse(raw || '{}')) + } catch { + resolve(null) + } + }) + req.on('error', () => resolve(null)) + }) +} + +/** 写 JSON 响应(node http 风格)。 */ +function sendJson(res, status, body) { + const payload = JSON.stringify(body) + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }) + res.end(payload) +} + +/** 解析 URL 查询参数(sessionId 等)。 */ +function queryOf(req) { + const url = new URL(req.url || '/', 'http://localhost') + return Object.fromEntries(url.searchParams.entries()) +} + +/** + * 注册面板接口与抽取逻辑。 + * @param ctx - bundle 插件上下文。 + * @returns 移除所有注册的 disposer(bundle 卸载时清理)。 + */ +export function applyMexPanel(ctx) { + const webServer = ctx.get('webServer') + const agents = ctx.get('agents') + const subagents = ctx.get('subagents') + + // 依赖缺失时静默降级(headless / 非 web 环境)。 + if (webServer === undefined) return () => {} + + // 每轮对话即将关闭时缓存该轮对话文本,供面板提示与抽取使用。 + ctx.on('agent/turn-stopping', ({ agent, turn }) => { + try { + // session.log 是实时追加数组;session.events 是惰性冻结快照, + // 可能在 turn 进行中被提前固化,故这里读 log 拿到最新事件。 + const dialogue = extractTurnDialogue(agent.session.log, turn) + if (dialogue.trim()) pendingTurns.set(agent.id, { turn, dialogue }) + } catch (error) { + ctx.logger?.warn(`mex-panel: 缓存第 ${turn} 轮对话失败: ${String(error)}`) + } + }) + + /** GET /mex/panel-state?sessionId=…:面板状态(是否有未抽取的已结束轮次)。 */ + const onPanelState = async (req, res) => { + const sessionId = queryOf(req).sessionId + if (!sessionId) { + sendJson(res, 200, { ok: true, hasNew: false, error: '缺少 sessionId' }) + return + } + const pending = pendingTurns.get(sessionId) + const lastTurn = pending ? pending.turn : 0 + const extracted = extractedTurns.get(sessionId) ?? 0 + sendJson(res, 200, { ok: true, hasNew: lastTurn > extracted, lastTurn, extractedTurn: extracted }) + } + + /** POST /mex/extract body {sessionId}:触发抽取 agent 对话。 */ + const onExtract = async (req, res) => { + const body = await readJsonBody(req) + const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId : undefined + if (!sessionId || agents === undefined || subagents === undefined) { + sendJson(res, 200, { ok: false, error: '依赖服务不可用(agents/subagents 未装配)' }) + return + } + const pending = pendingTurns.get(sessionId) + const lastTurn = pending ? pending.turn : 0 + const extracted = extractedTurns.get(sessionId) ?? 0 + if (lastTurn <= extracted || !pending) { + sendJson(res, 200, { ok: false, error: '没有新的已结束对话可抽取' }) + return + } + const parent = agents.get(sessionId) + if (parent === undefined) { + sendJson(res, 200, { ok: false, error: '会话未激活(agent 不在运行中)' }) + return + } + const controller = new AbortController() + try { + const run = await subagents.start('spawn', { + label: 'meX 记忆抽取', + prompt: [{ type: 'text', text: EXTRACT_PROMPT.replace('{dialogue}', pending.dialogue) }], + parent, + signal: controller.signal, + maxDepth: 3, + }) + const result = await run.result + // 抽取完成(含"无新增记忆"):标记该轮已处理,避免重复触发。 + extractedTurns.set(sessionId, lastTurn) + sendJson(res, 200, { + ok: true, + written: parseWritten(result && result.output), + stopReason: result && result.stopReason, + notes: result && result.stopReason === 'completed' ? undefined : '抽取未正常完成', + }) + } catch (error) { + sendJson(res, 200, { ok: false, error: error instanceof Error ? error.message : String(error) }) + } + } + + const d1 = webServer.register({ kind: 'exact', path: '/mex/panel-state', handler: onPanelState }) + const d2 = webServer.register({ kind: 'exact', path: '/mex/extract', handler: onExtract }) + return () => { + d1() + d2() + } +} diff --git a/src/mex/services/integrate.py b/src/mex/services/integrate.py index 8f905fc..6b1a3d8 100644 --- a/src/mex/services/integrate.py +++ b/src/mex/services/integrate.py @@ -5,7 +5,7 @@ claude / opencode 产出 skill 说明书(写入官方 skills/mex/SKILL.md 发现路径)+ README.md (写入 agent 配置目录)。不再生成 hooks.md——OpenCode 原生不支持 SessionEnd hook (官方配置 schema 无 hooks 字段),写路径由 skill 引导 agent 即时写承担,hook 能力留待扩展; -dsh 产出标准 DSH bundle 目录(index.js + package.json + cordis.patch.yml + README.md)。 +dsh 产出标准 DSH bundle 目录(index.js + panel.js + client.js + package.json + cordis.patch.yml + README.md)。 """ from __future__ import annotations @@ -33,7 +33,9 @@ ) # DSH bundle 的组成文件(独立于 SKILL_FILE_LAYOUT 的清单)。 -DSH_BUNDLE_FILES = ("index.js", "package.json", "cordis.patch.yml", "README.md") +# index.js = Host half(工具注册);panel.js = Host 面板逻辑(抽取 agent 触发); +# client.js = Client half(浏览器 UI 面板);package.json 声明 dsh.client 入口。 +DSH_BUNDLE_FILES = ("index.js", "panel.js", "client.js", "package.json", "cordis.patch.yml", "README.md") @dataclass(frozen=True) @@ -108,7 +110,7 @@ def _generate_skill_files(agent: AgentName, base_dir: Path) -> list[str]: def _generate_dsh_bundle(base_dir: Path) -> list[str]: - """复制 DSH bundle 目录(index.js 等四件套)到 base_dir。""" + """复制 DSH bundle 目录(index.js/panel.js/client.js 等六件套)到 base_dir。""" written: list[str] = [] for name in DSH_BUNDLE_FILES: source = resources.files("mex.integration").joinpath("dsh", name) diff --git a/tests/services/test_integrate.py b/tests/services/test_integrate.py index d02b032..5306538 100644 --- a/tests/services/test_integrate.py +++ b/tests/services/test_integrate.py @@ -98,9 +98,9 @@ def test_unknown_scope_raises(self, mex_home, tmp_path, monkeypatch): class TestIntegrateDsh: - """dsh 产出标准 DSH bundle 目录(四件套),形态与 claude/opencode 的三件 .md 不同。""" + """dsh 产出标准 DSH bundle 目录(六件套:Host/Client 双 half),形态与 claude/opencode 的三件 .md 不同。""" - DSH_FILES = ("index.js", "package.json", "cordis.patch.yml", "README.md") + DSH_FILES = ("index.js", "panel.js", "client.js", "package.json", "cordis.patch.yml", "README.md") @pytest.fixture def mex_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: @@ -121,7 +121,7 @@ def test_generates_bundle(self, mex_home, tmp_path, monkeypatch, scope): result = integrate("dsh", scope) assert result.agent == "dsh" and result.scope == scope - assert len(result.written_files) == 4 + assert len(result.written_files) == 6 for name in self.DSH_FILES: assert (expected / name).exists() assert str(expected / name) in result.written_files @@ -134,11 +134,24 @@ def test_bundle_manifest_valid(self, mex_home, tmp_path, monkeypatch): pkg = (tmp_path / "mex-dsh-plugin" / "package.json").read_text(encoding="utf-8") assert "mex-dsh-plugin" in pkg assert '"type": "module"' in pkg + assert '"client"' in pkg # Client half 入口 js = (tmp_path / "mex-dsh-plugin" / "index.js").read_text(encoding="utf-8") assert "@deepseek-ai/dsh-tools" in js assert "export function apply" in js assert "defineTool" in js + assert "applyMexPanel" in js # Host 面板逻辑已接入 + + client = (tmp_path / "mex-dsh-plugin" / "client.js").read_text(encoding="utf-8") + assert "__ModuleLoader__.load" in client # Client half 打包格式 + assert "shell.overlay" in client # 注册到浮动层 + assert "Add to MeX" in client # 抽取按钮 + + panel = (tmp_path / "mex-dsh-plugin" / "panel.js").read_text(encoding="utf-8") + assert "agent/turn-stopping" in panel # 每轮结束感知 + assert "/mex/panel-state" in panel + assert "/mex/extract" in panel + assert "subagents.start" in panel # 触发新 agent 对话 def test_idempotent(self, mex_home, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) From 8731423b323895e769c0456e0f517dad309b2702 Mon Sep 17 00:00:00 2001 From: micklife Date: Thu, 20 Aug 2026 14:59:11 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20mex=20=E9=9D=A2=E6=9D=BF=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E6=9C=AA=E6=B3=A8=E5=86=8C=E5=AF=BC=E8=87=B4=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=E5=A4=B1=E8=B4=A5=EF=BC=88Unexpected=20end=20of=20JSO?= =?UTF-8?q?N=20input=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:panel.js 用 ctx.get('webServer') 一次性获取服务,而 bundle 插件 apply 时机可能早于 webServer 服务注册(Cordis 按 inject 依赖激活,mex 只依赖 subprocess/tools),拿到 undefined 后静默降级,/mex/* 路由从未 注册,POST 落到 SPA fallback 返回 405 空 body,前端 res.json() 报 "Unexpected end of JSON input"。 修复: - panel.js 改用 ctx.inject(['webServer'], cb) 等待服务可用后再注册路由 (标准做法,headless 无 webServer 时回调不执行、不影响 mex 工具); - client.js 增加 parseJson 容错:空响应/非 JSON 转结构化错误提示, 不再抛 SyntaxError。 --- src/mex/integration/dsh/client.js | 16 ++++++++++++-- src/mex/integration/dsh/panel.js | 35 ++++++++++++++++++------------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/mex/integration/dsh/client.js b/src/mex/integration/dsh/client.js index d672501..4a081ba 100644 --- a/src/mex/integration/dsh/client.js +++ b/src/mex/integration/dsh/client.js @@ -37,6 +37,18 @@ window.__ModuleLoader__.load({ var useSessions = props.useSessions var sessionId = useSessions(function (s) { return s.current }) + /** 解析 JSON 响应;非 JSON(405 空 body / HTML fallback)转为结构化错误提示。 */ + var parseJson = function (res) { + return res.text().then(function (text) { + if (!text) return { ok: false, error: '接口返回空响应(Host 服务可能未就绪,请重启 dsh 后重试)' } + try { + return JSON.parse(text) + } catch (err) { + return { ok: false, error: '接口返回非 JSON 响应(' + String(err) + '),请重启 dsh 后重试' } + } + }) + } + var state = React.useState(null) var panel = state[0] var setPanel = state[1] @@ -56,7 +68,7 @@ window.__ModuleLoader__.load({ var tick = function () { if (!sessionId) return fetch('/mex/panel-state?sessionId=' + encodeURIComponent(sessionId)) - .then(function (res) { return res.json() }) + .then(parseJson) .then(function (data) { if (alive) setPanel(parseState(data)) }) @@ -80,7 +92,7 @@ window.__ModuleLoader__.load({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: sessionId }), }) - .then(function (res) { return res.json() }) + .then(parseJson) .then(function (data) { setResult(data) }) .catch(function (err) { setResult({ ok: false, error: String(err) }) diff --git a/src/mex/integration/dsh/panel.js b/src/mex/integration/dsh/panel.js index 317f869..e3da210 100644 --- a/src/mex/integration/dsh/panel.js +++ b/src/mex/integration/dsh/panel.js @@ -129,18 +129,18 @@ function queryOf(req) { /** * 注册面板接口与抽取逻辑。 + * + * 注意:不能用 `ctx.get('webServer')` 一次性获取——bundle 插件的 apply 时机 + * 可能早于 webServer 服务注册(装配时序由 Cordis 依赖决定),拿到 undefined + * 会导致路由永不注册、面板请求落到 SPA fallback。正确做法是用 + * `ctx.inject(['webServer'], ...)` 等待服务可用后再注册路由;headless 环境 + * 服务永不出现则回调不执行,插件其余功能(mex 工具)不受影响。 + * * @param ctx - bundle 插件上下文。 - * @returns 移除所有注册的 disposer(bundle 卸载时清理)。 */ export function applyMexPanel(ctx) { - const webServer = ctx.get('webServer') - const agents = ctx.get('agents') - const subagents = ctx.get('subagents') - - // 依赖缺失时静默降级(headless / 非 web 环境)。 - if (webServer === undefined) return () => {} - // 每轮对话即将关闭时缓存该轮对话文本,供面板提示与抽取使用。 + // 该监听不依赖 webServer,始终注册。 ctx.on('agent/turn-stopping', ({ agent, turn }) => { try { // session.log 是实时追加数组;session.events 是惰性冻结快照, @@ -169,6 +169,8 @@ export function applyMexPanel(ctx) { const onExtract = async (req, res) => { const body = await readJsonBody(req) const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId : undefined + const agents = ctx.get('agents') + const subagents = ctx.get('subagents') if (!sessionId || agents === undefined || subagents === undefined) { sendJson(res, 200, { ok: false, error: '依赖服务不可用(agents/subagents 未装配)' }) return @@ -208,10 +210,15 @@ export function applyMexPanel(ctx) { } } - const d1 = webServer.register({ kind: 'exact', path: '/mex/panel-state', handler: onPanelState }) - const d2 = webServer.register({ kind: 'exact', path: '/mex/extract', handler: onExtract }) - return () => { - d1() - d2() - } + // 等待 webServer 服务可用后再注册路由(web 环境);回调返回 disposer, + // 服务卸载时由 Cordis 收集清理。 + ctx.inject(['webServer'], (webCtx) => { + const webServer = webCtx.webServer + const d1 = webServer.register({ kind: 'exact', path: '/mex/panel-state', handler: onPanelState }) + const d2 = webServer.register({ kind: 'exact', path: '/mex/extract', handler: onExtract }) + return () => { + d1() + d2() + } + }) } From 2f58e119b02e77abfc1d6d9b2792d4a15cc3ffb5 Mon Sep 17 00:00:00 2001 From: micklife Date: Thu, 20 Aug 2026 15:18:33 +0800 Subject: [PATCH 3/8] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20dsh=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E9=9B=86=E6=88=90=E8=AE=BE=E8=AE=A1=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=88meX=20=E8=AE=B0=E5=BF=86=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E4=B8=8E=20Add=20to=20MeX=20=E6=8A=BD=E5=8F=96=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/dsh-integration.md 记录 dsh 侧实现细节: - 整体架构(Client 面板 → Host 缓存/路由 → spawn 子代理 → mex CLI → SQLite); - 触发链路逐步说明(turn-stopping 缓存、prompt 组装、防重复机制); - 关键设计决策(为何用子代理、为何 ctx.inject 等待 webServer、log vs events); - 与 ADR-12 的关系(用户主动触发 vs 自动实时抽取,不冲突)。 dsh 插件 README 补充指向该设计文档的链接。 --- docs/dsh-integration.md | 200 ++++++++++++++++++++++++++++++ src/mex/integration/dsh/README.md | 3 + 2 files changed, 203 insertions(+) create mode 100644 docs/dsh-integration.md diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md new file mode 100644 index 0000000..0c6dca7 --- /dev/null +++ b/docs/dsh-integration.md @@ -0,0 +1,200 @@ +# dsh 插件集成设计:meX 记忆面板与"Add to MeX"抽取链路 + +> 适用对象:mex-dsh-plugin(DeepSeek Harness 插件)。 +> 本文档记录 dsh 侧集成**实现细节**,与 `architecture.md` §8 的 agent 集成章节互补: +> §8 描述 claude/opencode 的 skill 机制(即时写 + 会话结束 hook 留待扩展); +> 本文描述 dsh 特有形态——浏览器 UI 面板 + 用户主动触发的"每轮对话后抽取"。 +> 接口以代码 docstring 为准;本文是设计说明,不替代代码。 + +## 1. 定位与触发时机 + +| 维度 | 说明 | +|---|---| +| 触发方式 | **用户主动点击**面板按钮(Add to MeX),非自动实时抽取 | +| 触发粒度 | 一轮(dsh 的 Turn)对话结束后可触发一次 | +| 与 ADR-12 的关系 | 不冲突。ADR-12 §8.1 的"每轮对话后自动抽取 ❌ 不做"针对的是**自动实时抽取**(LLM 延迟打断对话节奏、成本无谓放大);本面板由**用户显式点击**触发,成本与节奏由用户掌控,属于 §8.1 表格之外的用户主动形态 | +| 依赖 | dsh 默认装配的 spawn 子代理(`dsh-subagent-spawn-in-process`),无需额外安装 | + +## 2. 整体架构 + +``` +┌─────────────┐ ① POST /mex/extract ┌──────────────────────────┐ +│ 浏览器面板 │ ──────────────────────► │ dsh Host(panel.js) │ +│ "Add to MeX"│ │ ② 取缓存的本轮对话文本 │ +└─────────────┘ │ ③ 组装抽取 prompt │ + │ ④ 触发一个全新子代理 │ + └────────────┬─────────────┘ + │ ⑤ 子代理开始思考 + ▼ + ┌─────────────────────────────────────┐ + │ 子代理(全新、独立的 agent) │ + │ 收到:抽取规范 prompt + 对话文本 │ + │ 自主判断哪些值得记,调用 │ + │ mex_search / mex_add / mex_update │ + └────────────┬────────────────────────┘ + │ ⑥ 调用 mex 工具 + ▼ + ┌─────────────────────────────────────┐ + │ mex_add 等工具(index.js 注册) │ + │ 薄壳:subprocess 执行 mex CLI 命令 │ + │ mex add --topic work --content ... │ + └────────────┬────────────────────────┘ + │ ⑦ CLI 写数据库 + ▼ + ┌─────────────────────────────────────┐ + │ ~/.mex/mex.db(SQLite) │ + │ 记忆落库,可被任何项目读取 │ + └─────────────────────────────────────┘ +``` + +## 3. 组件与文件职责 + +| 文件 | half | 职责 | +|---|---|---| +| `client.js` | Client(浏览器) | 注册 `shell.overlay` 浮动层面板(右下角);轮询 `/mex/panel-state`;"Add to MeX"按钮 → POST `/mex/extract`;展示抽取结果 | +| `panel.js` | Host(Node) | 监听 `agent/turn-stopping` 缓存对话;注册 `/mex/panel-state` 与 `/mex/extract` 两个 HTTP 接口;组装抽取 prompt;触发 spawn 子代理 | +| `index.js` | Host(Node) | 注册 13 个 `mex_*` 工具(薄壳:subprocess 执行 `mex` CLI);接入 `applyMexPanel(ctx)` | +| `package.json` | — | 声明 `dsh.client` 入口(Client half 打包产物 + 依赖注入) | + +## 4. 触发链路逐步说明 + +### 4.1 点击按钮 → 浏览器发请求(client.js) + +`onAddToMex` 只把**当前会话 id** 发给 Host,对话内容本身不随请求传输: + +```js +fetch('/mex/extract', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId: sessionId }), +}) +``` + +### 4.2 Host 取"本轮对话文本"(panel.js) + +Host 维护内存缓存 `pendingTurns`(`sessionId → { turn, dialogue }`),来源是监听 dsh 事件: + +```js +ctx.on('agent/turn-stopping', ({ agent, turn }) => { + const dialogue = extractTurnDialogue(agent.session.log, turn) + if (dialogue.trim()) pendingTurns.set(agent.id, { turn, dialogue }) +}) +``` + +- **`agent/turn-stopping`**:dsh 在"每一轮对话即将结束"时发出的信号(Turn 概念:一次"提问 → agent 完整回答")。回答完、要收尾时触发。 +- `extractTurnDialogue` 遍历会话日志,把 `user/message` 事件写为 `用户:…`,把 `assistant/message` 事件写为 `AI:…`,按 turn 号筛选目标轮次。 +- 读 `agent.session.log`(实时追加数组)而非 `session.events`(惰性冻结快照,可能在 turn 进行中被提前固化,拿不到最新事件)。 + +点击按钮时对话文本**已在** Host 内存,请求处理函数直接取 `pending.dialogue`。 + +### 4.3 防重复机制 + +`extractedTurns`(`sessionId → turn`)记录每个会话"已抽取到第几轮": + +- 点击时比较 `lastTurn > extracted` 才允许抽取,否则返回"没有新的已结束对话可抽取"。 +- 同一轮不会重复抽取、不会重复写入。 + +### 4.4 组装抽取 prompt(panel.js 的 EXTRACT_PROMPT) + +参考 `src/mex/llm/prompts.py` 的抽取规范,精简为给 agent 的工具指令: + +1. **画像 vs 画像外**:能归入 `topic.sub_topic` 槽位的写画像,否则写画像外记录(sub_topic 省略) +2. **置信度五档**:explicit / inferred / speculated / uncertain,禁止 confirmed(仅供人工审查) +3. **质量标准**:具体(保留名称/数字/日期/角色)、独立(脱离对话可理解)、有用、不重复 +4. **时间规范**:写具体日期(YYYY-MM-DD),禁止"最近/上周/昨天"等相对表述 +5. **操作步骤**:先 `mex_profile`/`mex_list` 查重 → `mex_add` 新增 / `mex_update` 更新 → 无内容不写 +6. 最后要求输出"已写入N条记忆"(供面板显示条数) + +组装时替换占位符: + +```js +prompt: [{ type: 'text', text: EXTRACT_PROMPT.replace('{dialogue}', dialogue) }] +``` + +### 4.5 触发全新子代理(panel.js) + +```js +const run = await subagents.start('spawn', { + label: 'meX 记忆抽取', + prompt: [{ type: 'text', text: ... }], // 4.4 组装好的 prompt + parent, // 当前会话的 agent(作为父级) + signal: controller.signal, + maxDepth: 3, +}) +``` + +- `subagents.start('spawn', ...)` 是 dsh 的子代理机制(`dsh-subagent-spawn-in-process` 提供)。 +- 创建一个**全新的子代理**:独立会话、独立上下文、**看不到用户与 AI 的主对话**(零父上下文)。 +- 把 prompt 作为子代理的第一条用户消息,子代理运行完一轮后返回结果并自动销毁。 +- **为什么用子代理**:抽取是独立任务(读记忆库、写记忆库),不应污染当前对话,也不应看到主对话外的内容。隔离的临时 agent 最干净。 +- 子代理继承所有已注册工具——**包括 13 个 `mex_*` 工具**,这是它能写记忆的原因。 + +### 4.6 子代理调用 mex 工具写记忆 + +子代理自主决策,典型流程: + +1. `mex_profile` / `mex_list` → 查看记忆库已有内容(了解字段、避免重复) +2. 每条值得记的信息 → `mex_add`(topic / sub_topic / content / confidence) +3. 已有信息变化 → `mex_update` +4. 无值得记的内容 → 不调用任何写工具,直接回复"无新增记忆" + +写工具的 description 内置"写作规范"(代词明确、具体日期、自包含、用全称),子代理每次调用可见,与 4.4 的 prompt 双重约束。 + +### 4.7 mex 工具薄壳:subprocess 执行 CLI(index.js) + +```js +async execute(args) { + return await runMex(spec.subprocess, spec.argsFn(args)) +} +``` + +`runMex` 用 `subprocess.spawn` 启动真实的 `mex` 命令行进程,例如子代理调用 +`mex_add(topic="work", sub_topic="company", content="星辰科技")` 底层执行: + +```bash +mex add --topic work --sub-topic company --content "星辰科技" +``` + +`mex add` 是 meX 项目自己的 CLI(`src/mex/cli/write.py`),内部把记忆写入 +`~/.mex/mex.db`(SQLite)。**业务逻辑 100% 留在 meX CLI 内**——唯一事实原则、 +槽位校验、审计、置信度处理都由 meX 承担,插件只做"参数翻译 + 进程调用 + 结果透传"。 + +### 4.8 结果回传与状态更新 + +```js +const result = await run.result // 等子代理完成 +extractedTurns.set(sessionId, lastTurn) // 标记这轮已抽取,防重复 +sendJson(res, 200, { + ok: true, + written: parseWritten(result.output), // 从"已写入N条记忆"解析条数 + stopReason: result.stopReason, +}) +``` + +面板收到后显示"已写入 N 条记忆"。 + +## 5. 关键设计决策 + +| 决策 | 原因 | +|---|---| +| 用子代理而非直接调 `mex extract` | 需求要求"触发一轮新的 agent 对话";子代理是 dsh 的隔离 agent 机制,能看到 mex 工具但看不到主对话上下文——安全且不干扰当前对话 | +| prompt 参考 prompts.py 但改写成"工具指令" | prompts.py 是给 `mex extract` 用的(输出 JSON 让代码解析);本场景 agent 自主**操作工具**,所以 prompt 是"规范 + 操作步骤" | +| 每轮一个对话文本 + `extractedTurns` 防重复 | 避免同一轮对话被反复抽取写脏数据 | +| `ctx.inject(['webServer'], cb)` 等待路由注册 | bundle 插件 apply 时机可能早于 webServer 服务注册(Cordis 按 inject 依赖激活,mex 只依赖 subprocess/tools);一次性 `ctx.get('webServer')` 会拿到 undefined 导致路由永不注册、请求落到 SPA fallback(曾导致 405 空 body、前端 `Unexpected end of JSON input`)。headless 无 webServer 时回调不执行、不影响 mex 工具 | +| 读 `agent.session.log` 而非 `session.events` | events 是惰性冻结快照,turn 进行中可能已被固化;log 是实时追加数组 | + +## 6. HTTP 接口 + +| 接口 | 方法 | 入参 | 返回 | +|---|---|---|---| +| `/mex/panel-state` | GET | `sessionId` query | `{ ok, hasNew, lastTurn, extractedTurn }` | +| `/mex/extract` | POST | body `{ sessionId }` | `{ ok, written, stopReason, error? }` | + +同源 HTTP(webServer 注册 exact route),Client half 用 fetch 调用。 + +## 7. 局限与后续 + +- 抽取以"最近一轮"为单位:点一次抽一轮,多轮未点只抽最近一轮。 +- 子代理是真实 LLM 对话,判断有自由度(漏记/置信度偏差),建议定期 `mex review list` 审查 AI 推断记忆。 +- 每次点击都启动一个子代理(一次 LLM 调用);高频点击会放大成本——当前由"同一轮防重复"机制兜底。 +- 未来若做"会话结束自动抽取",可复用 4.2 的 `pendingTurns` 缓存与 4.4 的 prompt,在 hook/事件回调里触发(ADR-12 留待扩展的 hook 能力)。 diff --git a/src/mex/integration/dsh/README.md b/src/mex/integration/dsh/README.md index a0df028..a93ff34 100644 --- a/src/mex/integration/dsh/README.md +++ b/src/mex/integration/dsh/README.md @@ -73,6 +73,9 @@ Client half(`client.js`)注册到 `shell.overlay` 浮动层并调用这两 > **注意**:抽取依赖 spawn 子代理(`dsh-subagent-spawn-in-process`,dsh 默认 > 装配),且每次抽取以"最近一轮对话"为单位——多轮未点只抽取最近一轮,点一次抽一轮。 +> +> **完整实现细节**(触发链路、prompt 设计、防重复机制、与 ADR-12 的关系等) +> 见设计文档 [`docs/dsh-integration.md`](../../../../docs/dsh-integration.md)。 ## 提供的工具(13 个) From 4f7ca722416717daff621a58e905187ce32340b2 Mon Sep 17 00:00:00 2001 From: micklife Date: Thu, 20 Aug 2026 15:24:52 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=E6=8A=BD=E5=8F=96=E8=BF=87?= =?UTF-8?q?=E7=A8=8B=E9=80=8F=E6=98=8E=E5=8F=AF=E8=A7=81=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E5=90=8E=E6=89=93=E5=BC=80=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E4=BC=9A=E8=AF=9D=E8=A7=86=E5=9B=BE=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=9F=A5=E7=9C=8B=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E4=B8=8E=E6=80=9D=E8=80=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - panel.js:onExtract 改为异步触发(立即返回 childSessionId,不等待 result、不 dispose),子代理保留在当前会话的侧边栏目录; 监听 subagent/end 标记抽取完成(防重复 + 记录写入条数); panel-state 增加 extraction 运行状态(running/done)。 - client.js:注入 sessions 服务,点击 Add to MeX 后调 sessions.openSubagent 打开抽取子代理会话视图(非黑盒); 面板显示抽取中/完成状态与查看运行过程按钮。 - 文档:dsh-integration.md 更新 4.5/4.8/5/6/7(透明可见设计), README 同步说明。 --- docs/dsh-integration.md | 94 +++++++++++++-------- src/mex/integration/dsh/README.md | 14 +++- src/mex/integration/dsh/client.js | 132 ++++++++++++++++++++---------- src/mex/integration/dsh/panel.js | 60 +++++++++++--- 4 files changed, 210 insertions(+), 90 deletions(-) diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md index 0c6dca7..ae68c52 100644 --- a/docs/dsh-integration.md +++ b/docs/dsh-integration.md @@ -22,31 +22,23 @@ │ 浏览器面板 │ ──────────────────────► │ dsh Host(panel.js) │ │ "Add to MeX"│ │ ② 取缓存的本轮对话文本 │ └─────────────┘ │ ③ 组装抽取 prompt │ - │ ④ 触发一个全新子代理 │ - └────────────┬─────────────┘ - │ ⑤ 子代理开始思考 - ▼ - ┌─────────────────────────────────────┐ - │ 子代理(全新、独立的 agent) │ - │ 收到:抽取规范 prompt + 对话文本 │ - │ 自主判断哪些值得记,调用 │ - │ mex_search / mex_add / mex_update │ - └────────────┬────────────────────────┘ - │ ⑥ 调用 mex 工具 - ▼ - ┌─────────────────────────────────────┐ - │ mex_add 等工具(index.js 注册) │ - │ 薄壳:subprocess 执行 mex CLI 命令 │ - │ mex add --topic work --content ... │ - └────────────┬────────────────────────┘ - │ ⑦ CLI 写数据库 - ▼ - ┌─────────────────────────────────────┐ - │ ~/.mex/mex.db(SQLite) │ - │ 记忆落库,可被任何项目读取 │ - └─────────────────────────────────────┘ + │ ⑥ openSubagent │ ④ 触发一个全新子代理(保留) │ + ▼ (打开子代理会话) └────────────┬─────────────┘ +┌──────────────────────┐ │ ⑤ 子代理开始思考 +│ 子代理会话视图(透明) │ ◄───────────────────────────┘ +│ 用户实时看到: │ +│ · 思考过程(流式) │ 子代理调用 mex_search / mex_add … +│ · 工具调用卡片 │ +│ · 最终"已写入N条" │ +└──────────────────────┘ ``` +**透明性设计**:抽取子代理**保留**(不 dispose),作为当前会话的 one-shot +子代理进入 dsh 侧边栏子代理目录;点击"Add to MeX"后 client 立即调用 +`sessions.openSubagent(address)` 打开该子代理的会话视图——用户**实时看到 +抽取 agent 调用工具与思考的完整过程**,不再是隐藏的黑盒。抽取完成后该 +子代理会话保留在目录中,可随时回看(§4.8)。 + ## 3. 组件与文件职责 | 文件 | half | 职责 | @@ -124,8 +116,9 @@ const run = await subagents.start('spawn', { ``` - `subagents.start('spawn', ...)` 是 dsh 的子代理机制(`dsh-subagent-spawn-in-process` 提供)。 -- 创建一个**全新的子代理**:独立会话、独立上下文、**看不到用户与 AI 的主对话**(零父上下文)。 -- 把 prompt 作为子代理的第一条用户消息,子代理运行完一轮后返回结果并自动销毁。 +- 创建一个**全新的子代理**:独立会话、独立上下文、**看不到用户与 AI 的主对话**(零父上下文)。注意:是全新会话(`agents.create` 随机新 id),**不是** fork 主对话。 +- 把 prompt 作为子代理的第一条用户消息。 +- **子代理保留(不 dispose)**:与 dsh 标准工具的"前台调用结束即 dispose"不同,这里刻意保留——子代理作为当前会话的 one-shot 子代理进入侧边栏目录,用户可点开查看完整过程(§4.8)。 - **为什么用子代理**:抽取是独立任务(读记忆库、写记忆库),不应污染当前对话,也不应看到主对话外的内容。隔离的临时 agent 最干净。 - 子代理继承所有已注册工具——**包括 13 个 `mex_*` 工具**,这是它能写记忆的原因。 @@ -159,19 +152,45 @@ mex add --topic work --sub-topic company --content "星辰科技" `~/.mex/mex.db`(SQLite)。**业务逻辑 100% 留在 meX CLI 内**——唯一事实原则、 槽位校验、审计、置信度处理都由 meX 承担,插件只做"参数翻译 + 进程调用 + 结果透传"。 -### 4.8 结果回传与状态更新 +### 4.8 透明可见:打开子代理会话,实时查看全程 + +**设计目标**:抽取不是黑盒——用户点击后能看到子代理调用工具与思考的全程。 + +触发链路(异步): + +1. **Host 立即返回,不等待**:`onExtract` 在 `subagents.start` 返回后马上响应 + `{ ok, childSessionId, parentSessionId, mode: 'one-shot' }`,**不** `await run.result`, + **不** dispose(子代理保留)。 +2. **Client 打开子代理会话视图**:面板拿到 `childSessionId` 后调用 + `sessions.openSubagent({ parentSessionId, childSessionId, mode: 'one-shot' })`, + 界面切换到该子代理的会话——用户**实时看到**抽取 agent 的思考过程、 + 每一步 `mex_*` 工具调用卡片与结果,与查看普通对话完全一致。 +3. **运行状态**:Host 维护 `extractionState`(`sessionId → { phase: 'running', + childSessionId }`),`/mex/panel-state` 返回给面板;面板显示"抽取中…", + 并提供"查看运行过程"按钮(再次 `openSubagent`,从侧边栏/面板均可进入)。 +4. **完成标记(`subagent/end`)**:Host 监听 `subagent/end` 事件,按 + `info.id`(子代理 session id)关联到父会话,把该轮标记为已抽取(防重复), + 并从 `info.lastAssistantMessage` 解析"已写入N条记忆",更新 + `extractionState` 为 `{ phase: 'done', written, stopReason }`。 +5. **保留回看**:抽取完成的子代理会话保留在侧边栏子代理目录(运行状态圆点 + 变"done"),用户可随时点开回看这次抽取做了什么。 ```js -const result = await run.result // 等子代理完成 -extractedTurns.set(sessionId, lastTurn) // 标记这轮已抽取,防重复 -sendJson(res, 200, { - ok: true, - written: parseWritten(result.output), // 从"已写入N条记忆"解析条数 - stopReason: result.stopReason, +// panel.js:subagent/end 完成标记 +ctx.on('subagent/end', (info) => { + const entry = extractionState.get(info.id) // childSessionId → 父会话关联 + if (entry === undefined) return + extractedTurns.set(entry.parentSessionId, entry.turn) + extractionState.set(entry.parentSessionId, { + phase: 'done', + childSessionId: info.id, + written: parseWritten(info.lastAssistantMessage), + stopReason: info.stopReason, + }) }) ``` -面板收到后显示"已写入 N 条记忆"。 +面板收到 `extraction.phase === 'done'` 后显示"抽取完成:已写入 N 条记忆"。 ## 5. 关键设计决策 @@ -180,6 +199,8 @@ sendJson(res, 200, { | 用子代理而非直接调 `mex extract` | 需求要求"触发一轮新的 agent 对话";子代理是 dsh 的隔离 agent 机制,能看到 mex 工具但看不到主对话上下文——安全且不干扰当前对话 | | prompt 参考 prompts.py 但改写成"工具指令" | prompts.py 是给 `mex extract` 用的(输出 JSON 让代码解析);本场景 agent 自主**操作工具**,所以 prompt 是"规范 + 操作步骤" | | 每轮一个对话文本 + `extractedTurns` 防重复 | 避免同一轮对话被反复抽取写脏数据 | +| 子代理保留 + `openSubagent` 打开会话视图 | 抽取过程**透明可见**:用户实时看到工具调用与思考,不再是黑盒;完成后仍可回看 | +| 异步触发(不 `await run.result`)+ `subagent/end` 完成标记 | HTTP 立即返回、不阻塞请求;完成状态由事件驱动,面板轮询展示 | | `ctx.inject(['webServer'], cb)` 等待路由注册 | bundle 插件 apply 时机可能早于 webServer 服务注册(Cordis 按 inject 依赖激活,mex 只依赖 subprocess/tools);一次性 `ctx.get('webServer')` 会拿到 undefined 导致路由永不注册、请求落到 SPA fallback(曾导致 405 空 body、前端 `Unexpected end of JSON input`)。headless 无 webServer 时回调不执行、不影响 mex 工具 | | 读 `agent.session.log` 而非 `session.events` | events 是惰性冻结快照,turn 进行中可能已被固化;log 是实时追加数组 | @@ -187,8 +208,8 @@ sendJson(res, 200, { | 接口 | 方法 | 入参 | 返回 | |---|---|---|---| -| `/mex/panel-state` | GET | `sessionId` query | `{ ok, hasNew, lastTurn, extractedTurn }` | -| `/mex/extract` | POST | body `{ sessionId }` | `{ ok, written, stopReason, error? }` | +| `/mex/panel-state` | GET | `sessionId` query | `{ ok, hasNew, lastTurn, extractedTurn, extraction }`(`extraction` 为 `{ phase: 'running'\|'done', childSessionId, written?, stopReason? }`) | +| `/mex/extract` | POST | body `{ sessionId }` | `{ ok, started, childSessionId, parentSessionId, mode: 'one-shot' }`(异步触发,立即返回) | 同源 HTTP(webServer 注册 exact route),Client half 用 fetch 调用。 @@ -196,5 +217,6 @@ sendJson(res, 200, { - 抽取以"最近一轮"为单位:点一次抽一轮,多轮未点只抽最近一轮。 - 子代理是真实 LLM 对话,判断有自由度(漏记/置信度偏差),建议定期 `mex review list` 审查 AI 推断记忆。 -- 每次点击都启动一个子代理(一次 LLM 调用);高频点击会放大成本——当前由"同一轮防重复"机制兜底。 +- 每次点击都启动一个子代理(一次 LLM 调用);高频点击会放大成本——当前由"同一轮防重复 + 运行中防重入"机制兜底。 +- 子代理保留在侧边栏目录:抽取完成后不回看也不会自动清理,长期使用目录会累积历史抽取会话(可接受;如需清理可删除对应子代理会话)。 - 未来若做"会话结束自动抽取",可复用 4.2 的 `pendingTurns` 缓存与 4.4 的 prompt,在 hook/事件回调里触发(ADR-12 留待扩展的 hook 能力)。 diff --git a/src/mex/integration/dsh/README.md b/src/mex/integration/dsh/README.md index a93ff34..c6aa86d 100644 --- a/src/mex/integration/dsh/README.md +++ b/src/mex/integration/dsh/README.md @@ -60,16 +60,24 @@ dsh web 插件自带一个浏览器 UI 面板,默认停靠在页面**右下角**: - 每轮对话(dsh 的 Turn)结束后,面板提示"本轮对话已结束,可加入记忆"; -- 点击 **Add to MeX** 按钮:插件在后台触发一轮全新的 agent 对话,自动把 +- 点击 **Add to MeX** 按钮:插件触发一轮全新的 agent 对话,自动把 **抽取记忆 prompt + 本轮对话文本**交给该 agent,由 agent 调用 `mex_add` / `mex_update` 等工具把值得记住的信息写入 meX; - 抽取 prompt 依据 `src/mex/llm/prompts.py` 的抽取规范精简而成:画像 vs 画像外区分、置信度五档(禁止 confirmed)、质量标准(具体/独立/有用/不重复)、 时间规范(具体日期),并引导 agent 先用 `mex_profile` / `mex_list` 查重。 +**透明可见(非黑盒)**:点击后面板自动打开抽取子代理的**会话视图**——用户 +实时看到抽取 agent 的思考过程与每一步工具调用(`mex_search` / `mex_add` 等), +与查看普通对话完全一致。抽取子代理**保留**在当前会话的侧边栏子代理目录中 +(运行状态圆点:进行中 → 完成),可随时点开回看这次抽取做了什么;面板同时 +显示"抽取中…"与"查看运行过程"按钮,完成后显示"已写入 N 条记忆"。 + 实现:Host half(`panel.js`)监听 `agent/turn-stopping` 事件缓存本轮对话, -注册 `/mex/panel-state`(状态轮询)与 `/mex/extract`(触发抽取)两个 HTTP 接口; -Client half(`client.js`)注册到 `shell.overlay` 浮动层并调用这两个接口。 +注册 `/mex/panel-state`(状态轮询)与 `/mex/extract`(触发抽取)两个 HTTP 接口, +并监听 `subagent/end` 标记抽取完成;Client half(`client.js`)注册到 +`shell.overlay` 浮动层并调用这两个接口,通过 `sessions.openSubagent` 打开 +子代理会话视图。 > **注意**:抽取依赖 spawn 子代理(`dsh-subagent-spawn-in-process`,dsh 默认 > 装配),且每次抽取以"最近一轮对话"为单位——多轮未点只抽取最近一轮,点一次抽一轮。 diff --git a/src/mex/integration/dsh/client.js b/src/mex/integration/dsh/client.js index 4a081ba..2b3a273 100644 --- a/src/mex/integration/dsh/client.js +++ b/src/mex/integration/dsh/client.js @@ -2,11 +2,13 @@ // // 打包产物格式(window.__ModuleLoader__.load),依赖从 dsh 的模块表解析: // - react:staticModules 静态提供; -// - slots:通过 inject 声明,运行时由 dsh-client-runtime 提供。 +// - slots / sessions:通过 inject 声明,运行时由 dsh-client-runtime 提供。 // // 面板注册到 shell.overlay(全屏浮动层,additive),默认停靠在页面右下角; -// 每轮对话结束后面板提示"可加入记忆",点击"Add to MeX"通过 HTTP 接口 -// 通知 Host 触发一轮新的抽取 agent 对话(见 index.js)。 +// 每轮对话结束后面板提示"可加入记忆"。点击"Add to MeX"通过 HTTP 接口 +// 通知 Host 触发一轮新的抽取 agent 对话(见 panel.js),随后调用 +// sessions.openSubagent 打开该子代理的会话视图——用户可实时看到抽取 +// agent 调用工具与思考的完整过程(非黑盒)。子代理保留在侧边栏目录。 window.__ModuleLoader__.load({ id: 'mex-dsh-plugin', factory: (require) => { @@ -16,13 +18,13 @@ window.__ModuleLoader__.load({ var React = require('react') - // 面板需要 slots 服务(注册 UI)。 - var inject = ['slots'] + // 面板需要 slots(注册 UI)与 sessions(打开子代理会话视图)。 + var inject = ['slots', 'sessions'] // 轮询间隔(毫秒)。 var POLL_MS = 3000 - /** 解析面板状态响应:{ hasNew, lastTurn, extractedSeq, error }。 */ + /** 解析面板状态响应:{ hasNew, lastTurn, extractedSeq, extraction, error }。 */ function parseState(data) { if (data === null || typeof data !== 'object') return null return data @@ -31,11 +33,13 @@ window.__ModuleLoader__.load({ /** * meX 面板:右下角浮动卡片。 * 从 shell.overlay 的标准 props 取当前 session(useSessions), - * 轮询 Host 的面板状态接口,显示"可加入记忆"提示与触发按钮。 + * 轮询 Host 的面板状态接口,显示"可加入记忆"提示、抽取运行状态 + * 与触发按钮。sessions 服务实例由 apply 通过闭包注入 props.sessions。 */ function MexPanel(props) { var useSessions = props.useSessions var sessionId = useSessions(function (s) { return s.current }) + var sessions = props.sessions /** 解析 JSON 响应;非 JSON(405 空 body / HTML fallback)转为结构化错误提示。 */ var parseJson = function (res) { @@ -57,10 +61,6 @@ window.__ModuleLoader__.load({ var busy = busyState[0] var setBusy = busyState[1] - var resultState = React.useState(null) - var result = resultState[0] - var setResult = resultState[1] - // 轮询面板状态:session 切换或每 POLL_MS 刷新一次。 React.useEffect(function () { var alive = true @@ -82,26 +82,44 @@ window.__ModuleLoader__.load({ } }, [sessionId]) - /** 点击 Add to MeX:通知 Host 触发抽取 agent 对话。 */ + /** 打开抽取子代理的会话视图(用户可查看工具调用与思考全程)。 */ + var openExtraction = function (childSessionId) { + if (sessions === null || sessions === undefined || !sessionId || !childSessionId) return + try { + sessions.openSubagent({ + parentSessionId: sessionId, + childSessionId: childSessionId, + mode: 'one-shot', + }) + } catch (err) { + // openSubagent 失败不影响抽取本身,静默忽略。 + } + } + + /** 点击 Add to MeX:触发抽取,并打开子代理会话视图(非黑盒)。 */ var onAddToMex = function () { if (!sessionId || busy) return setBusy(true) - setResult(null) fetch('/mex/extract', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: sessionId }), }) .then(parseJson) - .then(function (data) { setResult(data) }) - .catch(function (err) { - setResult({ ok: false, error: String(err) }) + .then(function (data) { + if (data && data.ok && data.childSessionId) { + openExtraction(data.childSessionId) + } }) + .catch(function () { /* 网络错误:轮询状态会反映真实情况 */ }) .finally(function () { setBusy(false) }) } + var extraction = panel !== null ? (panel.extraction || null) : null + var running = extraction !== null && extraction.phase === 'running' + var done = extraction !== null && extraction.phase === 'done' var hasNew = panel !== null && panel.ok === true && panel.hasNew === true - var canRun = sessionId !== undefined && !busy + var canRun = sessionId !== undefined && !busy && !running // 面板卡片:固定右下角,深色底,紧凑布局。 var cardStyle = { @@ -135,11 +153,11 @@ window.__ModuleLoader__.load({ width: '8px', height: '8px', borderRadius: '50%', - background: hasNew ? '#4ade80' : '#52525b', + background: running ? '#facc15' : hasNew ? '#4ade80' : '#52525b', } var hintStyle = { - color: hasNew ? '#bbf7d0' : '#a1a1aa', + color: running ? '#fde68a' : hasNew ? '#bbf7d0' : '#a1a1aa', lineHeight: '18px', } @@ -148,54 +166,86 @@ window.__ModuleLoader__.load({ padding: '8px 10px', border: 'none', borderRadius: '8px', - background: hasNew ? '#16a34a' : '#3f3f46', - color: hasNew ? '#ffffff' : '#a1a1aa', + background: hasNew && !running ? '#16a34a' : '#3f3f46', + color: hasNew && !running ? '#ffffff' : '#a1a1aa', cursor: canRun ? 'pointer' : 'not-allowed', fontWeight: 600, fontSize: '13px', } - var resultText = '' - if (result !== null) { - if (result.ok) { - resultText = '已写入 ' + (result.written || 0) + ' 条记忆' - if (result.notes) resultText += '(' + result.notes + ')' - } else { - resultText = '抽取失败:' + (result.error || '未知错误') - } + var viewStyle = { + width: '100%', + padding: '6px 10px', + border: '1px solid #3f3f46', + borderRadius: '8px', + background: 'transparent', + color: '#e4e4e7', + cursor: 'pointer', + fontWeight: 500, + fontSize: '12px', } - return React.createElement( - 'div', - { style: cardStyle, 'data-plugin': 'mex-dsh-plugin' }, + // 提示文本:优先运行状态,其次抽取完成,其次可抽取,兜底引导。 + var hintText + if (running) { + hintText = '抽取中…点击下方按钮查看运行过程' + } else if (done) { + hintText = '抽取完成:已写入 ' + (extraction.written || 0) + ' 条记忆' + } else if (hasNew) { + hintText = '本轮对话已结束,可加入记忆' + } else { + hintText = '对话结束后点击下方按钮抽取记忆' + } + + var children = [ React.createElement( 'div', { style: titleStyle }, React.createElement('span', { style: badgeStyle }), React.createElement('span', null, 'meX 记忆'), ), - React.createElement( - 'div', - { style: hintStyle }, - hasNew ? '本轮对话已结束,可加入记忆' : busy ? '正在抽取记忆…' : result !== null ? resultText : '对话结束后点击下方按钮抽取记忆', - ), + React.createElement('div', { style: hintStyle }, hintText), React.createElement( 'button', { style: buttonStyle, onClick: onAddToMex, disabled: !canRun }, - busy ? '抽取中…' : 'Add to MeX', + running ? '抽取中…' : 'Add to MeX', ), + ] + + // 抽取进行中:提供"查看运行过程"入口(打开子代理会话视图)。 + if (running && extraction !== null && extraction.childSessionId) { + children.push( + React.createElement( + 'button', + { + style: viewStyle, + onClick: function () { openExtraction(extraction.childSessionId) }, + }, + '查看运行过程', + ), + ) + } + + return React.createElement( + 'div', + { style: cardStyle, 'data-plugin': 'mex-dsh-plugin' }, + children, ) } /** - * Client 插件入口:注册 shell.overlay 面板。 - * @param ctx - client 根上下文(注入 slots)。 + * Client 插件入口:注册 shell.overlay 面板,并把 sessions 服务注入组件。 + * @param ctx - client 根上下文(注入 slots / sessions)。 */ function apply(ctx) { + var sessions = ctx.sessions ctx.slots.inject('shell.overlay', function () { return ctx.slots.register( { name: 'shell.overlay', id: 'mex-panel', order: 9999, label: 'meX 记忆' }, - MexPanel, + // 包一层:把 sessions 服务实例通过 props 传给面板组件。 + function (props) { + return React.createElement(MexPanel, Object.assign({}, props, { sessions: sessions })) + }, ) }) } diff --git a/src/mex/integration/dsh/panel.js b/src/mex/integration/dsh/panel.js index e3da210..f08e741 100644 --- a/src/mex/integration/dsh/panel.js +++ b/src/mex/integration/dsh/panel.js @@ -4,9 +4,13 @@ * 职责: * 1. 监听 `agent/turn-stopping`(每轮对话即将关闭)——记录该会话"最近一轮 * 已结束"的对话文本(user/assistant 消息),供面板提示与抽取使用; - * 2. 面板状态查询:client 轮询该会话是否有未抽取的已结束轮次; + * 2. 面板状态查询:client 轮询该会话是否有未抽取的已结束轮次,以及 + * 最近一次抽取的运行状态(进行中 / 已完成 / 写入条数); * 3. 触发抽取:点击"Add to MeX"后,把未抽取轮次的对话文本 + 抽取 prompt * 交给一个全新的子代理(spawn),子代理调用 mex_* 工具写入记忆。 + * 子代理**保留**(不 dispose)——它作为当前会话的 one-shot 子代理进入 + * dsh 侧边栏子代理目录,用户可点开查看调用工具与思考的完整过程; + * 抽取完成后由 `subagent/end` 事件标记,供面板展示结果。 * * 通信:通过 webServer 注册两个同源 HTTP 接口(/mex/panel-state、 * /mex/extract),client half 用 fetch 调用。webServer / subagents / @@ -39,6 +43,13 @@ const pendingTurns = new Map() /** 每会话已抽取到的最大 turn 号(内存态;重启后未抽取轮次需重新对话才会补录)。 */ const extractedTurns = new Map() +/** + * 每会话最近一次抽取的运行状态:sessionId → 状态对象。 + * - 子代理启动后写 { phase: 'running', childSessionId }; + * - subagent/end 事件到达后写 { phase: 'done', childSessionId, written, stopReason }。 + */ +const extractionState = new Map() + /** 从 ContentBlock 数组中提取纯文本。 */ function textOf(blocks) { if (!Array.isArray(blocks)) return '' @@ -152,7 +163,22 @@ export function applyMexPanel(ctx) { } }) - /** GET /mex/panel-state?sessionId=…:面板状态(是否有未抽取的已结束轮次)。 */ + // 抽取子代理结束(无论成败):按子代理 session id 关联到父会话, + // 标记该轮已抽取(防重复),并记录写入条数供面板展示。 + ctx.on('subagent/end', (info) => { + const entry = extractionState.get(info.id) + if (entry === undefined) return + const { parentSessionId, turn } = entry + extractedTurns.set(parentSessionId, turn) + extractionState.set(parentSessionId, { + phase: 'done', + childSessionId: info.id, + written: parseWritten(info.lastAssistantMessage), + stopReason: info.stopReason, + }) + }) + + /** GET /mex/panel-state?sessionId=…:面板状态(新轮次 + 抽取运行状态)。 */ const onPanelState = async (req, res) => { const sessionId = queryOf(req).sessionId if (!sessionId) { @@ -162,10 +188,17 @@ export function applyMexPanel(ctx) { const pending = pendingTurns.get(sessionId) const lastTurn = pending ? pending.turn : 0 const extracted = extractedTurns.get(sessionId) ?? 0 - sendJson(res, 200, { ok: true, hasNew: lastTurn > extracted, lastTurn, extractedTurn: extracted }) + const extraction = extractionState.get(sessionId) + sendJson(res, 200, { + ok: true, + hasNew: lastTurn > extracted, + lastTurn, + extractedTurn: extracted, + extraction: extraction ?? null, + }) } - /** POST /mex/extract body {sessionId}:触发抽取 agent 对话。 */ + /** POST /mex/extract body {sessionId}:触发抽取 agent 对话(异步,立即返回)。 */ const onExtract = async (req, res) => { const body = await readJsonBody(req) const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId : undefined @@ -178,6 +211,11 @@ export function applyMexPanel(ctx) { const pending = pendingTurns.get(sessionId) const lastTurn = pending ? pending.turn : 0 const extracted = extractedTurns.get(sessionId) ?? 0 + const running = extractionState.get(sessionId) + if (running !== undefined && running.phase === 'running') { + sendJson(res, 200, { ok: false, error: '上一轮抽取仍在进行中', childSessionId: running.childSessionId }) + return + } if (lastTurn <= extracted || !pending) { sendJson(res, 200, { ok: false, error: '没有新的已结束对话可抽取' }) return @@ -196,14 +234,16 @@ export function applyMexPanel(ctx) { signal: controller.signal, maxDepth: 3, }) - const result = await run.result - // 抽取完成(含"无新增记忆"):标记该轮已处理,避免重复触发。 - extractedTurns.set(sessionId, lastTurn) + // 记录运行状态;子代理保留(不 dispose),供用户在侧边栏目录查看全程。 + extractionState.set(sessionId, { phase: 'running', childSessionId: run.id }) + // 建立 childSessionId → 父会话关联,供 subagent/end 回调标记完成。 + extractionState.set(run.id, { parentSessionId: sessionId, turn: lastTurn }) sendJson(res, 200, { ok: true, - written: parseWritten(result && result.output), - stopReason: result && result.stopReason, - notes: result && result.stopReason === 'completed' ? undefined : '抽取未正常完成', + started: true, + childSessionId: run.id, + parentSessionId: sessionId, + mode: 'one-shot', }) } catch (error) { sendJson(res, 200, { ok: false, error: error instanceof Error ? error.message : String(error) }) From d49dd5ac6f971e14374777b91bb6716b5af58977 Mon Sep 17 00:00:00 2001 From: micklife Date: Thu, 20 Aug 2026 15:27:28 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E5=AF=B9=E8=AF=9D=E8=BF=9B=E8=A1=8C?= =?UTF-8?q?=E4=B8=AD=20Add=20to=20MeX=20=E6=8C=89=E9=92=AE=E8=AF=AF?= =?UTF-8?q?=E4=BA=AE=E8=B5=B7=E2=80=94=E2=80=94agent=20running=20=E6=97=B6?= =?UTF-8?q?=E9=97=A8=E6=8E=A7=20hasNew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:panel-state 的 hasNew 只比较缓存轮次 > 已抽取轮次,未检查 agent 运行状态。对话进行中(等待模型输出 / 执行工具,如 deep diving)时 pendingTurns 仍留着上一轮数据,按钮一直亮着,用户可能误以为本轮已结束。 修复: - panel.js:panel-state 增加 agentRunning 字段,agent.status==='running' 时 hasNew 恒为 false;extract 接口同样拒绝 running 中的抽取请求; - client.js:canRun 增加 !agentRunning 门控,按钮禁用、提示文案改为 '对话进行中,本轮结束后可加入记忆'; - 文档:dsh-integration.md 记录该门控设计。 --- docs/dsh-integration.md | 3 ++- src/mex/integration/dsh/client.js | 10 ++++++++-- src/mex/integration/dsh/panel.js | 13 ++++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md index ae68c52..c4b4e99 100644 --- a/docs/dsh-integration.md +++ b/docs/dsh-integration.md @@ -199,6 +199,7 @@ ctx.on('subagent/end', (info) => { | 用子代理而非直接调 `mex extract` | 需求要求"触发一轮新的 agent 对话";子代理是 dsh 的隔离 agent 机制,能看到 mex 工具但看不到主对话上下文——安全且不干扰当前对话 | | prompt 参考 prompts.py 但改写成"工具指令" | prompts.py 是给 `mex extract` 用的(输出 JSON 让代码解析);本场景 agent 自主**操作工具**,所以 prompt 是"规范 + 操作步骤" | | 每轮一个对话文本 + `extractedTurns` 防重复 | 避免同一轮对话被反复抽取写脏数据 | +| `agent.status === 'running'` 门控 `hasNew` | 对话进行中(等待模型输出 / 执行工具,如 deep diving)**不算**"已结束的新轮次"——此时 `hasNew=false`、按钮禁用、`extract` 接口拒绝,避免用户在对话未结束时误触发抽取 | | 子代理保留 + `openSubagent` 打开会话视图 | 抽取过程**透明可见**:用户实时看到工具调用与思考,不再是黑盒;完成后仍可回看 | | 异步触发(不 `await run.result`)+ `subagent/end` 完成标记 | HTTP 立即返回、不阻塞请求;完成状态由事件驱动,面板轮询展示 | | `ctx.inject(['webServer'], cb)` 等待路由注册 | bundle 插件 apply 时机可能早于 webServer 服务注册(Cordis 按 inject 依赖激活,mex 只依赖 subprocess/tools);一次性 `ctx.get('webServer')` 会拿到 undefined 导致路由永不注册、请求落到 SPA fallback(曾导致 405 空 body、前端 `Unexpected end of JSON input`)。headless 无 webServer 时回调不执行、不影响 mex 工具 | @@ -208,7 +209,7 @@ ctx.on('subagent/end', (info) => { | 接口 | 方法 | 入参 | 返回 | |---|---|---|---| -| `/mex/panel-state` | GET | `sessionId` query | `{ ok, hasNew, lastTurn, extractedTurn, extraction }`(`extraction` 为 `{ phase: 'running'\|'done', childSessionId, written?, stopReason? }`) | +| `/mex/panel-state` | GET | `sessionId` query | `{ ok, hasNew, agentRunning, lastTurn, extractedTurn, extraction }`(`agentRunning`:当前对话是否进行中——进行中 `hasNew` 恒为 `false`;`extraction` 为 `{ phase: 'running'\|'done', childSessionId, written?, stopReason? }`) | | `/mex/extract` | POST | body `{ sessionId }` | `{ ok, started, childSessionId, parentSessionId, mode: 'one-shot' }`(异步触发,立即返回) | 同源 HTTP(webServer 注册 exact route),Client half 用 fetch 调用。 diff --git a/src/mex/integration/dsh/client.js b/src/mex/integration/dsh/client.js index 2b3a273..8d95027 100644 --- a/src/mex/integration/dsh/client.js +++ b/src/mex/integration/dsh/client.js @@ -118,8 +118,11 @@ window.__ModuleLoader__.load({ var extraction = panel !== null ? (panel.extraction || null) : null var running = extraction !== null && extraction.phase === 'running' var done = extraction !== null && extraction.phase === 'done' + // agentRunning:当前对话正在进行(等待模型输出 / 执行工具), + // 本轮尚未结束,按钮不可用、不算"有新轮次可抽取"。 + var agentRunning = panel !== null && panel.ok === true && panel.agentRunning === true var hasNew = panel !== null && panel.ok === true && panel.hasNew === true - var canRun = sessionId !== undefined && !busy && !running + var canRun = sessionId !== undefined && !busy && !running && !agentRunning // 面板卡片:固定右下角,深色底,紧凑布局。 var cardStyle = { @@ -185,10 +188,13 @@ window.__ModuleLoader__.load({ fontSize: '12px', } - // 提示文本:优先运行状态,其次抽取完成,其次可抽取,兜底引导。 + // 提示文本:优先抽取运行状态,其次对话进行中,其次抽取完成, + // 再次可抽取,兜底引导。 var hintText if (running) { hintText = '抽取中…点击下方按钮查看运行过程' + } else if (agentRunning) { + hintText = '对话进行中,本轮结束后可加入记忆' } else if (done) { hintText = '抽取完成:已写入 ' + (extraction.written || 0) + ' 条记忆' } else if (hasNew) { diff --git a/src/mex/integration/dsh/panel.js b/src/mex/integration/dsh/panel.js index f08e741..9b91685 100644 --- a/src/mex/integration/dsh/panel.js +++ b/src/mex/integration/dsh/panel.js @@ -189,9 +189,15 @@ export function applyMexPanel(ctx) { const lastTurn = pending ? pending.turn : 0 const extracted = extractedTurns.get(sessionId) ?? 0 const extraction = extractionState.get(sessionId) + // 对话进行中(等待模型输出 / 执行工具)不算"已结束的新轮次": + // hasNew 仅在 agent 空闲且存在未抽取的已结束轮次时为 true。 + const agents = ctx.get('agents') + const parent = agents === undefined ? undefined : agents.get(sessionId) + const agentRunning = parent !== undefined && parent.status === 'running' sendJson(res, 200, { ok: true, - hasNew: lastTurn > extracted, + hasNew: lastTurn > extracted && !agentRunning, + agentRunning, lastTurn, extractedTurn: extracted, extraction: extraction ?? null, @@ -225,6 +231,11 @@ export function applyMexPanel(ctx) { sendJson(res, 200, { ok: false, error: '会话未激活(agent 不在运行中)' }) return } + // 对话进行中(等待模型输出 / 执行工具):本轮尚未结束,拒绝抽取。 + if (parent.status === 'running') { + sendJson(res, 200, { ok: false, error: '对话仍在进行中,请等本轮结束后再抽取' }) + return + } const controller = new AbortController() try { const run = await subagents.start('spawn', { From d1cf2e92af0215343846d8a0d4157d781d5e359e Mon Sep 17 00:00:00 2001 From: micklife Date: Thu, 20 Aug 2026 15:29:41 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20meX=20=E9=9D=A2=E6=9D=BF=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E8=B7=9F=E9=9A=8F=20dsh=20=E4=B8=BB=E9=A2=98=EF=BC=88?= =?UTF-8?q?=E6=B7=B1=E8=89=B2/=E6=B5=85=E8=89=B2/=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:client.js 面板使用硬编码深色背景(rgba(24,24,27,0.92)), 浅色主题下仍是深色卡片,与 dsh 主题不符。 修复:面板全部颜色改用 dsh 主题 token(CSS 变量): - 背景 var(--dsw-alias-bg-overlay)、文字 label-primary/secondary、 边框 border-l1、成功绿 state-success-primary、警示黄 state-warn-primary、 禁用背景 bg-layer-1、阴影 shadow-lv3(fallback); 主题系统在 body[data-ds-dark-theme] 上按偏好解析变量,面板位于 shell.overlay(body 内),颜色自动跟随三种主题,无需感知主题偏好。 文档:dsh-integration.md 新增 §4.0 主题适配;README 同步。 --- docs/dsh-integration.md | 19 +++++++++++++++++ src/mex/integration/dsh/README.md | 3 ++- src/mex/integration/dsh/client.js | 34 ++++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md index c4b4e99..c24d91a 100644 --- a/docs/dsh-integration.md +++ b/docs/dsh-integration.md @@ -50,6 +50,25 @@ ## 4. 触发链路逐步说明 +### 4.0 主题适配(深色 / 浅色 / 跟随系统) + +面板颜色**不使用硬编码色值**,全部引用 dsh 的主题 token(CSS 变量): + +| 用途 | token | +|---|---| +| 卡片背景 | `var(--dsw-alias-bg-overlay)` | +| 主文字 | `var(--dsw-alias-label-primary)` | +| 次要/禁用文字 | `var(--dsw-alias-label-secondary)` | +| 边框 | `var(--dsw-alias-border-l1)` | +| 运行中徽标/提示(黄) | `var(--dsw-alias-state-warn-primary)` | +| 可抽取徽标/提示/按钮(绿) | `var(--dsw-alias-state-success-primary)` | +| 禁用按钮背景 | `var(--dsw-alias-bg-layer-1)` | +| 阴影 | `var(--dsw-shadow-lv3, 0 8px 24px rgba(0,0,0,0.28))`(fallback) | + +dsh 的主题系统(ui-theme)在 `body[data-ds-dark-theme]` 上按当前偏好(深色 / 浅色 / +跟随系统)解析这些变量的值,面板位于 shell.overlay(body 内),因此颜色**自动跟随 +三种主题**,无需插件感知主题偏好或监听主题切换事件。 + ### 4.1 点击按钮 → 浏览器发请求(client.js) `onAddToMex` 只把**当前会话 id** 发给 Host,对话内容本身不随请求传输: diff --git a/src/mex/integration/dsh/README.md b/src/mex/integration/dsh/README.md index c6aa86d..0b76811 100644 --- a/src/mex/integration/dsh/README.md +++ b/src/mex/integration/dsh/README.md @@ -77,7 +77,8 @@ dsh web 注册 `/mex/panel-state`(状态轮询)与 `/mex/extract`(触发抽取)两个 HTTP 接口, 并监听 `subagent/end` 标记抽取完成;Client half(`client.js`)注册到 `shell.overlay` 浮动层并调用这两个接口,通过 `sessions.openSubagent` 打开 -子代理会话视图。 +子代理会话视图。面板颜色全部使用 dsh 主题 token(`--dsw-alias-*`),自动 +跟随深色 / 浅色 / 跟随系统三种主题。 > **注意**:抽取依赖 spawn 子代理(`dsh-subagent-spawn-in-process`,dsh 默认 > 装配),且每次抽取以"最近一轮对话"为单位——多轮未点只抽取最近一轮,点一次抽一轮。 diff --git a/src/mex/integration/dsh/client.js b/src/mex/integration/dsh/client.js index 8d95027..2637dcb 100644 --- a/src/mex/integration/dsh/client.js +++ b/src/mex/integration/dsh/client.js @@ -124,7 +124,8 @@ window.__ModuleLoader__.load({ var hasNew = panel !== null && panel.ok === true && panel.hasNew === true var canRun = sessionId !== undefined && !busy && !running && !agentRunning - // 面板卡片:固定右下角,深色底,紧凑布局。 + // 面板卡片:固定右下角,使用 dsh 主题 token(CSS 变量), + // 自动跟随深色/浅色/跟随系统三种主题。 var cardStyle = { position: 'fixed', right: '16px', @@ -136,11 +137,12 @@ window.__ModuleLoader__.load({ width: '240px', padding: '12px', borderRadius: '12px', - background: 'rgba(24, 24, 27, 0.92)', - color: '#e4e4e7', + background: 'var(--dsw-alias-bg-overlay)', + color: 'var(--dsw-alias-label-primary)', fontFamily: 'system-ui, sans-serif', fontSize: '13px', - boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)', + boxShadow: 'var(--dsw-shadow-lv3, 0 8px 24px rgba(0, 0, 0, 0.28))', + border: '1px solid var(--dsw-alias-border-l1)', pointerEvents: 'auto', } @@ -156,11 +158,19 @@ window.__ModuleLoader__.load({ width: '8px', height: '8px', borderRadius: '50%', - background: running ? '#facc15' : hasNew ? '#4ade80' : '#52525b', + background: running + ? 'var(--dsw-alias-state-warn-primary)' + : hasNew + ? 'var(--dsw-alias-state-success-primary)' + : 'var(--dsw-alias-label-secondary)', } var hintStyle = { - color: running ? '#fde68a' : hasNew ? '#bbf7d0' : '#a1a1aa', + color: running + ? 'var(--dsw-alias-state-warn-primary)' + : hasNew + ? 'var(--dsw-alias-state-success-primary)' + : 'var(--dsw-alias-label-secondary)', lineHeight: '18px', } @@ -169,8 +179,12 @@ window.__ModuleLoader__.load({ padding: '8px 10px', border: 'none', borderRadius: '8px', - background: hasNew && !running ? '#16a34a' : '#3f3f46', - color: hasNew && !running ? '#ffffff' : '#a1a1aa', + background: hasNew && !running + ? 'var(--dsw-alias-state-success-primary)' + : 'var(--dsw-alias-bg-layer-1)', + color: hasNew && !running + ? 'var(--dsw-alias-bg-base)' + : 'var(--dsw-alias-label-secondary)', cursor: canRun ? 'pointer' : 'not-allowed', fontWeight: 600, fontSize: '13px', @@ -179,10 +193,10 @@ window.__ModuleLoader__.load({ var viewStyle = { width: '100%', padding: '6px 10px', - border: '1px solid #3f3f46', + border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', background: 'transparent', - color: '#e4e4e7', + color: 'var(--dsw-alias-label-primary)', cursor: 'pointer', fontWeight: 500, fontSize: '12px', From 8b81e267cecdf10074a226d41ef0d1e9303bb4cc Mon Sep 17 00:00:00 2001 From: micklife Date: Thu, 20 Aug 2026 15:35:05 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=E9=9D=A2=E6=9D=BF=E9=85=8D?= =?UTF-8?q?=E8=89=B2=E5=BE=AE=E8=B0=83=EF=BC=88=E6=B5=85=E8=89=B2=E6=9B=B4?= =?UTF-8?q?=E6=B5=85+=E7=BB=BF=E5=AD=97=E5=8A=A0=E6=B7=B1=EF=BC=89+=20?= =?UTF-8?q?=E5=8F=AF=E6=8B=96=E5=8A=A8=E6=B5=AE=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配色:按 body[data-ds-dark-theme] 区分主题(MutationObserver 监听 切换),深色用 dsh token,浅色微调——背景 #f5f6f8(非常浅灰,比 bg-overlay 更浅)、提示绿 #15803d(比 state-success-primary 更深、 浅底可读)、主文字 #1f2328、次要文字 #6b7280; - 拖拽:标题栏(⣿ 手柄)cursor: move,mousedown/mousemove/mouseup 拖动,位置持久化 localStorage['mex-panel-pos'],未拖拽默认右下角; - 文档:dsh-integration.md §4.0 重写为主题适配+拖拽,README 同步。 --- docs/dsh-integration.md | 46 ++++++---- src/mex/integration/dsh/README.md | 5 +- src/mex/integration/dsh/client.js | 135 ++++++++++++++++++++++++------ 3 files changed, 140 insertions(+), 46 deletions(-) diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md index c24d91a..34eac8b 100644 --- a/docs/dsh-integration.md +++ b/docs/dsh-integration.md @@ -50,24 +50,34 @@ ## 4. 触发链路逐步说明 -### 4.0 主题适配(深色 / 浅色 / 跟随系统) - -面板颜色**不使用硬编码色值**,全部引用 dsh 的主题 token(CSS 变量): - -| 用途 | token | -|---|---| -| 卡片背景 | `var(--dsw-alias-bg-overlay)` | -| 主文字 | `var(--dsw-alias-label-primary)` | -| 次要/禁用文字 | `var(--dsw-alias-label-secondary)` | -| 边框 | `var(--dsw-alias-border-l1)` | -| 运行中徽标/提示(黄) | `var(--dsw-alias-state-warn-primary)` | -| 可抽取徽标/提示/按钮(绿) | `var(--dsw-alias-state-success-primary)` | -| 禁用按钮背景 | `var(--dsw-alias-bg-layer-1)` | -| 阴影 | `var(--dsw-shadow-lv3, 0 8px 24px rgba(0,0,0,0.28))`(fallback) | - -dsh 的主题系统(ui-theme)在 `body[data-ds-dark-theme]` 上按当前偏好(深色 / 浅色 / -跟随系统)解析这些变量的值,面板位于 shell.overlay(body 内),因此颜色**自动跟随 -三种主题**,无需插件感知主题偏好或监听主题切换事件。 +### 4.0 主题适配(深色 / 浅色 / 跟随系统)与面板拖拽 + +**主题适配**:面板通过 `document.body.hasAttribute('data-ds-dark-theme')` 判断 +当前实际主题(dsh 主题系统已按偏好——深色 / 浅色 / 跟随系统——归一化该属性), +并用 `MutationObserver` 监听属性变化(主题切换时即时重渲染)。 + +- **深色主题**:全部使用 dsh 主题 token(CSS 变量),与官方 UI 一致: + + | 用途 | token | + |---|---| + | 卡片背景 | `var(--dsw-alias-bg-overlay)` | + | 主文字 | `var(--dsw-alias-label-primary)` | + | 次要/禁用文字 | `var(--dsw-alias-label-secondary)` | + | 边框 | `var(--dsw-alias-border-l1)` | + | 运行中徽标/提示(黄) | `var(--dsw-alias-state-warn-primary)` | + | 可抽取徽标/提示/按钮(绿) | `var(--dsw-alias-state-success-primary)` | + | 禁用按钮背景 | `var(--dsw-alias-bg-layer-1)` | + | 阴影 | `var(--dsw-shadow-lv3, fallback)` | + +- **浅色主题**(微调,保证可读性):背景用**非常浅的灰** `#f5f6f8`(比 token + 的 `bg-overlay` 更浅);"可加入记忆"的绿色提示加深为 `#15803d`(token 的 + `state-success-primary` 在浅底上偏亮、对比不足);主文字 `#1f2328`、次要文字 + `#6b7280`、边框 `#e3e6ea`、禁用背景 `#e8eaed`。 + +**面板拖拽**:标题栏(含"⣿"手柄图标)`cursor: move`,按下后跟随鼠标移动 +(`mousemove`/`mouseup` 监听),面板位置改为 `left/top` 固定定位;释放时把 +位置写入 `localStorage['mex-panel-pos']`,下次打开自动恢复。未拖拽时默认 +停靠右下角(`right: 16px; bottom: 16px`)。 ### 4.1 点击按钮 → 浏览器发请求(client.js) diff --git a/src/mex/integration/dsh/README.md b/src/mex/integration/dsh/README.md index 0b76811..e6adb47 100644 --- a/src/mex/integration/dsh/README.md +++ b/src/mex/integration/dsh/README.md @@ -77,8 +77,9 @@ dsh web 注册 `/mex/panel-state`(状态轮询)与 `/mex/extract`(触发抽取)两个 HTTP 接口, 并监听 `subagent/end` 标记抽取完成;Client half(`client.js`)注册到 `shell.overlay` 浮动层并调用这两个接口,通过 `sessions.openSubagent` 打开 -子代理会话视图。面板颜色全部使用 dsh 主题 token(`--dsw-alias-*`),自动 -跟随深色 / 浅色 / 跟随系统三种主题。 +子代理会话视图。面板是**可拖动浮窗**(标题栏拖拽,位置记忆在 localStorage, +默认停靠右下角);颜色跟随 dsh 主题——深色用主题 token,浅色微调为更浅的 +灰底与更深绿提示,保证可读性。 > **注意**:抽取依赖 spawn 子代理(`dsh-subagent-spawn-in-process`,dsh 默认 > 装配),且每次抽取以"最近一轮对话"为单位——多轮未点只抽取最近一轮,点一次抽一轮。 diff --git a/src/mex/integration/dsh/client.js b/src/mex/integration/dsh/client.js index 2637dcb..0e90a12 100644 --- a/src/mex/integration/dsh/client.js +++ b/src/mex/integration/dsh/client.js @@ -61,6 +61,84 @@ window.__ModuleLoader__.load({ var busy = busyState[0] var setBusy = busyState[1] + // 主题检测:body[data-ds-dark-theme] 由 dsh 主题系统按偏好 + // (深色/浅色/跟随系统)解析,跟随系统时也已归一化。 + var darkState = React.useState(false) + var isDark = darkState[0] + var setIsDark = darkState[1] + + // 面板位置:null 表示默认右下角;拖动后为 { left, top },持久化到 localStorage。 + var posState = React.useState(null) + var position = posState[0] + var setPosition = posState[1] + var dragRef = React.useRef(null) + + React.useEffect(function () { + var detect = function () { + try { + setIsDark(document.body.hasAttribute('data-ds-dark-theme')) + } catch (err) { + setIsDark(false) + } + } + detect() + var observer = null + try { + observer = new MutationObserver(detect) + observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] }) + } catch (err) { /* 无 MutationObserver 时只检测一次 */ } + return function () { + if (observer !== null) observer.disconnect() + } + }, []) + + // 恢复上次拖动位置(localStorage 持久化)。 + React.useEffect(function () { + try { + var raw = window.localStorage.getItem('mex-panel-pos') + if (raw) { + var saved = JSON.parse(raw) + if (typeof saved.left === 'number' && typeof saved.top === 'number') { + setPosition({ left: saved.left, top: saved.top }) + } + } + } catch (err) { /* 无 localStorage 时忽略 */ } + }, []) + + /** 拖拽面板:标题栏按下 → 跟随鼠标移动 → 释放保存位置。 */ + var onTitleMouseDown = function (e) { + if (e.button !== 0) return + e.preventDefault() + var startX = e.clientX + var startY = e.clientY + var startLeft = position !== null ? position.left : window.innerWidth - 240 - 16 + var startTop = position !== null ? position.top : window.innerHeight - 200 + dragRef.current = { startX, startY, startLeft, startTop } + var onMove = function (ev) { + var d = dragRef.current + if (d === null || d === undefined) return + setPosition({ + left: Math.max(0, d.startLeft + ev.clientX - d.startX), + top: Math.max(0, d.startTop + ev.clientY - d.startY), + }) + } + var onUp = function () { + dragRef.current = null + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + // 保存位置(读取最新 state 不可靠,这里从 DOM 计算) + try { + var el = document.querySelector('[data-plugin="mex-dsh-plugin"]') + if (el) { + var rect = el.getBoundingClientRect() + window.localStorage.setItem('mex-panel-pos', JSON.stringify({ left: rect.left, top: rect.top })) + } + } catch (err) { /* 忽略 */ } + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + } + // 轮询面板状态:session 切换或每 POLL_MS 刷新一次。 React.useEffect(function () { var alive = true @@ -124,12 +202,19 @@ window.__ModuleLoader__.load({ var hasNew = panel !== null && panel.ok === true && panel.hasNew === true var canRun = sessionId !== undefined && !busy && !running && !agentRunning - // 面板卡片:固定右下角,使用 dsh 主题 token(CSS 变量), - // 自动跟随深色/浅色/跟随系统三种主题。 + // 配色:深色主题用 dsh token(自动跟随);浅色主题微调—— + // 背景用非常浅的灰,成功绿加深保证可读性。 + var cardBg = isDark ? 'var(--dsw-alias-bg-overlay)' : '#f5f6f8' + var labelMain = isDark ? 'var(--dsw-alias-label-primary)' : '#1f2328' + var labelSecondary = isDark ? 'var(--dsw-alias-label-secondary)' : '#6b7280' + var borderColor = isDark ? 'var(--dsw-alias-border-l1)' : '#e3e6ea' + var successColor = isDark ? 'var(--dsw-alias-state-success-primary)' : '#15803d' + var warnColor = isDark ? 'var(--dsw-alias-state-warn-primary)' : '#b45309' + var disabledBg = isDark ? 'var(--dsw-alias-bg-layer-1)' : '#e8eaed' + + // 面板卡片:默认右下角;用户拖动后固定到指定位置。 var cardStyle = { position: 'fixed', - right: '16px', - bottom: '16px', zIndex: 9999, display: 'flex', flexDirection: 'column', @@ -137,13 +222,21 @@ window.__ModuleLoader__.load({ width: '240px', padding: '12px', borderRadius: '12px', - background: 'var(--dsw-alias-bg-overlay)', - color: 'var(--dsw-alias-label-primary)', + background: cardBg, + color: labelMain, fontFamily: 'system-ui, sans-serif', fontSize: '13px', boxShadow: 'var(--dsw-shadow-lv3, 0 8px 24px rgba(0, 0, 0, 0.28))', - border: '1px solid var(--dsw-alias-border-l1)', + border: '1px solid ' + borderColor, pointerEvents: 'auto', + userSelect: 'none', + } + if (position !== null) { + cardStyle.left = position.left + 'px' + cardStyle.top = position.top + 'px' + } else { + cardStyle.right = '16px' + cardStyle.bottom = '16px' } var titleStyle = { @@ -152,25 +245,18 @@ window.__ModuleLoader__.load({ gap: '6px', fontWeight: 600, fontSize: '14px', + cursor: 'move', } var badgeStyle = { width: '8px', height: '8px', borderRadius: '50%', - background: running - ? 'var(--dsw-alias-state-warn-primary)' - : hasNew - ? 'var(--dsw-alias-state-success-primary)' - : 'var(--dsw-alias-label-secondary)', + background: running ? warnColor : hasNew ? successColor : labelSecondary, } var hintStyle = { - color: running - ? 'var(--dsw-alias-state-warn-primary)' - : hasNew - ? 'var(--dsw-alias-state-success-primary)' - : 'var(--dsw-alias-label-secondary)', + color: running ? warnColor : hasNew ? successColor : labelSecondary, lineHeight: '18px', } @@ -179,12 +265,8 @@ window.__ModuleLoader__.load({ padding: '8px 10px', border: 'none', borderRadius: '8px', - background: hasNew && !running - ? 'var(--dsw-alias-state-success-primary)' - : 'var(--dsw-alias-bg-layer-1)', - color: hasNew && !running - ? 'var(--dsw-alias-bg-base)' - : 'var(--dsw-alias-label-secondary)', + background: hasNew && !running ? successColor : disabledBg, + color: hasNew && !running ? '#ffffff' : labelSecondary, cursor: canRun ? 'pointer' : 'not-allowed', fontWeight: 600, fontSize: '13px', @@ -193,10 +275,10 @@ window.__ModuleLoader__.load({ var viewStyle = { width: '100%', padding: '6px 10px', - border: '1px solid var(--dsw-alias-border-l1)', + border: '1px solid ' + borderColor, borderRadius: '8px', background: 'transparent', - color: 'var(--dsw-alias-label-primary)', + color: labelMain, cursor: 'pointer', fontWeight: 500, fontSize: '12px', @@ -220,9 +302,10 @@ window.__ModuleLoader__.load({ var children = [ React.createElement( 'div', - { style: titleStyle }, + { style: titleStyle, onMouseDown: onTitleMouseDown, title: '拖动面板' }, React.createElement('span', { style: badgeStyle }), React.createElement('span', null, 'meX 记忆'), + React.createElement('span', { style: { color: labelSecondary, fontWeight: 400, fontSize: '11px', marginLeft: 'auto' } }, '⣿'), ), React.createElement('div', { style: hintStyle }, hintText), React.createElement( From f6988120fb509f8c973a21002e450ca29c52fe2b Mon Sep 17 00:00:00 2001 From: micklife Date: Thu, 20 Aug 2026 15:37:17 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E8=83=8C=E6=99=AF=E5=8A=A0=E6=B7=B1=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=94=B9=E7=94=A8=20--dsw-specific-menu=EF=BC=88bg-la?= =?UTF-8?q?yer-3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 深色背景从 var(--dsw-alias-bg-overlay)(neutral-bluish-700, rgb(97,102,107) 中灰)改为 var(--dsw-specific-menu)(bg-layer-3 = neutral-bluish-800,rgb(53,54,56) 更深),与 dsh 官方弹层/菜单一致, 仍走主题 token 体系。 --- docs/dsh-integration.md | 2 +- src/mex/integration/dsh/client.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md index 34eac8b..870f28e 100644 --- a/docs/dsh-integration.md +++ b/docs/dsh-integration.md @@ -60,7 +60,7 @@ | 用途 | token | |---|---| - | 卡片背景 | `var(--dsw-alias-bg-overlay)` | + | 卡片背景 | `var(--dsw-specific-menu)`(= `bg-layer-3` = `neutral-bluish-800`,`rgb(53,54,56)`,比 `bg-overlay` 更深,与官方弹层/菜单一致) | | 主文字 | `var(--dsw-alias-label-primary)` | | 次要/禁用文字 | `var(--dsw-alias-label-secondary)` | | 边框 | `var(--dsw-alias-border-l1)` | diff --git a/src/mex/integration/dsh/client.js b/src/mex/integration/dsh/client.js index 0e90a12..43bd15c 100644 --- a/src/mex/integration/dsh/client.js +++ b/src/mex/integration/dsh/client.js @@ -204,7 +204,10 @@ window.__ModuleLoader__.load({ // 配色:深色主题用 dsh token(自动跟随);浅色主题微调—— // 背景用非常浅的灰,成功绿加深保证可读性。 - var cardBg = isDark ? 'var(--dsw-alias-bg-overlay)' : '#f5f6f8' + // 深色主题背景用 --dsw-specific-menu(= bg-layer-3 = neutral-bluish-800, + // rgb(53,54,56),比 bg-overlay 更深,与官方弹层/菜单一致); + // 浅色主题用非常浅的灰。 + var cardBg = isDark ? 'var(--dsw-specific-menu)' : '#f5f6f8' var labelMain = isDark ? 'var(--dsw-alias-label-primary)' : '#1f2328' var labelSecondary = isDark ? 'var(--dsw-alias-label-secondary)' : '#6b7280' var borderColor = isDark ? 'var(--dsw-alias-border-l1)' : '#e3e6ea'