@sophiacyl/dsh-plugin-waves 0.0.0-stage → 0.1.0-beta.2
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/DSH-GO-VERIFICATION.md +57 -0
- package/ICON.png +0 -0
- package/LICENSE +674 -0
- package/README.md +251 -2
- package/cordis.patch.yml +8 -0
- package/install.ps1 +0 -0
- package/install.sh +209 -0
- package/lib/client.js +1511 -0
- package/lib/host/aliashint.js +70 -0
- package/lib/host/attachments.js +25 -0
- package/lib/host/command.js +34 -0
- package/lib/host/consts.js +16 -0
- package/lib/host/dataguide.js +35 -0
- package/lib/host/imagebridge.js +41 -0
- package/lib/host/native-result.js +91 -0
- package/lib/host/native.js +191 -0
- package/lib/host/proxy.js +59 -0
- package/lib/host/results.js +67 -0
- package/lib/host/service.js +263 -0
- package/lib/host/tool.js +185 -0
- package/lib/host/troubleshoot.js +31 -0
- package/lib/host/worldbook.js +80 -0
- package/lib/index.js +74 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +136 -4
- package/service/ICON.png +0 -0
- package/service/ai/AI_DATA.md +221 -0
- package/service/knowledge/additional/manual.jsonl +5 -0
- package/service/knowledge/wiki/baike.jsonl +1231 -0
- package/service/knowledge/wiki/chengjiu.jsonl +36 -0
- package/service/knowledge/wiki/gonglve.jsonl +383 -0
- package/service/knowledge/wiki/huodong.jsonl +427 -0
- package/service/knowledge/wiki/renwu.jsonl +412 -0
- package/service/knowledge/wiki/tujian.jsonl +2889 -0
- package/service/knowledge/wiki/zixun.jsonl +47 -0
- package/service/plugins/__init__.py +0 -0
- package/service/pyproject.toml +40 -0
- package/service/rover/__init__.py +0 -0
- package/service/rover/ai_core/__init__.py +0 -0
- package/service/rover/ai_core/models.py +50 -0
- package/service/rover/ai_core/rag/__init__.py +5 -0
- package/service/rover/ai_core/rag/knowledge.py +23 -0
- package/service/rover/ai_core/register.py +86 -0
- package/service/rover/ai_core/resource.py +6 -0
- package/service/rover/ai_core/skills/__init__.py +0 -0
- package/service/rover/ai_core/skills/operations.py +8 -0
- package/service/rover/api.py +720 -0
- package/service/rover/api_render.py +161 -0
- package/service/rover/app_life.py +154 -0
- package/service/rover/aps.py +47 -0
- package/service/rover/avatar.py +85 -0
- package/service/rover/bot.py +133 -0
- package/service/rover/builtin.py +152 -0
- package/service/rover/cards/__init__.py +26 -0
- package/service/rover/cards/_shared.py +129 -0
- package/service/rover/cards/char_list.py +258 -0
- package/service/rover/cards/char_panel.py +1353 -0
- package/service/rover/cards/echo_list.py +206 -0
- package/service/rover/cards/matrix_roles.py +25 -0
- package/service/rover/cards/rank_boards.py +714 -0
- package/service/rover/cards/rank_total.py +664 -0
- package/service/rover/compat.py +215 -0
- package/service/rover/config.py +163 -0
- package/service/rover/custom_panel.py +220 -0
- package/service/rover/data_store.py +31 -0
- package/service/rover/gss.py +19 -0
- package/service/rover/handler.py +191 -0
- package/service/rover/help/__init__.py +0 -0
- package/service/rover/help/draw_new_plugin_help.py +332 -0
- package/service/rover/help/model.py +16 -0
- package/service/rover/help/utils.py +7 -0
- package/service/rover/hooks.py +420 -0
- package/service/rover/html_actions.py +236 -0
- package/service/rover/i18n.py +19 -0
- package/service/rover/knowledge.py +235 -0
- package/service/rover/logger.py +102 -0
- package/service/rover/main.py +33 -0
- package/service/rover/media.py +222 -0
- package/service/rover/message_models.py +23 -0
- package/service/rover/models.py +92 -0
- package/service/rover/native_media.py +64 -0
- package/service/rover/ocr.py +79 -0
- package/service/rover/pool.py +43 -0
- package/service/rover/restart.py +36 -0
- package/service/rover/segment.py +143 -0
- package/service/rover/server.py +131 -0
- package/service/rover/status/__init__.py +0 -0
- package/service/rover/status/models.py +10 -0
- package/service/rover/status/plugin_status.py +17 -0
- package/service/rover/subscribe.py +148 -0
- package/service/rover/sv.py +403 -0
- package/service/rover/templates/char_list.html +182 -0
- package/service/rover/templates/char_panel.html +465 -0
- package/service/rover/templates/echo_list.html +221 -0
- package/service/rover/templates/rank_all_total.html +193 -0
- package/service/rover/templates/rank_board.html +107 -0
- package/service/rover/templates/rank_level_total.html +57 -0
- package/service/rover/templates/rank_matrix_total.html +66 -0
- package/service/rover/templates/rank_phantom_total.html +180 -0
- package/service/rover/templates/rank_slash_total.html +38 -0
- package/service/rover/trigger.py +98 -0
- package/service/rover/utils/__init__.py +0 -0
- package/service/rover/utils/boardcast/__init__.py +0 -0
- package/service/rover/utils/boardcast/models.py +15 -0
- package/service/rover/utils/cookie_manager/__init__.py +0 -0
- package/service/rover/utils/cookie_manager/qrlogin.py +24 -0
- package/service/rover/utils/database/__init__.py +0 -0
- package/service/rover/utils/database/base_models.py +1771 -0
- package/service/rover/utils/database/models.py +80 -0
- package/service/rover/utils/database/startup.py +60 -0
- package/service/rover/utils/default_bg/bg.jpg +0 -0
- package/service/rover/utils/download_resource/__init__.py +0 -0
- package/service/rover/utils/download_resource/download_core.py +164 -0
- package/service/rover/utils/download_resource/download_file.py +46 -0
- package/service/rover/utils/fonts/__init__.py +0 -0
- package/service/rover/utils/fonts/fonts.py +33 -0
- package/service/rover/utils/image/__init__.py +0 -0
- package/service/rover/utils/image/convert.py +51 -0
- package/service/rover/utils/image/image_tools.py +30 -0
- package/service/rover/utils/image/utils.py +70 -0
- package/service/rover/utils/plugins_config/__init__.py +0 -0
- package/service/rover/utils/plugins_config/gs_config.py +506 -0
- package/service/rover/utils/plugins_config/models.py +182 -0
- package/service/rover/web_app.py +3 -0
- package/service/rover/webconsole/__init__.py +0 -0
- package/service/rover/webconsole/mount_app.py +26 -0
- package/service/tools/migrate.py +351 -0
- package/service/tools/regression.py +87 -0
- package/service/tools/smoke.py +63 -0
- package/service/tools/update_plugins.py +212 -0
- package/service/tools/verify_actions.py +104 -0
- package/service/tools/verify_card.py +82 -0
- package/service/tools/wwdata.py +665 -0
- package/tests/compat.test.js +181 -0
- package/tests/imagebridge.test.js +58 -0
- package/tests/media.test.js +68 -0
- package/tests/native.test.js +127 -0
- package/tests/runtime.test.js +69 -0
- package/tests/service.test.js +48 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import vm from 'node:vm'
|
|
5
|
+
import { readAttachments } from '../lib/host/attachments.js'
|
|
6
|
+
import { runSlashCommand } from '../lib/host/command.js'
|
|
7
|
+
import { makeResultStore } from '../lib/host/results.js'
|
|
8
|
+
|
|
9
|
+
const signal = new AbortController().signal
|
|
10
|
+
const image = { type: 'image', attachment: { attachmentId: 'image-id' } }
|
|
11
|
+
const file = { type: 'file', attachment: { attachmentId: 'file-id', name: 'records.json' } }
|
|
12
|
+
const ctx = { get: () => ({
|
|
13
|
+
async readImage(ref, receivedSignal) {
|
|
14
|
+
assert.equal(ref, image.attachment)
|
|
15
|
+
assert.equal(receivedSignal, signal)
|
|
16
|
+
return { data: Buffer.from('image') }
|
|
17
|
+
},
|
|
18
|
+
async *readFileStream(ref, receivedSignal) {
|
|
19
|
+
assert.equal(ref, file.attachment)
|
|
20
|
+
assert.equal(receivedSignal, signal)
|
|
21
|
+
yield Buffer.from('{')
|
|
22
|
+
yield Buffer.from('}')
|
|
23
|
+
},
|
|
24
|
+
}) }
|
|
25
|
+
|
|
26
|
+
test('DSH 0.2 attachments reach the service as images and one named file', async () => {
|
|
27
|
+
const results = makeResultStore()
|
|
28
|
+
let body
|
|
29
|
+
const result = await runSlashCommand({ async call(path, options) {
|
|
30
|
+
assert.equal(path, '/api/chat')
|
|
31
|
+
assert.equal(options.signal, signal)
|
|
32
|
+
body = options.body
|
|
33
|
+
return { ok: true, summary: '评分完成', segments: [{ type: 'html', url: '/api/media/panel.html' }] }
|
|
34
|
+
} }, results, { rawInput: '-p 评分', agent: { id: 'session-id' }, commandId: 'command-id', signal,
|
|
35
|
+
attachments: [image, file] }, (blocks, signal) => readAttachments(ctx, blocks, signal))
|
|
36
|
+
assert.equal(result.kind, 'success')
|
|
37
|
+
assert.deepEqual(body, { command: '评分', scene: 'direct', session_id: 'session-id',
|
|
38
|
+
images: ['base64://aW1hZ2U='], file_name: 'records.json', file_data: 'e30=' })
|
|
39
|
+
assert.equal(results.get('command-id').segments[0].type, 'html')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('attachment read failures refuse dispatch and preserve the command failure', async () => {
|
|
43
|
+
const failure = new Error('图片读取失败')
|
|
44
|
+
let called = false
|
|
45
|
+
const result = await runSlashCommand({ call() { called = true } }, makeResultStore(), {
|
|
46
|
+
rawInput: '评分', signal, attachments: [image],
|
|
47
|
+
}, () => { throw failure })
|
|
48
|
+
assert.equal(called, false)
|
|
49
|
+
assert.equal(result.kind, 'error')
|
|
50
|
+
assert.match(result.text, /图片读取失败/)
|
|
51
|
+
await assert.rejects(readAttachments({ get: () => null }, [image], signal), /附件服务不可用/)
|
|
52
|
+
await assert.rejects(readAttachments(ctx, [file, file], signal), /一次只接受一个文件/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('no attachments and canceled commands keep their original semantics', async () => {
|
|
56
|
+
assert.deepEqual(await readAttachments({ get: () => null }, [], signal), { images: [] })
|
|
57
|
+
const controller = new AbortController()
|
|
58
|
+
controller.abort()
|
|
59
|
+
const result = await runSlashCommand({ call() { throw new Error('aborted') } }, makeResultStore(), {
|
|
60
|
+
rawInput: '查询', signal: controller.signal,
|
|
61
|
+
})
|
|
62
|
+
assert.equal(result.kind, 'error')
|
|
63
|
+
assert.match(result.text, /已取消/)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
async function clientFixture(execution, { deferred = [] } = {}) {
|
|
67
|
+
let plugin, source
|
|
68
|
+
const slots = new Map()
|
|
69
|
+
const pending = new Map()
|
|
70
|
+
const requests = []
|
|
71
|
+
const react = {
|
|
72
|
+
createElement: (type, props, ...children) => ({ type, props, children }),
|
|
73
|
+
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
|
|
74
|
+
useEffect: () => {}, useRef: (value) => ({ current: value }), useCallback: (fn) => fn,
|
|
75
|
+
}
|
|
76
|
+
const sandbox = {
|
|
77
|
+
window: { __ModuleLoader__: { load: ({ factory }) => { plugin = factory(() => react) } } },
|
|
78
|
+
document: { body: {}, head: { appendChild() {} }, getElementById: () => ({}), querySelectorAll: () => [] },
|
|
79
|
+
MutationObserver: class { observe() {} },
|
|
80
|
+
localStorage: { getItem: (key) => key === 'ww-slash-prefixes' ? '["/ww","/xw"]' : null, setItem() {} },
|
|
81
|
+
fetch: async (path, options) => {
|
|
82
|
+
requests.push({ path, options })
|
|
83
|
+
return { ok: true, json: async () => ({ ok: true, prefixes: ['ww', 'xw'], segments: [] }) }
|
|
84
|
+
},
|
|
85
|
+
setTimeout, clearTimeout,
|
|
86
|
+
}
|
|
87
|
+
vm.runInNewContext(await readFile(new URL('../lib/client.js', import.meta.url), 'utf8'), sandbox)
|
|
88
|
+
const ctx = {
|
|
89
|
+
effect: (setup) => setup(),
|
|
90
|
+
on: () => () => {},
|
|
91
|
+
inject: (dependencies, setup) => setup(ctx),
|
|
92
|
+
remote: { commands: { execute: execution } },
|
|
93
|
+
inputTriggers: { registerSource: (registered) => { source = registered } },
|
|
94
|
+
slots: {
|
|
95
|
+
inject: (name, setup) => deferred.includes(name) ? pending.set(name, setup) : setup(),
|
|
96
|
+
register: (spec, view) => {
|
|
97
|
+
const key = spec.name + ":" + (spec.key || spec.id)
|
|
98
|
+
slots.set(key, view)
|
|
99
|
+
return () => slots.delete(key)
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
plugin.apply(ctx)
|
|
104
|
+
return { source, slots, requests, declare: (name) => pending.get(name)() }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test('compact slash claims follow the DSH 0.2 contract and forward attachments once', async () => {
|
|
108
|
+
const calls = []
|
|
109
|
+
const { source } = await clientFixture(async (...args) => {
|
|
110
|
+
calls.push(args)
|
|
111
|
+
return { ok: true, value: { result: { kind: 'success' } } }
|
|
112
|
+
})
|
|
113
|
+
assert.equal(await source.matchEnter({ sessionId: 'session' }, '/ww 查询'), undefined)
|
|
114
|
+
const { claim } = await source.matchEnter({ sessionId: 'session' }, '/xw评分')
|
|
115
|
+
assert.equal(claim.name, 'xw')
|
|
116
|
+
assert.equal(claim.token, '/xw ')
|
|
117
|
+
assert.equal(claim.attachments, true)
|
|
118
|
+
const images = [{ type: 'image', data: 'image-base64', mediaType: 'image/png' }]
|
|
119
|
+
assert.equal((await claim.submit('', {}, images)).kind, 'success')
|
|
120
|
+
assert.deepEqual(calls, [['session', '/ww 评分', images]])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('compact slash failures return error so the composer retains attachments', async () => {
|
|
124
|
+
const { source } = await clientFixture(async () => ({ ok: true, value: { result: { kind: 'error', text: '评分失败' } } }))
|
|
125
|
+
const { claim } = await source.matchEnter({ sessionId: 'session' }, '/ww评分')
|
|
126
|
+
assert.equal((await claim.submit('', {}, [])).text, '评分失败')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('rc.7 arity fallback only retries argument validation without attachments', async () => {
|
|
130
|
+
const calls = []
|
|
131
|
+
const { source } = await clientFixture(async (...args) => {
|
|
132
|
+
calls.push(args)
|
|
133
|
+
if (args.length === 3) throw new Error('expected 2 business arguments')
|
|
134
|
+
return { ok: true, value: { result: { kind: 'success' } } }
|
|
135
|
+
})
|
|
136
|
+
const { claim } = await source.matchEnter({ sessionId: 'session' }, '/ww查询')
|
|
137
|
+
assert.equal((await claim.submit('', {}, [])).kind, 'success')
|
|
138
|
+
assert.deepEqual(calls.map(args => args.length), [3, 2])
|
|
139
|
+
calls.length = 0
|
|
140
|
+
await assert.rejects(claim.submit('', {}, [image]), /expected 2 business arguments/)
|
|
141
|
+
assert.equal(calls.length, 1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('settled tool cards use current metadata and the real session for follow-up commands', async () => {
|
|
145
|
+
const { slots, requests } = await clientFixture()
|
|
146
|
+
const view = slots.get('tool.call.toolview:waves')
|
|
147
|
+
const tree = view({ sessionId: 'session', callId: 'tool-id', phase: 'result', block: {
|
|
148
|
+
kind: 'tool-result', call: { name: 'waves', argsRaw: '{"command":"ww查询"}' },
|
|
149
|
+
meta: { segments: [{ type: 'text', text: '查询结果' }] },
|
|
150
|
+
} })
|
|
151
|
+
const find = (node) => node && typeof node === 'object' && (
|
|
152
|
+
node.props?.seg ? node : node.children?.flat().map(find).find(value => value && typeof value === 'object')
|
|
153
|
+
)
|
|
154
|
+
const segment = find(tree)
|
|
155
|
+
assert.equal(segment.props.seg.text, '查询结果')
|
|
156
|
+
await segment.props.onCommand('ww椿面板')
|
|
157
|
+
const request = requests.find(({ path }) => path === '/waves-api/api/chat')
|
|
158
|
+
assert.equal(JSON.parse(request.options.body).session_id, 'session')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('service env drops bracketed no_proxy entries that httpx rejects', async () => {
|
|
162
|
+
const { serviceEnv } = await import('../lib/host/service.js')
|
|
163
|
+
const env = serviceEnv(9777, { no_proxy: 'localhost,127.0.0.1,::1,[::1]', NO_PROXY: '[::1]', HTTP_PROXY: 'http://127.0.0.1:1' })
|
|
164
|
+
assert.equal(env.no_proxy, 'localhost,127.0.0.1,::1')
|
|
165
|
+
assert.equal(env.NO_PROXY, '')
|
|
166
|
+
assert.equal(env.HTTP_PROXY, 'http://127.0.0.1:1')
|
|
167
|
+
assert.equal(env.ROVER_PORT, '9777')
|
|
168
|
+
assert.equal(serviceEnv(1, {}).no_proxy, undefined)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('config page lives on the plugins page and falls back to settings only without it', async () => {
|
|
172
|
+
const { slots } = await clientFixture()
|
|
173
|
+
assert.ok(slots.has('plugins.bundle.config:@sophiacyl/dsh-plugin-waves'))
|
|
174
|
+
assert.ok(!slots.has('settings.section:waves'))
|
|
175
|
+
|
|
176
|
+
const legacy = await clientFixture(undefined, { deferred: ['plugins.bundle.config'] })
|
|
177
|
+
assert.ok(legacy.slots.has('settings.section:waves'))
|
|
178
|
+
legacy.declare('plugins.bundle.config')
|
|
179
|
+
assert.ok(legacy.slots.has('plugins.bundle.config:@sophiacyl/dsh-plugin-waves'))
|
|
180
|
+
assert.ok(!legacy.slots.has('settings.section:waves'))
|
|
181
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import { registerImageBridge, takeImages } from '../lib/host/imagebridge.js'
|
|
4
|
+
|
|
5
|
+
function fixture() {
|
|
6
|
+
const listeners = new Map()
|
|
7
|
+
const disposers = []
|
|
8
|
+
const injected = []
|
|
9
|
+
const resolveModelInfo = async () => ({ inputModalities: ['text'] })
|
|
10
|
+
const ctx = {
|
|
11
|
+
llm: { resolveModelInfo },
|
|
12
|
+
inject(deps, callback) { injected.push(...deps); callback(ctx) },
|
|
13
|
+
on(event, fn) { listeners.set(event, fn) },
|
|
14
|
+
effect(fn) { disposers.push(fn()) },
|
|
15
|
+
}
|
|
16
|
+
registerImageBridge(ctx)
|
|
17
|
+
return { ctx, listeners, disposers, injected, resolveModelInfo }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ref = Object.freeze({ attachmentId: 'test-image' })
|
|
21
|
+
const image = Object.freeze({ type: 'image', attachment: ref })
|
|
22
|
+
const message = Object.freeze({ role: 'user', source: { kind: 'user' }, content: Object.freeze([image, Object.freeze({ type: 'text', text: '普通聊天截图' })]) })
|
|
23
|
+
|
|
24
|
+
test('image observer preserves host decision and exact user image content', async () => {
|
|
25
|
+
const { ctx, listeners, injected, resolveModelInfo } = fixture()
|
|
26
|
+
const decision = Object.freeze({ kind: 'enter', messages: Object.freeze([message]) })
|
|
27
|
+
const value = await listeners.get('agent/pre-step')({ agent: { id: 'preserve' } }, async () => decision)
|
|
28
|
+
assert.equal(value, decision)
|
|
29
|
+
assert.equal(value.messages[0], message)
|
|
30
|
+
assert.equal(value.messages[0].content[0], image)
|
|
31
|
+
assert.equal(value.messages[0].content.length, 2)
|
|
32
|
+
assert.equal(ctx.llm.resolveModelInfo, resolveModelInfo)
|
|
33
|
+
assert.deepEqual(injected, ['agents'])
|
|
34
|
+
assert.deepEqual(takeImages('preserve'), [ref])
|
|
35
|
+
assert.deepEqual(takeImages('preserve'), [])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('images are isolated by session and removed for a later text-only step', async () => {
|
|
39
|
+
const { listeners } = fixture()
|
|
40
|
+
const step = listeners.get('agent/pre-step')
|
|
41
|
+
await step({ agent: { id: 'a' } }, async () => ({ kind: 'enter', messages: [message] }))
|
|
42
|
+
assert.deepEqual(takeImages('b'), [])
|
|
43
|
+
await step({ agent: { id: 'a' } }, async () => ({ kind: 'enter', messages: [{ role: 'user', content: [{ type: 'text', text: '下一条' }] }] }))
|
|
44
|
+
assert.deepEqual(takeImages('a'), [])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('rejected steps and disposal clear pending images without altering host results', async () => {
|
|
48
|
+
const { listeners, disposers } = fixture()
|
|
49
|
+
const step = listeners.get('agent/pre-step')
|
|
50
|
+
await step({ agent: { id: 'reject' } }, async () => ({ kind: 'enter', messages: [message] }))
|
|
51
|
+
const rejected = { kind: 'reject' }
|
|
52
|
+
assert.equal(await step({ agent: { id: 'reject' } }, async () => rejected), rejected)
|
|
53
|
+
assert.deepEqual(takeImages('reject'), [])
|
|
54
|
+
await step({ agent: { id: 'dispose' } }, async () => ({ kind: 'enter', messages: [message] }))
|
|
55
|
+
listeners.get('agent/disposed')({ agent: { id: 'dispose' } })
|
|
56
|
+
assert.deepEqual(takeImages('dispose'), [])
|
|
57
|
+
for (const dispose of disposers) dispose()
|
|
58
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import { resultMeta } from '../lib/host/native-result.js'
|
|
7
|
+
|
|
8
|
+
const filename = '0123456789abcdef0123.native.jpg'
|
|
9
|
+
// Minimal JPEG marker fixture for header parsing; not an image decoder fixture.
|
|
10
|
+
function header(width = 320, height = 640) {
|
|
11
|
+
const data = Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08,
|
|
12
|
+
0x00, 0x00, 0x00, 0x00, 0x03, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 0xff, 0xd9])
|
|
13
|
+
data.writeUInt16BE(height, 7)
|
|
14
|
+
data.writeUInt16BE(width, 9)
|
|
15
|
+
return data
|
|
16
|
+
}
|
|
17
|
+
async function fixture(t) {
|
|
18
|
+
const dir = await mkdtemp(join(tmpdir(), 'waves-media-'))
|
|
19
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
20
|
+
const media = join(dir, 'data', 'media')
|
|
21
|
+
await mkdir(media, { recursive: true })
|
|
22
|
+
return { dir, media }
|
|
23
|
+
}
|
|
24
|
+
const value = { ok: true, summary: '测试摘要', segments: [{ type: 'html', native_image: filename }] }
|
|
25
|
+
|
|
26
|
+
test('chat results use file-backed media metadata, never panel asset image views', async (t) => {
|
|
27
|
+
const { dir, media } = await fixture(t)
|
|
28
|
+
await writeFile(join(media, filename), header())
|
|
29
|
+
const meta = resultMeta({ command: 'ww练度' }, value, dir)
|
|
30
|
+
const result = meta['dsh-go/media']
|
|
31
|
+
assert.equal(result.v, 1)
|
|
32
|
+
assert.equal(result.kind, 'image')
|
|
33
|
+
assert.equal(result.status, 'done')
|
|
34
|
+
assert.deepEqual(result.items, [{ path: join(media, filename), mime: 'image/jpeg', width: 320, height: 640, bytes: header().length }])
|
|
35
|
+
assert.equal(result.request.label, '鸣潮')
|
|
36
|
+
assert.equal(result.request.n, 1)
|
|
37
|
+
assert.equal(meta['dsh-go/view'], undefined)
|
|
38
|
+
assert.deepEqual(meta.segments, value.segments)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('expired or invalid image files degrade to readable text without a broken image', async (t) => {
|
|
42
|
+
const { dir, media } = await fixture(t)
|
|
43
|
+
for (const data of [null, Buffer.from('not jpeg'), Buffer.alloc(1048577)]) {
|
|
44
|
+
if (data) await writeFile(join(media, filename), data)
|
|
45
|
+
const meta = resultMeta({}, value, dir)
|
|
46
|
+
assert.equal(meta['dsh-go/media'], undefined)
|
|
47
|
+
assert.deepEqual(meta['dsh-go/view'].children, [{ type: 'text', text: '测试摘要' }])
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('chat image metadata cannot reference traversal paths or symlinks outside media directory', async (t) => {
|
|
52
|
+
const { dir, media } = await fixture(t)
|
|
53
|
+
const outside = join(dir, 'outside.jpg')
|
|
54
|
+
await writeFile(outside, header())
|
|
55
|
+
await symlink(outside, join(media, filename))
|
|
56
|
+
assert.equal(resultMeta({}, value, dir)['dsh-go/media'], undefined)
|
|
57
|
+
const traversed = { ...value, segments: [{ type: 'image', native_image: '../../outside.jpg' }] }
|
|
58
|
+
assert.equal(resultMeta({}, traversed, dir)['dsh-go/media'], undefined)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('nested image results retain their file dimensions in the chat card', async (t) => {
|
|
62
|
+
const { dir, media } = await fixture(t)
|
|
63
|
+
await writeFile(join(media, filename), header(876, 4096))
|
|
64
|
+
const nested = { ...value, segments: [{ type: 'node', items: value.segments }] }
|
|
65
|
+
const item = resultMeta({}, nested, dir)['dsh-go/media'].items[0]
|
|
66
|
+
assert.equal(item.width, 876)
|
|
67
|
+
assert.equal(item.height, 4096)
|
|
68
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import { registerNative } from '../lib/host/native.js'
|
|
6
|
+
import { nativeResultView, plainSummary } from '../lib/host/native-result.js'
|
|
7
|
+
import { makeResultStore, resultText } from '../lib/host/results.js'
|
|
8
|
+
|
|
9
|
+
const kit = process.env.DSH_GO_KIT
|
|
10
|
+
const scope = { sessionId: 'native-test-session' }
|
|
11
|
+
const caps = { components: ['form', 'gallery'] }
|
|
12
|
+
const groups = [{ name: '服务配置', items: [
|
|
13
|
+
{ key: 'image_ocr', title: 'OCR', desc: '图片摘要', type: 'BoolConfig', value: true },
|
|
14
|
+
{ key: 'WavesToken', title: 'Token', desc: '从文件导入', type: 'StrConfig', value: '已设置', secret: true, is_set: true },
|
|
15
|
+
{ key: 'integer', title: '整数', desc: '', type: 'IntConfig', value: 1 },
|
|
16
|
+
] }]
|
|
17
|
+
|
|
18
|
+
function fixture() {
|
|
19
|
+
const panels = new Map()
|
|
20
|
+
const calls = []
|
|
21
|
+
const ctx = { dshGo: { panel: (id, def) => panels.set(id, def) }, inject: (_deps, callback) => callback(ctx) }
|
|
22
|
+
const client = { async call(path, opts = {}) {
|
|
23
|
+
calls.push({ path, ...opts })
|
|
24
|
+
if (path === '/api/config' && !opts.method) return { groups }
|
|
25
|
+
if (path === '/api/config') return { ok: true }
|
|
26
|
+
return { ok: true, summary: '体力 120/240', segments: [] }
|
|
27
|
+
} }
|
|
28
|
+
const results = makeResultStore()
|
|
29
|
+
registerNative(ctx, client, results)
|
|
30
|
+
return { panels, calls, results, ctx }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('native tool cards use safe local images and preserve useful fallback text', () => {
|
|
34
|
+
const data = { ok: true, summary: '[图文卡片已在界面中展示]\n卡片内容: 体力120/240', segments: [
|
|
35
|
+
{ type: 'html', native_image: '1234567890abcdef1234.native.jpg' },
|
|
36
|
+
{ type: 'image', native_image: '../config.json' },
|
|
37
|
+
] }
|
|
38
|
+
const view = nativeResultView(data)
|
|
39
|
+
assert.equal(view.children.filter((c) => c.type === 'image').length, 1)
|
|
40
|
+
assert.equal(view.children[0].src, 'asset:1234567890abcdef1234.native.jpg')
|
|
41
|
+
assert.match(resultText('查询', data), /120\/240/)
|
|
42
|
+
assert.doesNotMatch(plainSummary(data), /已在界面/)
|
|
43
|
+
assert.ok(Buffer.byteLength(JSON.stringify(nativeResultView({ summary: '长'.repeat(10000) }))) <= 8000)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('native query action uses deferred results and keeps the result scoped to its session', async () => {
|
|
47
|
+
const { panels, calls, results } = fixture()
|
|
48
|
+
const query = panels.get('waves-home').actions.query(scope, { value: { values: { 'data.form.command': 'ww体力' } } })
|
|
49
|
+
assert.equal(query.pending.timeoutMs, 60000)
|
|
50
|
+
const result = await query.result(new AbortController().signal)
|
|
51
|
+
assert.match(result.patch.summary, /120\/240/)
|
|
52
|
+
assert.equal(calls[0].body.native, true)
|
|
53
|
+
assert.equal(calls[0].body.session_id, scope.sessionId)
|
|
54
|
+
assert.equal(results.latest(scope.sessionId).command, 'ww体力')
|
|
55
|
+
assert.equal(results.latest('another-session'), undefined)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('settings validates identifiers and values and never renders a secret input', async () => {
|
|
59
|
+
const { panels, calls } = fixture()
|
|
60
|
+
const settings = panels.get('waves-settings')
|
|
61
|
+
const edit = panels.get('waves-edit')
|
|
62
|
+
await settings.actions.edit(scope, { value: { group: '服务配置', key: 'WavesToken' } })
|
|
63
|
+
const view = await edit.view(scope, caps)
|
|
64
|
+
assert.doesNotMatch(JSON.stringify(view), /textField|data.form.value/)
|
|
65
|
+
const secretSave = await edit.actions.save(scope, { value: { values: { 'data.form.value': 'must-not-save' } } }).result(new AbortController().signal)
|
|
66
|
+
assert.match(secretSave.toast, /文件导入/)
|
|
67
|
+
assert.equal(calls.filter((c) => c.method === 'POST').length, 0)
|
|
68
|
+
assert.ok((await settings.actions.edit(scope, { value: { group: '__proto__', key: 'bad' } })).toast)
|
|
69
|
+
await settings.actions.edit(scope, { value: { group: '服务配置', key: 'integer' } })
|
|
70
|
+
const rejected = await edit.actions.save(scope, { value: { values: { 'data.form.value': 1.5 } } }).result(new AbortController().signal)
|
|
71
|
+
assert.match(rejected.toast, /有效数字/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('opening a second setting resets the cached editor feed before navigation', async () => {
|
|
75
|
+
const { panels } = fixture()
|
|
76
|
+
const editorFeed = { form: { value: 'stale-value' }, notice: 'stale-notice' }
|
|
77
|
+
const effects = await panels.get('waves-settings').actions.edit(scope, { value: { group: '服务配置', key: 'integer' } })
|
|
78
|
+
assert.equal(effects[0].type, 'patch')
|
|
79
|
+
assert.equal(effects[0].id, 'waves-edit')
|
|
80
|
+
Object.assign(editorFeed, effects[0].data)
|
|
81
|
+
assert.equal(editorFeed.form.value, 1)
|
|
82
|
+
assert.equal(editorFeed.notice, '')
|
|
83
|
+
assert.equal(effects[1].open, 'panel:waves-edit')
|
|
84
|
+
const secretEffects = await panels.get('waves-settings').actions.edit(scope, { value: { group: '服务配置', key: 'WavesToken' } })
|
|
85
|
+
assert.equal(secretEffects[0].data.form.value, '')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('credential files are consumed only during import and never returned in effects', async () => {
|
|
89
|
+
const { panels, calls, ctx } = fixture()
|
|
90
|
+
const marker = 'dummy-test-value-not-a-real-token'
|
|
91
|
+
ctx.dshGo.readPickedFile = (handle) => { assert.equal(handle, 'test-file'); return Buffer.from(marker) }
|
|
92
|
+
await panels.get('waves-settings').actions.edit(scope, { value: { group: '服务配置', key: 'WavesToken' } })
|
|
93
|
+
const result = await panels.get('waves-edit').actions['import-file'](scope, { value: { status: 'selected', files: [{ handle: 'test-file' }] } })
|
|
94
|
+
const saved = calls.find((call) => call.method === 'POST')
|
|
95
|
+
assert.equal(saved.body.value, marker)
|
|
96
|
+
assert.doesNotMatch(JSON.stringify(result), new RegExp(marker))
|
|
97
|
+
assert.equal(result.patch.form.value, '')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('latest result follows replacement recency across native and slash commands', () => {
|
|
101
|
+
const store = makeResultStore()
|
|
102
|
+
store.put('native:s', { sessionId: 's', summary: 'first' })
|
|
103
|
+
store.put('slash', { sessionId: 's', summary: 'second' })
|
|
104
|
+
store.put('native:s', { sessionId: 's', summary: 'third' })
|
|
105
|
+
assert.equal(store.latest('s').summary, 'third')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('native views and tool card pass the installed App protocol checker', { skip: !kit }, async () => {
|
|
109
|
+
const { checkView } = await import(pathToFileURL(kit + '/lib/view-check.js').href)
|
|
110
|
+
const { panels } = fixture()
|
|
111
|
+
for (const [id, panel] of panels) {
|
|
112
|
+
const view = await panel.view(scope, caps)
|
|
113
|
+
const checked = checkView(view, { profile: 'panel', data: { summary: '测试', images: [], form: { command: '', value: true }, notice: '' } })
|
|
114
|
+
assert.deepEqual(checked.issues.filter((issue) => issue.kind !== 'note'), [], `${id}: ${JSON.stringify(checked.issues)}`)
|
|
115
|
+
}
|
|
116
|
+
const image = { type: 'html', native_image: '1234567890abcdef1234.native.jpg' }
|
|
117
|
+
const value = { ok: true, summary: '体力120/240', segments: [image] }
|
|
118
|
+
const card = checkView(nativeResultView(value), { profile: 'toolCard' })
|
|
119
|
+
assert.deepEqual(card.issues.filter((issue) => !(issue.kind === 'note' && issue.path === 'view.accent')), [])
|
|
120
|
+
const { panels: imagePanels, results } = fixture()
|
|
121
|
+
results.put('image-test', { ...value, sessionId: scope.sessionId })
|
|
122
|
+
const home = imagePanels.get('waves-home')
|
|
123
|
+
const patch = home.actions.latest(scope).patch
|
|
124
|
+
assert.equal(patch.images[0].src, 'asset:' + image.native_image)
|
|
125
|
+
const gallery = checkView(await home.view(scope, caps), { profile: 'panel', data: patch })
|
|
126
|
+
assert.deepEqual(gallery.issues.filter((issue) => !(issue.kind === 'note' && issue.path === 'view.accent')), [])
|
|
127
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Opt-in checks against a real DSH installation, without enabling the plugin.
|
|
2
|
+
// DSH_TEST_RUNTIME=/opt/dsh npm test
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { createRequire, registerHooks } from 'node:module'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { resolve } from 'node:path'
|
|
7
|
+
import { pathToFileURL } from 'node:url'
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
|
|
10
|
+
const runtime = process.env.DSH_TEST_RUNTIME
|
|
11
|
+
|
|
12
|
+
test('installed DSH accepts version ranges and executes both Waves tool definitions', {
|
|
13
|
+
skip: runtime ? false : 'Set DSH_TEST_RUNTIME to the DSH installation directory',
|
|
14
|
+
}, async () => {
|
|
15
|
+
const require = createRequire(pathToFileURL(resolve(runtime, 'package.json')))
|
|
16
|
+
const semver = require('semver')
|
|
17
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
18
|
+
assert.ok(semver.satisfies(process.versions.node, manifest.engines.node))
|
|
19
|
+
for (const [name, range] of Object.entries(manifest.peerDependencies)) {
|
|
20
|
+
const installed = require(`${name}/package.json`)
|
|
21
|
+
assert.ok(semver.satisfies(installed.version, range), `${name}@${installed.version} is outside ${range}`)
|
|
22
|
+
if (name === '@deepseek-ai/dsh-tools') {
|
|
23
|
+
assert.ok(semver.satisfies(installed.version, manifest.engines.dsh))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Resolve peer imports from the selected installation only for plugin modules.
|
|
28
|
+
// No symlinks, dependency installation, profile changes or service startup.
|
|
29
|
+
const pluginRoot = new URL('../lib/', import.meta.url).href
|
|
30
|
+
const hooks = registerHooks({
|
|
31
|
+
resolve(specifier, context, nextResolve) {
|
|
32
|
+
if (specifier.startsWith('@deepseek-ai/') && context.parentURL?.startsWith(pluginRoot)) {
|
|
33
|
+
return nextResolve(pathToFileURL(require.resolve(specifier)).href, context)
|
|
34
|
+
}
|
|
35
|
+
return nextResolve(specifier, context)
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
try {
|
|
39
|
+
const { registerTool } = await import('../lib/host/tool.js')
|
|
40
|
+
const { apply } = await import('../lib/index.js')
|
|
41
|
+
assert.equal(typeof apply, 'function')
|
|
42
|
+
const tools = []
|
|
43
|
+
const calls = []
|
|
44
|
+
const client = { async call(path, options) {
|
|
45
|
+
calls.push({ path, options })
|
|
46
|
+
return path === '/api/health' ? { ok: true } : {
|
|
47
|
+
ok: true, summary: '查询结果', segments: [{ type: 'text', text: '体力 120/240' }],
|
|
48
|
+
}
|
|
49
|
+
} }
|
|
50
|
+
registerTool({ tools: { register: (tool) => tools.push(tool) } }, client, resolve(runtime, 'waves-test-service-not-created'))
|
|
51
|
+
assert.deepEqual(tools.map((tool) => tool.name), ['waves', 'waves_panel'])
|
|
52
|
+
const signal = new AbortController().signal
|
|
53
|
+
for (const [index, args] of [{ command: 'ww体力' }, { char: '椿' }].entries()) {
|
|
54
|
+
const tool = tools[index]
|
|
55
|
+
const value = await tool.execute(args, { agent: { id: 'compat-session' }, signal })
|
|
56
|
+
assert.equal(value.ok, true)
|
|
57
|
+
assert.deepEqual(tool.output.render(args, value), [{ type: 'text', text: '查询结果' }])
|
|
58
|
+
assert.deepEqual(tool.output.presentationMeta(args, value).segments, value.segments)
|
|
59
|
+
assert.equal(tool.presentCall(args).card, 'generic')
|
|
60
|
+
}
|
|
61
|
+
assert.deepEqual(calls.map(({ path }) => path), ['/api/health', '/api/chat', '/api/health', '/api/render/panel'])
|
|
62
|
+
assert.equal(calls[1].options.body.session_id, 'compat-session')
|
|
63
|
+
assert.equal(calls[3].options.body.char, '椿')
|
|
64
|
+
await assert.rejects(tools[0].execute({}, { signal }))
|
|
65
|
+
await assert.rejects(tools[1].execute({ char: 123 }, { signal }))
|
|
66
|
+
} finally {
|
|
67
|
+
hooks.deregister()
|
|
68
|
+
}
|
|
69
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import { bootstrap, waitReady } from '../lib/host/service.js'
|
|
7
|
+
|
|
8
|
+
async function fixture(t, script) {
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), 'waves-setup-'))
|
|
10
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
11
|
+
const service = join(root, 'service')
|
|
12
|
+
await mkdir(service)
|
|
13
|
+
await writeFile(join(root, 'install.sh'), script)
|
|
14
|
+
return service
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('bootstrap stays asynchronous and returns installation failure', { skip: process.platform === 'win32' }, async (t) => {
|
|
18
|
+
const service = await fixture(t, '#!/bin/sh\nsleep 0.1\nexit 7\n')
|
|
19
|
+
let ticked = false
|
|
20
|
+
setImmediate(() => { ticked = true })
|
|
21
|
+
const result = await bootstrap(service, { timeoutMs: 2000 })
|
|
22
|
+
assert.equal(ticked, true)
|
|
23
|
+
assert.equal(result.ok, false)
|
|
24
|
+
assert.match(result.reason, /7/)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('bootstrap is bounded and can be canceled', { skip: process.platform === 'win32' }, async (t) => {
|
|
28
|
+
const service = await fixture(t, '#!/bin/sh\nsleep 30\n')
|
|
29
|
+
const timed = await bootstrap(service, { timeoutMs: 30 })
|
|
30
|
+
assert.equal(timed.ok, false)
|
|
31
|
+
assert.match(timed.reason, /超时/)
|
|
32
|
+
const controller = new AbortController()
|
|
33
|
+
controller.abort()
|
|
34
|
+
assert.deepEqual(await bootstrap(service, { signal: controller.signal }), { ok: false, reason: '已取消安装' })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('bootstrap reports success only for a successful installer', { skip: process.platform === 'win32' }, async (t) => {
|
|
38
|
+
const service = await fixture(t, '#!/bin/sh\nexit 0\n')
|
|
39
|
+
assert.equal((await bootstrap(service, { timeoutMs: 2000 })).ok, true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('waitReady requires affirmative health and honors cancellation', async () => {
|
|
43
|
+
assert.equal(await waitReady({ call: async () => ({ ok: false }) }, 0), false)
|
|
44
|
+
assert.equal(await waitReady({ call: async () => ({ ok: true }) }, 1), true)
|
|
45
|
+
const controller = new AbortController()
|
|
46
|
+
controller.abort()
|
|
47
|
+
assert.equal(await waitReady({ call: () => { throw Error('must not call') } }, 1, controller.signal), false)
|
|
48
|
+
})
|