dsh-plugin-choice-refresh 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +852 -0
- package/lib/index.js +31 -0
- package/package.json +61 -0
- package/scripts/selfcheck.mjs +132 -0
- package/scripts/smoke-client.mjs +151 -0
- package//350/256/276/350/256/241/350/257/264/346/230/216.md +93 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-choice-refresh — 选择增强插件(服务端半边)
|
|
3
|
+
*
|
|
4
|
+
* 功能:在 ask_user_question / ask_user_choice 的选择卡上提供两个增强按钮:
|
|
5
|
+
* 1. 重新生成选项(refresh):用户对当前选项都不满意时,一键让模型换一批选项;
|
|
6
|
+
* 2. 更多选项(more):选项太少时,一键让模型在保留原选项的基础上补充更多。
|
|
7
|
+
*
|
|
8
|
+
* 实现完全在客户端:按钮点击后
|
|
9
|
+
* 1) 通过 pending.cancel() 以「用户关闭」解除当前待答问题(与原生 UI 的
|
|
10
|
+
* 「放弃整组问题」同一答案协议);
|
|
11
|
+
* 2) 通过 session.prompt(content, 'steer') 向 agent 注入一条结构化的
|
|
12
|
+
* 用户消息(【系统 · 选项刷新/更多选项】),模型随即重新调用
|
|
13
|
+
* ask_user_question / ask_user_choice 提问。
|
|
14
|
+
* 不注册任何工具、不新增路由、不改核心包;对原生 ask_user_question 与
|
|
15
|
+
* dsh-plugin-image-tools 的 ask_user_choice(含图片标记)都生效。
|
|
16
|
+
*
|
|
17
|
+
* 服务端半边因此为空实现:插件以「自带 bundle patch」方式挂载进 profile,
|
|
18
|
+
* 客户端半边由 dsh.client 声明经 /plugins/<id>/client.js 送达浏览器。
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-plugin-choice-refresh
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const name = 'dsh-plugin-choice-refresh'
|
|
24
|
+
|
|
25
|
+
/** 服务端不依赖任何宿主服务(所有逻辑在客户端半边)。 */
|
|
26
|
+
export const inject = []
|
|
27
|
+
|
|
28
|
+
/** 插件入口:空实现。详见文件头注释。 */
|
|
29
|
+
export function apply() {
|
|
30
|
+
// 无服务端注册项:刷新协议是纯客户端交互(cancel + steer prompt)。
|
|
31
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-choice-refresh",
|
|
3
|
+
"description": "DSH 选择增强插件:在 ask_user_question / ask_user_choice 的选择卡上提供「重新生成选项」(都不满意一键刷新)与「更多选项」(选项太少一键补充)按钮。纯前端交互实现(取消当前问题 + steer 提示模型重新提问),不改核心包、不新增工具。",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"scripts",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"设计说明.md"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/Pasumao/dsh-plugin-choice-refresh.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/Pasumao/dsh-plugin-choice-refresh",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/Pasumao/dsh-plugin-choice-refresh/issues"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"selfcheck": "node scripts/selfcheck.mjs",
|
|
30
|
+
"smoke": "node scripts/selfcheck.mjs && node scripts/smoke-client.mjs",
|
|
31
|
+
"pack": "npm pack"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"dsh",
|
|
41
|
+
"dsh-plugin",
|
|
42
|
+
"deepseek",
|
|
43
|
+
"harness",
|
|
44
|
+
"cordis",
|
|
45
|
+
"plugin",
|
|
46
|
+
"choice",
|
|
47
|
+
"refresh",
|
|
48
|
+
"regenerate",
|
|
49
|
+
"options"
|
|
50
|
+
],
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"dsh": {
|
|
53
|
+
"bundle": {
|
|
54
|
+
"patch": "./cordis.patch.yml"
|
|
55
|
+
},
|
|
56
|
+
"client": {
|
|
57
|
+
"inject": [],
|
|
58
|
+
"platform": "web"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-choice-refresh 自检:对 lib/client.js 导出的纯函数做离线断言。
|
|
3
|
+
* 运行:node scripts/selfcheck.mjs
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { strict as assert } from 'node:assert'
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url)
|
|
12
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
13
|
+
const profileRequire = createRequire(join('C:/Users/18303/.dsh/profiles/node_modules', 'noop.cjs'))
|
|
14
|
+
const reactRoot = dirname(profileRequire.resolve('react'))
|
|
15
|
+
const reactModule = profileRequire('react')
|
|
16
|
+
|
|
17
|
+
// 1) 模拟浏览器模块加载器并物化 factory
|
|
18
|
+
let spec = null
|
|
19
|
+
globalThis.window = {
|
|
20
|
+
__ModuleLoader__: { load: (value) => { spec = value } },
|
|
21
|
+
}
|
|
22
|
+
const code = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8')
|
|
23
|
+
new Function(code)()
|
|
24
|
+
assert.ok(spec !== null, 'module loader not invoked')
|
|
25
|
+
assert.equal(spec.id, 'dsh-plugin-choice-refresh')
|
|
26
|
+
|
|
27
|
+
const shimRequire = (name) => {
|
|
28
|
+
if (name === 'react/jsx-runtime') return require(join(reactRoot, 'jsx-runtime.js'))
|
|
29
|
+
if (name === 'react') return reactModule
|
|
30
|
+
throw new Error(`unexpected require: ${name}`)
|
|
31
|
+
}
|
|
32
|
+
const mod = spec.factory(shimRequire)
|
|
33
|
+
assert.equal(typeof mod.apply, 'function')
|
|
34
|
+
assert.deepEqual(mod.inject, ['slots', 'locale', 'sessions', 'remote'])
|
|
35
|
+
assert.equal(typeof mod.parseMarker, 'function')
|
|
36
|
+
assert.equal(typeof mod.isPlanReviewBatch, 'function')
|
|
37
|
+
assert.equal(typeof mod.buildSteerText, 'function')
|
|
38
|
+
assert.equal(typeof mod.selectChoice, 'function')
|
|
39
|
+
assert.equal(typeof mod.ChoiceComposer, 'function')
|
|
40
|
+
|
|
41
|
+
let passed = 0
|
|
42
|
+
function ok(name, fn) {
|
|
43
|
+
fn()
|
|
44
|
+
passed += 1
|
|
45
|
+
console.log(` ok ${name}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log('[dsh-plugin-choice-refresh] selfcheck')
|
|
49
|
+
|
|
50
|
+
// --- parseMarker(与 image-tools 标记格式互通) ---
|
|
51
|
+
function encodeBase64Url(json) {
|
|
52
|
+
return Buffer.from(json, 'utf8').toString('base64url')
|
|
53
|
+
}
|
|
54
|
+
ok('parseMarker round-trip', () => {
|
|
55
|
+
const marker = '<!--dsh-pick:v1:' + encodeBase64Url(JSON.stringify({ pickId: 'pick-9', images: [0, 2] })) + '-->'
|
|
56
|
+
assert.deepEqual(mod.parseMarker(marker + '\n\n补充说明'), { pickId: 'pick-9', images: [0, 2], human: '\n\n补充说明' })
|
|
57
|
+
})
|
|
58
|
+
ok('parseMarker absent -> null', () => {
|
|
59
|
+
assert.equal(mod.parseMarker(undefined), null)
|
|
60
|
+
assert.equal(mod.parseMarker('普通 detail'), null)
|
|
61
|
+
assert.equal(mod.parseMarker('<!--dsh-pick:v1:broken-->'), null)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// --- isPlanReviewBatch ---
|
|
65
|
+
ok('isPlanReviewBatch detects single plan-review question', () => {
|
|
66
|
+
assert.equal(mod.isPlanReviewBatch([{ id: 'a', question: 'q', detail: 'plan', intent: { kind: 'plan-review', approve: '执行' }, options: [{ label: '执行' }] }]), true)
|
|
67
|
+
})
|
|
68
|
+
ok('isPlanReviewBatch passes normal questions', () => {
|
|
69
|
+
assert.equal(mod.isPlanReviewBatch([{ id: 'a', question: 'q', options: [{ label: 'A' }] }]), false)
|
|
70
|
+
assert.equal(mod.isPlanReviewBatch([{ id: 'a', question: 'q' }, { id: 'b', question: 'q2' }]), false)
|
|
71
|
+
assert.equal(mod.isPlanReviewBatch([]), false)
|
|
72
|
+
assert.equal(mod.isPlanReviewBatch(undefined), false)
|
|
73
|
+
// 多题批里即便有 plan-review 意图也不按 plan-review 处理(原生同样放行给通用流)
|
|
74
|
+
assert.equal(mod.isPlanReviewBatch([{ id: 'a', question: 'q', detail: 'd', intent: { kind: 'plan-review', approve: 'x' }, options: [{ label: 'x' }] }, { id: 'b', question: 'q2' }]), false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// --- buildSteerText ---
|
|
78
|
+
ok('buildSteerText zh refresh mentions intent and batch', () => {
|
|
79
|
+
const text = mod.buildSteerText('refresh', [{ id: 'a', question: '选封面' }, { id: 'b', question: '选风格' }], 0, true)
|
|
80
|
+
assert.ok(text.includes('选项刷新'), '应标记为选项刷新')
|
|
81
|
+
assert.ok(text.includes('ask_user_question'), '应点名工具')
|
|
82
|
+
assert.ok(text.includes('ask_user_choice'), '应点名工具')
|
|
83
|
+
assert.ok(text.includes('5~8'), '应给出数量区间')
|
|
84
|
+
assert.ok(text.includes('共 2 道'), '应说明批内题目数')
|
|
85
|
+
})
|
|
86
|
+
ok('buildSteerText zh more keeps original options', () => {
|
|
87
|
+
const text = mod.buildSteerText('more', [{ id: 'a', question: '选模型' }], 0, true)
|
|
88
|
+
assert.ok(text.includes('更多选项'), '应标记为更多选项')
|
|
89
|
+
assert.ok(text.includes('保留原选项'), '应要求保留原选项')
|
|
90
|
+
assert.ok(text.includes('6~10'), '应给出目标数量')
|
|
91
|
+
})
|
|
92
|
+
ok('buildSteerText en refresh', () => {
|
|
93
|
+
const text = mod.buildSteerText('refresh', [{ id: 'a', question: 'pick a cover' }], 0, false)
|
|
94
|
+
assert.ok(text.includes('Regenerate options'))
|
|
95
|
+
assert.ok(text.includes('ask_user_choice'))
|
|
96
|
+
})
|
|
97
|
+
ok('buildSteerText tolerates empty questions', () => {
|
|
98
|
+
const text = mod.buildSteerText('refresh', undefined, 0, true)
|
|
99
|
+
assert.equal(typeof text, 'string')
|
|
100
|
+
assert.ok(text.length > 0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// --- parseRecommendedLabel ---
|
|
104
|
+
ok('parseRecommendedLabel strips suffix', () => {
|
|
105
|
+
assert.deepEqual(mod.parseRecommendedLabel('A (Recommended)'), { label: 'A', recommended: true })
|
|
106
|
+
assert.deepEqual(mod.parseRecommendedLabel('A(推荐)'), { label: 'A', recommended: true })
|
|
107
|
+
assert.deepEqual(mod.parseRecommendedLabel('A'), { label: 'A', recommended: false })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// --- selectChoice ---
|
|
111
|
+
ok('selectChoice claims normal question', () => {
|
|
112
|
+
const q = { kind: 'question', key: 'q:1', sessionId: 's1', payload: { questions: [{ id: 'a', question: 'q', options: [{ label: 'A' }] }] } }
|
|
113
|
+
assert.equal(mod.selectChoice({ interactions: [q] }), q)
|
|
114
|
+
})
|
|
115
|
+
ok('selectChoice skips plan-review and approval', () => {
|
|
116
|
+
const plan = { kind: 'question', key: 'q:1', sessionId: 's1', payload: { questions: [{ id: 'a', question: 'q', detail: 'plan', intent: { kind: 'plan-review', approve: '执行' }, options: [{ label: '执行' }] }] } }
|
|
117
|
+
const approval = { kind: 'approval', key: 'a:1', sessionId: 's1', payload: {} }
|
|
118
|
+
assert.equal(mod.selectChoice({ interactions: [approval, plan] }), null, 'plan-review 应放行给原生 PlanReviewPanel')
|
|
119
|
+
})
|
|
120
|
+
ok('selectChoice prefers question over approval and falls through to later', () => {
|
|
121
|
+
const q = { kind: 'question', key: 'q:2', sessionId: 's2', payload: { questions: [{ id: 'b', question: 'q2' }] } }
|
|
122
|
+
const approval = { kind: 'approval', key: 'a:1', sessionId: 's1', payload: {} }
|
|
123
|
+
assert.equal(mod.selectChoice({ interactions: [approval, q] }), q)
|
|
124
|
+
assert.equal(mod.selectChoice({ interactions: [approval] }), null)
|
|
125
|
+
})
|
|
126
|
+
ok('selectChoice claims image-marked question too', () => {
|
|
127
|
+
const marker = '<!--dsh-pick:v1:' + encodeBase64Url(JSON.stringify({ pickId: 'p1', images: [0] })) + '-->'
|
|
128
|
+
const q = { kind: 'question', key: 'q:3', sessionId: 's3', payload: { questions: [{ id: 'c', question: '选图', detail: marker, options: [{ label: 'A' }] }] } }
|
|
129
|
+
assert.equal(mod.selectChoice({ interactions: [q] }), q, '带图问题应由本插件渲染(含刷新/更多按钮)')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
console.log(`\n[dsh-plugin-choice-refresh] ${passed} checks passed`)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-choice-refresh 客户端冒烟测试:
|
|
3
|
+
* 用 Node 模拟浏览器模块加载器执行 lib/client.js,再用真实 react /
|
|
4
|
+
* react-dom/server 渲染一次选择卡,验证:
|
|
5
|
+
* 1. 模块加载、apply/inject 导出正常;
|
|
6
|
+
* 2. 纯文字问题:卡片渲染出「重新生成选项 / 更多选项」按钮,标记不泄漏;
|
|
7
|
+
* 3. 带图片标记的问题:渲染图片卡片(复用 image-tools 路由);
|
|
8
|
+
* 4. plan-review 批:selectChoice 放行(不认领);
|
|
9
|
+
* 5. Lightbox 放大查看单独渲染正常。
|
|
10
|
+
*
|
|
11
|
+
* 运行:node scripts/smoke-client.mjs
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { createRequire } from 'node:module'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { strict as assert } from 'node:assert'
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url)
|
|
20
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
|
+
const profileRequire = createRequire(join('C:/Users/18303/.dsh/profiles/node_modules', 'noop.cjs'))
|
|
22
|
+
const reactRoot = dirname(profileRequire.resolve('react'))
|
|
23
|
+
const jsxRuntime = join(reactRoot, 'jsx-runtime.js')
|
|
24
|
+
const reactModule = profileRequire('react')
|
|
25
|
+
const reactDomServer = profileRequire('react-dom/server')
|
|
26
|
+
|
|
27
|
+
// 1) 模拟浏览器模块加载器
|
|
28
|
+
let spec = null
|
|
29
|
+
globalThis.window = {
|
|
30
|
+
__ModuleLoader__: { load: (value) => { spec = value } },
|
|
31
|
+
}
|
|
32
|
+
const code = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8')
|
|
33
|
+
new Function(code)()
|
|
34
|
+
assert.ok(spec !== null, 'module loader not invoked')
|
|
35
|
+
assert.equal(spec.id, 'dsh-plugin-choice-refresh')
|
|
36
|
+
|
|
37
|
+
const shimRequire = (name) => {
|
|
38
|
+
if (name === 'react/jsx-runtime') return require(jsxRuntime)
|
|
39
|
+
if (name === 'react') return reactModule
|
|
40
|
+
throw new Error(`unexpected require: ${name}`)
|
|
41
|
+
}
|
|
42
|
+
const mod = spec.factory(shimRequire)
|
|
43
|
+
assert.equal(typeof mod.apply, 'function')
|
|
44
|
+
assert.deepEqual(mod.inject, ['slots', 'locale', 'sessions', 'remote'])
|
|
45
|
+
assert.equal(typeof mod.selectChoice, 'function')
|
|
46
|
+
assert.equal(typeof mod.ChoiceComposer, 'function')
|
|
47
|
+
|
|
48
|
+
const { renderToString } = reactDomServer
|
|
49
|
+
const fakeT = (key) => ({
|
|
50
|
+
'nav.cancel': '放弃整组问题',
|
|
51
|
+
'nav.prev': '上一题',
|
|
52
|
+
'nav.next': '下一题',
|
|
53
|
+
'action.skip': '跳过本题',
|
|
54
|
+
'action.next': '下一题',
|
|
55
|
+
'submit': '提交',
|
|
56
|
+
'option.recommended': '推荐',
|
|
57
|
+
'custom.placeholder': '输入你的答案',
|
|
58
|
+
'refresh.button': '重新生成选项',
|
|
59
|
+
'more.button': '更多选项',
|
|
60
|
+
'busy.refresh': '重新生成中…',
|
|
61
|
+
'busy.more': '补充更多选项中…',
|
|
62
|
+
'image.failed': '图片加载失败',
|
|
63
|
+
'image.zoom': '放大查看',
|
|
64
|
+
'image.close': '关闭',
|
|
65
|
+
}[key] ?? key)
|
|
66
|
+
const fakeCtx = { sessions: {}, remote: {} }
|
|
67
|
+
|
|
68
|
+
function encodeBase64Url(json) {
|
|
69
|
+
return Buffer.from(json, 'utf8').toString('base64url')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2) 纯文字问题:渲染卡片 + 两个增强按钮
|
|
73
|
+
const plainWait = {
|
|
74
|
+
key: 'q:1',
|
|
75
|
+
sessionId: 's1',
|
|
76
|
+
payload: {
|
|
77
|
+
type: 'question/requested',
|
|
78
|
+
sessionId: 's1',
|
|
79
|
+
questions: [
|
|
80
|
+
{ id: 'a', question: '选一个封面风格', header: '封面', detail: '请选择你喜欢的风格', multiSelect: false, options: [
|
|
81
|
+
{ label: '赛博 (Recommended)', description: '霓虹质感' },
|
|
82
|
+
{ label: '水墨' },
|
|
83
|
+
{ label: '极简' },
|
|
84
|
+
] },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
respond: async (r) => ({ accepted: true }),
|
|
88
|
+
}
|
|
89
|
+
let html = renderToString(reactModule.createElement(mod.ChoiceComposer, { matched: plainWait, t: fakeT, ctx: fakeCtx }))
|
|
90
|
+
assert.ok(html.includes('dsr-card'), '卡片未渲染')
|
|
91
|
+
assert.ok(html.includes('选一个封面风格'), '问题文本缺失')
|
|
92
|
+
assert.ok(html.includes('重新生成选项'), '刷新按钮缺失')
|
|
93
|
+
assert.ok(html.includes('更多选项'), '更多选项按钮缺失')
|
|
94
|
+
assert.ok(html.includes('赛博'), '选项 label 缺失')
|
|
95
|
+
assert.ok(html.includes('推荐'), '推荐徽标缺失')
|
|
96
|
+
assert.ok(!html.includes('<!--dsh-pick:v1:'), '图片标记不应出现')
|
|
97
|
+
|
|
98
|
+
// 3) 带图片标记的问题:渲染图片卡片(image-tools 路由)
|
|
99
|
+
const marker = '<!--dsh-pick:v1:' + encodeBase64Url(JSON.stringify({ pickId: 'pick-7', images: [0, 2] })) + '-->'
|
|
100
|
+
const imageWait = {
|
|
101
|
+
key: 'q:2',
|
|
102
|
+
sessionId: 's2',
|
|
103
|
+
payload: {
|
|
104
|
+
type: 'question/requested',
|
|
105
|
+
sessionId: 's2',
|
|
106
|
+
questions: [
|
|
107
|
+
{ id: 'b', question: '选一张封面图', detail: marker, multiSelect: false, options: [
|
|
108
|
+
{ label: '深海' },
|
|
109
|
+
{ label: '极光' },
|
|
110
|
+
{ label: '沙漠' },
|
|
111
|
+
] },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
respond: async (r) => ({ accepted: true }),
|
|
115
|
+
}
|
|
116
|
+
html = renderToString(reactModule.createElement(mod.ChoiceComposer, { matched: imageWait, t: fakeT, ctx: fakeCtx }))
|
|
117
|
+
assert.ok(html.includes('/dsh-plugin-image-tools/pick-7/0'), '第一张图片 URL 缺失')
|
|
118
|
+
assert.ok(html.includes('/dsh-plugin-image-tools/pick-7/2'), '第三张图片 URL 缺失')
|
|
119
|
+
assert.ok(html.includes('深海'), '图片卡片 label 缺失')
|
|
120
|
+
assert.ok(html.includes('放大查看'), '放大触发按钮缺失')
|
|
121
|
+
assert.ok(html.includes('重新生成选项'), '带图问题的刷新按钮缺失')
|
|
122
|
+
assert.ok(!html.includes('<!--dsh-pick:v1:'), '图片标记不应泄漏到界面')
|
|
123
|
+
|
|
124
|
+
// 4) plan-review 批放行
|
|
125
|
+
const planWait = {
|
|
126
|
+
kind: 'question',
|
|
127
|
+
key: 'q:3',
|
|
128
|
+
sessionId: 's3',
|
|
129
|
+
payload: {
|
|
130
|
+
type: 'question/requested',
|
|
131
|
+
sessionId: 's3',
|
|
132
|
+
questions: [
|
|
133
|
+
{ id: 'c', question: '确认执行计划', detail: '# 计划\n1. 做 A', intent: { kind: 'plan-review', approve: '确认执行' }, options: [{ label: '确认执行' }, { label: '拒绝' }] },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
respond: async (r) => ({ accepted: true }),
|
|
137
|
+
}
|
|
138
|
+
assert.equal(mod.selectChoice({ interactions: [planWait] }), null, 'plan-review 不应被本插件认领')
|
|
139
|
+
|
|
140
|
+
// 5) Lightbox 单独渲染
|
|
141
|
+
const lightboxHtml = renderToString(reactModule.createElement(mod.Lightbox, {
|
|
142
|
+
zoom: { src: '/dsh-plugin-image-tools/pick-7/0', label: '深海', description: '第一张' },
|
|
143
|
+
onClose: () => {},
|
|
144
|
+
t: fakeT,
|
|
145
|
+
}))
|
|
146
|
+
assert.ok(lightboxHtml.includes('dsr-lightbox'), 'lightbox 遮罩未渲染')
|
|
147
|
+
assert.ok(lightboxHtml.includes('/dsh-plugin-image-tools/pick-7/0'), 'lightbox 大图 URL 缺失')
|
|
148
|
+
assert.ok(lightboxHtml.includes('深海'), 'lightbox 说明缺失')
|
|
149
|
+
assert.ok(lightboxHtml.includes('关闭'), 'lightbox 关闭按钮文案缺失')
|
|
150
|
+
|
|
151
|
+
console.log('[dsh-plugin-choice-refresh] client smoke passed')
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# dsh-plugin-choice-refresh 设计说明
|
|
2
|
+
|
|
3
|
+
作者向文档:方案、约束、边界。
|
|
4
|
+
|
|
5
|
+
## 1. 要解决的问题
|
|
6
|
+
|
|
7
|
+
DSH 的选择类工具(原生 `ask_user_question`、image-tools 的 `ask_user_choice`)
|
|
8
|
+
都是「模型给一批选项 → 用户只能选/跳过/放弃」。用户对选项不满意时只能:
|
|
9
|
+
打字说明(麻烦)、放弃后手动再问(打断流程)。本插件提供两个一键操作:
|
|
10
|
+
|
|
11
|
+
1. **refresh(重新生成选项)**:都不满意 → 换一批;
|
|
12
|
+
2. **more(更多选项)**:选项太少 → 补充到 6~10 个。
|
|
13
|
+
|
|
14
|
+
## 2. 为什么用「取消 + steer 注入」而非新工具 / 改服务端
|
|
15
|
+
|
|
16
|
+
- **不改核心包**:`ask_user_question` 注册在 preset 的 tools 层,同层重名注册会
|
|
17
|
+
抛 `already registered`,无法覆盖;新增同名不同名工具模型也不会主动用。
|
|
18
|
+
- **不新增工具**:模型本来就会用 `ask_user_question` / `ask_user_choice`。
|
|
19
|
+
用户点击后真正需要的是「让模型重新问一次」,而 `session.prompt(mode='steer')`
|
|
20
|
+
正是客户端向 agent 注入用户消息、打断当前回合的标准通道。
|
|
21
|
+
- **协议闭环**:
|
|
22
|
+
1. `pending.cancel()`:以「用户关闭」解除当前待答问题(与原生「放弃整组问题」
|
|
23
|
+
同一 wire 编码 `{ok:false, error:{code:'cancelled'}}`);
|
|
24
|
+
2. `ctx.sessions.binding(sessionId).session.prompt([{type:'text',text}], 'steer')`
|
|
25
|
+
(兜底 `ctx.remote.sessions.prompt({mode:'steer'})`)注入结构化指令;
|
|
26
|
+
3. 模型在下一回合看到指令与上一组选项的上下文,重新调用提问工具。
|
|
27
|
+
- **对任何提问工具生效**:协议挂在交互层(question/requested 帧)而不是工具
|
|
28
|
+
输出层,所以原生与 image-tools 的提问都被覆盖。
|
|
29
|
+
|
|
30
|
+
## 3. 客户端渲染策略:链条目全量接管(priority -300)
|
|
31
|
+
|
|
32
|
+
`conversation.composer` 是 chain 槽:多个条目按 `priority` 升序选举,第一个
|
|
33
|
+
`select` 返回非 null 者渲染(README 原话:"first non-null return elects its
|
|
34
|
+
entry")。要给所有选择卡加按钮,只有两条路:
|
|
35
|
+
|
|
36
|
+
- **DOM 注入**:MutationObserver 给卡片加按钮——拿不到 React 持有的
|
|
37
|
+
`PendingWait`(`wait.respond`),无法解除问题,**否决**;
|
|
38
|
+
- **全量接管**:以 -300(先于 image-tools 的 -100 与原生 0)认领全部
|
|
39
|
+
question 交互,自己渲染选择卡(含图片卡),并在页脚放两个增强按钮。
|
|
40
|
+
|
|
41
|
+
选择全量接管,代价是复刻原生卡片 UI(约 600 行,样式全走 `--dsw-*` 变量),
|
|
42
|
+
换来对交互载体的完全控制与对两种提问工具的覆盖。
|
|
43
|
+
|
|
44
|
+
### 边界处理
|
|
45
|
+
|
|
46
|
+
- **plan-review 意图放行**:`isPlanReviewBatch`(单题 + `intent.kind==='plan-review'`)
|
|
47
|
+
时 `select` 返回 null,落回原生 `PlanReviewPanel`,避免把计划审阅卡降级成
|
|
48
|
+
通用选项列表。
|
|
49
|
+
- **无选项的纯文本提问**:不显示两个增强按钮(没有选项可刷新/补充),
|
|
50
|
+
渲染与原生一致的 textarea。
|
|
51
|
+
- **多题批**:刷新/补充作用于当前题,steer 文案附带整批题目清单,
|
|
52
|
+
提示模型把其余题目一并重新询问(cancel 会整批解除,必须让模型重新问全)。
|
|
53
|
+
- **图片卡**:解析 image-tools 的 `<!--dsh-pick:v1:...-->` 标记,图片走其
|
|
54
|
+
公开路由 `/dsh-plugin-image-tools/<pickId>/<index>`;路由 404 时降级为
|
|
55
|
+
「图片加载失败」占位。未安装 image-tools 时无标记可解析,自动退化为纯文字。
|
|
56
|
+
|
|
57
|
+
## 4. 与 image-tools 的耦合边界
|
|
58
|
+
|
|
59
|
+
- 只耦合其**公开路由**与**标记格式**(`buildPickMarker`/`parsePickMarker` 的
|
|
60
|
+
wire 形状),二者在 image-tools 源码与文档中固定;
|
|
61
|
+
- 不 `require('dsh-plugin-image-tools')`(客户端模块表禁止跨插件值导入),
|
|
62
|
+
全部自包含;
|
|
63
|
+
- 若未来 image-tools 改动标记格式,本插件的 `parseMarker` 需要同步。
|
|
64
|
+
|
|
65
|
+
## 5. 已知限制
|
|
66
|
+
|
|
67
|
+
- **软增强**:刷新质量依赖模型对注入指令的执行力。指令写得非常明确
|
|
68
|
+
(换角度/数量/保留原选项/立即重新调用工具),但仍非硬保证;用户可再次点击
|
|
69
|
+
或打字说明。
|
|
70
|
+
- **取消的副作用**:`cancel()` 解除的是整批问题(与原生 ✕ 一致)。多题批里
|
|
71
|
+
点刷新会连其它题一起解除,靠 steer 文案让模型重新问全批。
|
|
72
|
+
- **执行顺序**:先 cancel 后 steer。若 cancel 成功而 steer 失败(如会话已
|
|
73
|
+
关闭),问题已解除且无重新生成——卡片随即消失,用户可重新提问;失败路径
|
|
74
|
+
会在卡片消失前短暂显示错误文案。
|
|
75
|
+
- **steer 消息入历史**:`【系统 · 选项刷新/更多选项】` 会作为用户消息留在
|
|
76
|
+
会话记录里(这也是模型能理解上下文的原因),多次点击会累积多条。
|
|
77
|
+
文案刻意保持简短(一句话点明意图 + 数量要求 + 推荐项标注),
|
|
78
|
+
多题批时附一行「其余题目也请一并重新询问」。
|
|
79
|
+
|
|
80
|
+
## 6. 测试
|
|
81
|
+
|
|
82
|
+
- `scripts/selfcheck.mjs`:13 个纯函数断言(标记解析 / plan-review 识别 /
|
|
83
|
+
steer 文案 zh/en / 选择器行为 / 推荐标注剥离)。
|
|
84
|
+
- `scripts/smoke-client.mjs`:真实 react-dom/server 渲染纯文字卡、图片卡、
|
|
85
|
+
Lightbox,并断言 plan-review 放行。
|
|
86
|
+
- 服务端半边为空(`apply` 无注册项),无 server 冒烟。
|
|
87
|
+
|
|
88
|
+
## 7. 挂载方式
|
|
89
|
+
|
|
90
|
+
采用「profile 手动 insert」(同 workbench/novel):`cordis.patch.yml` 是
|
|
91
|
+
HMR 层,insert/remove 无需重启 dsh;客户端 bundle 由 `dsh.client` 声明经
|
|
92
|
+
`/plugins/dsh-plugin-choice-refresh/client.js` 送达,profile 的 client-hmr
|
|
93
|
+
监控 bundle 变更自动广播(否则手动刷新一次)。
|