@sophiacyl/dsh-plugin-waves 0.0.0-stage → 0.1.0-beta.1

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.
Files changed (140) hide show
  1. package/DSH-GO-VERIFICATION.md +57 -0
  2. package/ICON.png +0 -0
  3. package/LICENSE +674 -0
  4. package/README.md +251 -2
  5. package/cordis.patch.yml +8 -0
  6. package/install.ps1 +0 -0
  7. package/install.sh +209 -0
  8. package/lib/client.js +1511 -0
  9. package/lib/host/aliashint.js +70 -0
  10. package/lib/host/attachments.js +25 -0
  11. package/lib/host/command.js +34 -0
  12. package/lib/host/consts.js +16 -0
  13. package/lib/host/dataguide.js +35 -0
  14. package/lib/host/imagebridge.js +41 -0
  15. package/lib/host/native-result.js +91 -0
  16. package/lib/host/native.js +191 -0
  17. package/lib/host/proxy.js +59 -0
  18. package/lib/host/results.js +67 -0
  19. package/lib/host/service.js +263 -0
  20. package/lib/host/tool.js +185 -0
  21. package/lib/host/troubleshoot.js +31 -0
  22. package/lib/host/worldbook.js +80 -0
  23. package/lib/index.js +74 -0
  24. package/locale/en.json +6 -0
  25. package/locale/zh.json +6 -0
  26. package/package.json +136 -4
  27. package/service/ICON.png +0 -0
  28. package/service/ai/AI_DATA.md +221 -0
  29. package/service/knowledge/additional/manual.jsonl +5 -0
  30. package/service/knowledge/wiki/baike.jsonl +1231 -0
  31. package/service/knowledge/wiki/chengjiu.jsonl +36 -0
  32. package/service/knowledge/wiki/gonglve.jsonl +383 -0
  33. package/service/knowledge/wiki/huodong.jsonl +427 -0
  34. package/service/knowledge/wiki/renwu.jsonl +412 -0
  35. package/service/knowledge/wiki/tujian.jsonl +2889 -0
  36. package/service/knowledge/wiki/zixun.jsonl +47 -0
  37. package/service/plugins/__init__.py +0 -0
  38. package/service/pyproject.toml +40 -0
  39. package/service/rover/__init__.py +0 -0
  40. package/service/rover/ai_core/__init__.py +0 -0
  41. package/service/rover/ai_core/models.py +50 -0
  42. package/service/rover/ai_core/rag/__init__.py +5 -0
  43. package/service/rover/ai_core/rag/knowledge.py +23 -0
  44. package/service/rover/ai_core/register.py +86 -0
  45. package/service/rover/ai_core/resource.py +6 -0
  46. package/service/rover/ai_core/skills/__init__.py +0 -0
  47. package/service/rover/ai_core/skills/operations.py +8 -0
  48. package/service/rover/api.py +720 -0
  49. package/service/rover/api_render.py +161 -0
  50. package/service/rover/app_life.py +154 -0
  51. package/service/rover/aps.py +47 -0
  52. package/service/rover/avatar.py +85 -0
  53. package/service/rover/bot.py +133 -0
  54. package/service/rover/builtin.py +152 -0
  55. package/service/rover/cards/__init__.py +26 -0
  56. package/service/rover/cards/_shared.py +129 -0
  57. package/service/rover/cards/char_list.py +258 -0
  58. package/service/rover/cards/char_panel.py +1353 -0
  59. package/service/rover/cards/echo_list.py +206 -0
  60. package/service/rover/cards/matrix_roles.py +25 -0
  61. package/service/rover/cards/rank_boards.py +714 -0
  62. package/service/rover/cards/rank_total.py +664 -0
  63. package/service/rover/compat.py +215 -0
  64. package/service/rover/config.py +163 -0
  65. package/service/rover/custom_panel.py +220 -0
  66. package/service/rover/data_store.py +31 -0
  67. package/service/rover/gss.py +19 -0
  68. package/service/rover/handler.py +191 -0
  69. package/service/rover/help/__init__.py +0 -0
  70. package/service/rover/help/draw_new_plugin_help.py +332 -0
  71. package/service/rover/help/model.py +16 -0
  72. package/service/rover/help/utils.py +7 -0
  73. package/service/rover/hooks.py +420 -0
  74. package/service/rover/html_actions.py +236 -0
  75. package/service/rover/i18n.py +19 -0
  76. package/service/rover/knowledge.py +235 -0
  77. package/service/rover/logger.py +102 -0
  78. package/service/rover/main.py +33 -0
  79. package/service/rover/media.py +222 -0
  80. package/service/rover/message_models.py +23 -0
  81. package/service/rover/models.py +92 -0
  82. package/service/rover/native_media.py +64 -0
  83. package/service/rover/ocr.py +79 -0
  84. package/service/rover/pool.py +43 -0
  85. package/service/rover/restart.py +36 -0
  86. package/service/rover/segment.py +143 -0
  87. package/service/rover/server.py +131 -0
  88. package/service/rover/status/__init__.py +0 -0
  89. package/service/rover/status/models.py +10 -0
  90. package/service/rover/status/plugin_status.py +17 -0
  91. package/service/rover/subscribe.py +148 -0
  92. package/service/rover/sv.py +403 -0
  93. package/service/rover/templates/char_list.html +182 -0
  94. package/service/rover/templates/char_panel.html +465 -0
  95. package/service/rover/templates/echo_list.html +221 -0
  96. package/service/rover/templates/rank_all_total.html +193 -0
  97. package/service/rover/templates/rank_board.html +107 -0
  98. package/service/rover/templates/rank_level_total.html +57 -0
  99. package/service/rover/templates/rank_matrix_total.html +66 -0
  100. package/service/rover/templates/rank_phantom_total.html +180 -0
  101. package/service/rover/templates/rank_slash_total.html +38 -0
  102. package/service/rover/trigger.py +98 -0
  103. package/service/rover/utils/__init__.py +0 -0
  104. package/service/rover/utils/boardcast/__init__.py +0 -0
  105. package/service/rover/utils/boardcast/models.py +15 -0
  106. package/service/rover/utils/cookie_manager/__init__.py +0 -0
  107. package/service/rover/utils/cookie_manager/qrlogin.py +24 -0
  108. package/service/rover/utils/database/__init__.py +0 -0
  109. package/service/rover/utils/database/base_models.py +1771 -0
  110. package/service/rover/utils/database/models.py +80 -0
  111. package/service/rover/utils/database/startup.py +60 -0
  112. package/service/rover/utils/default_bg/bg.jpg +0 -0
  113. package/service/rover/utils/download_resource/__init__.py +0 -0
  114. package/service/rover/utils/download_resource/download_core.py +164 -0
  115. package/service/rover/utils/download_resource/download_file.py +46 -0
  116. package/service/rover/utils/fonts/__init__.py +0 -0
  117. package/service/rover/utils/fonts/fonts.py +33 -0
  118. package/service/rover/utils/image/__init__.py +0 -0
  119. package/service/rover/utils/image/convert.py +51 -0
  120. package/service/rover/utils/image/image_tools.py +30 -0
  121. package/service/rover/utils/image/utils.py +70 -0
  122. package/service/rover/utils/plugins_config/__init__.py +0 -0
  123. package/service/rover/utils/plugins_config/gs_config.py +506 -0
  124. package/service/rover/utils/plugins_config/models.py +182 -0
  125. package/service/rover/web_app.py +3 -0
  126. package/service/rover/webconsole/__init__.py +0 -0
  127. package/service/rover/webconsole/mount_app.py +26 -0
  128. package/service/tools/migrate.py +351 -0
  129. package/service/tools/regression.py +87 -0
  130. package/service/tools/smoke.py +63 -0
  131. package/service/tools/update_plugins.py +212 -0
  132. package/service/tools/verify_actions.py +104 -0
  133. package/service/tools/verify_card.py +82 -0
  134. package/service/tools/wwdata.py +665 -0
  135. package/tests/compat.test.js +181 -0
  136. package/tests/imagebridge.test.js +58 -0
  137. package/tests/media.test.js +68 -0
  138. package/tests/native.test.js +127 -0
  139. package/tests/runtime.test.js +69 -0
  140. package/tests/service.test.js +48 -0
@@ -0,0 +1,70 @@
1
+ // 用户话里出现鸣潮别名时, 在这一步的上下文尾部补一条对照。
2
+ // 只给线索不下断言: 别名匹配是子串命中, 完全可能是误判, 由模型自己权衡。
3
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
4
+
5
+ const PLUGIN = 'waves-alias'
6
+ const MAX_CHARS = 500
7
+
8
+ function textOf(messages) {
9
+ const out = []
10
+ for (const message of messages || []) {
11
+ for (const block of message?.content || []) {
12
+ if (block?.type === 'text' && block.text) out.push(block.text)
13
+ }
14
+ }
15
+ return out.join('\n').slice(0, 2000)
16
+ }
17
+
18
+ function render(hits) {
19
+ const items = hits.map((h) => `${h.alias}→${h.name}(${h.kind})`).join('、')
20
+ return [
21
+ `鸣潮(Wuthering Waves)的本地别名表里有这些相近词条:${items}。`,
22
+ '这些是鸣潮的角色/武器/声骸/套装名,别按其他游戏的同名内容理解。',
23
+ '仅按字面子串匹配得到,可能与用户实际所指无关;用得上再用,别当成用户已经确认的指代。',
24
+ ]
25
+ .join('\n')
26
+ .slice(0, MAX_CHARS)
27
+ }
28
+
29
+ export function registerAliasHint(ctx, client) {
30
+ ctx.inject(['agents'], (actx) => {
31
+ actx.on(
32
+ 'agent/pre-step',
33
+ async (payload, next) => {
34
+ const decision = await next()
35
+ if (decision.kind === 'reject' || payload.signal?.aborted) return decision
36
+ // 只在用户刚说完话的那一步补, 工具往返的后续步骤不重复注入
37
+ if (payload.step !== 1) return decision
38
+ const text = textOf(decision.messages)
39
+ if (!text) return decision
40
+ let hits = []
41
+ try {
42
+ const data = await client.call(
43
+ `/api/alias/lookup?text=${encodeURIComponent(text)}`,
44
+ { timeoutMs: 1500, signal: payload.signal },
45
+ )
46
+ hits = (data && data.hits) || []
47
+ } catch {
48
+ return decision
49
+ }
50
+ if (!hits.length) return decision
51
+ const note = render(hits)
52
+ return {
53
+ kind: 'enter',
54
+ messages: [
55
+ ...decision.messages,
56
+ createUserMessage({
57
+ content: [{ type: 'text', text: note }],
58
+ source: {
59
+ kind: PLUGIN,
60
+ form: 'snapshot',
61
+ sections: [{ name: PLUGIN, text: note }],
62
+ },
63
+ }),
64
+ ],
65
+ }
66
+ },
67
+ { prepend: true },
68
+ )
69
+ })
70
+ }
@@ -0,0 +1,25 @@
1
+ // 将 DSH 已接收的附件交给鸣潮服务;读取失败直接报错,让输入框保留附件。
2
+ export async function readAttachments(ctx, blocks, signal) {
3
+ const store = ctx.get('attachments')
4
+ const images = []
5
+ const files = (blocks || []).filter((block) => block.type === 'file')
6
+ if (files.length > 1) throw new Error('鸣潮命令一次只接受一个文件')
7
+ if (blocks?.length && !store) throw new Error('DSH 附件服务不可用')
8
+ let file
9
+ for (const block of blocks || []) {
10
+ if (block.type === 'image') {
11
+ const { data } = await store.readImage(block.attachment, signal)
12
+ images.push('base64://' + Buffer.from(data).toString('base64'))
13
+ } else if (block.type === 'file') {
14
+ const chunks = []
15
+ for await (const chunk of store.readFileStream(block.attachment, signal)) {
16
+ chunks.push(Buffer.from(chunk))
17
+ }
18
+ file = {
19
+ file_name: block.attachment.name,
20
+ file_data: Buffer.concat(chunks).toString('base64'),
21
+ }
22
+ }
23
+ }
24
+ return { images, ...file }
25
+ }
@@ -0,0 +1,34 @@
1
+ // /ww 斜杠命令: 直接打服务, 不经过模型
2
+ import { SLASH_USAGE } from './consts.js'
3
+ import { resultText } from './results.js'
4
+
5
+ export function parseSlashInput(rawInput) {
6
+ let command = String(rawInput || '').trim()
7
+ let scene = 'group'
8
+ const flag = /^(-p|--private)(\s+|$)/.exec(command)
9
+ if (flag) {
10
+ scene = 'direct'
11
+ command = command.slice(flag[0].length).trim()
12
+ }
13
+ return { command, scene }
14
+ }
15
+
16
+ // 斜杠命令直接打服务, 不经过模型
17
+ export async function runSlashCommand(client, results, invocation, readAttachments = async () => ({})) {
18
+ const { command, scene } = parseSlashInput(invocation.rawInput)
19
+ if (!command) return { kind: 'error', text: SLASH_USAGE }
20
+ try {
21
+ const attachments = await readAttachments(invocation.attachments || [], invocation.signal)
22
+ const data = await client.call('/api/chat', {
23
+ method: 'POST',
24
+ body: { command, scene, session_id: invocation.agent?.id || 'dsh', ...attachments },
25
+ signal: invocation.signal,
26
+ })
27
+ if (!data?.ok) return { kind: 'error', text: data?.summary || `执行失败: ${command}` }
28
+ results.put(invocation.commandId, { command, scene, sessionId: invocation.agent?.id, ...data })
29
+ return { kind: 'success', text: resultText(command, data) }
30
+ } catch (e) {
31
+ if (invocation.signal?.aborted) return { kind: 'error', text: `已取消: ${command}` }
32
+ return { kind: 'error', text: `${command} 执行失败: ${String(e?.message || e)}` }
33
+ }
34
+ }
@@ -0,0 +1,16 @@
1
+ // 共享常量
2
+ export const PKG_NAME = '@sophiacyl/dsh-plugin-waves'
3
+
4
+ export const PROXY_PREFIX = '/waves-api'
5
+
6
+ // 命令卡片按 commandId 回取图文段的路径; 结果带落盘, 重启后仍能还原
7
+ export const RESULT_PATH = '/host/results/'
8
+ // 会话是否已经真的建起来了; 闪屏上还没有会话, 命令下不去
9
+ export const SESSION_PATH = '/host/session/'
10
+ export const RESULT_KEEP = 200
11
+
12
+ export const SLASH_USAGE = [
13
+ '用法: /ww <命令>',
14
+ '例: /ww 查询 · /ww 深塔 · /ww 椿面板 · /ww 签到 · /ww 帮助',
15
+ '命令不带 ww 前缀会自动补; 开头加 -p 按私聊执行(如 /ww -p 获取ck)',
16
+ ].join('\n')
@@ -0,0 +1,35 @@
1
+ // 开了"AI 可使用鸣潮插件"时一并给出的数据地图。
2
+ // 只放常驻部分: 够模型判断"要不要深挖", 细节让它按需去读 service/ai/AI_DATA.md, 省上下文也吃得到缓存
3
+ import path from 'node:path'
4
+
5
+ import { venvPython } from './service.js'
6
+
7
+ export function dataGuide(serviceDir) {
8
+ const service = serviceDir || '.'
9
+ const data = path.join(service, 'data')
10
+ const ww = path.join(data, 'XutheringWavesUID')
11
+ return [
12
+ '',
13
+ '除了下命令出图,你还可以直接读本地数据、写脚本自己算(用户问"最优/怎么分配/帮我规划"这类',
14
+ '需要跨角色统筹的问题时就该这么做):',
15
+ `- 只读库 ${path.join(service, 'tools', 'wwdata.py')}(跑脚本一律用服务自己的解释器`,
16
+ ` ${venvPython(service)},系统 python 缺依赖):`,
17
+ ' default_uid() / roles(uid) 角色与练度 /',
18
+ ' echoes(uid, sonata=) 声骸 / score(uid, 角色, 声骸) 按该角色评分模板打分 /',
19
+ ' cross_scores()、swap_gains() 同套装跨角色换装推演 / matrix_summary() 矩阵战绩 /',
20
+ ' resolve() 别名转正式名 / id2name() / char_info(角色) 图鉴文本(技能/链, 已净化标记) /',
21
+ ' kb(查询)、kb_get(id) 游戏常识库(库洛 wiki 全站文本: 机制/攻略/图鉴/活动)',
22
+ `- 玩家原始数据 ${path.join(ww, 'players')}/<特征码>/:rawData.json.gz 面板、matrixData/slashData/`,
23
+ ' rover.json.gz 挑战、gacha_logs.json.gz 抽卡、baseInfo.json 概览(.gz 要先解压)',
24
+ `- 静态资料 ${path.join(ww, 'resource', 'map')}/:CharId2Data.json 角色 id↔名、detail_json/ 角色武器声骸详情`,
25
+ `- 字段清单、两类统筹场景的完整做法,看 ${path.join(service, 'ai', 'AI_DATA.md')}(按需读,别整份贴)`,
26
+ '规矩:',
27
+ `- ${data} 只读,不写不删不"顺手修"。要落中间结果就用 wwdata.scratch()(系统临时目录下),`,
28
+ ' 沙箱只放行临时目录和工作区。改用户数据只能由用户自己发命令或在设置页操作。',
29
+ '- sqlite 一律 file:...?mode=ro&immutable=1 只读打开,服务正在写,读写连接会损坏库。',
30
+ '- 数据可能过期:挑战数据看 record_time,面板看文件 mtime。旧了先用本工具跑对应命令刷新',
31
+ ' (面板 ww刷新面板 / 矩阵 ww矩阵 / 深塔 ww深塔 / 海墟 ww海墟 / 抽卡 ww抽卡记录 / 概览 ww查询),再读。',
32
+ '- 插件已有的分析别重写:伤害 ww<角色>伤害、词条优化 ww<角色>优化、练度 ww练度、排行 ww<角色>总排行。',
33
+ '- 想把重组/转录的声骸渲成完整面板卡给用户看,用 waves_panel 工具(只本地渲染,不上传排行)。',
34
+ ].join('\n')
35
+ }
@@ -0,0 +1,41 @@
1
+ // Observe attachments for an explicitly requested Waves image operation.
2
+ // Never change model capabilities, message content, or the decision returned by
3
+ // the host. Text-only model admission remains the host's responsibility; users
4
+ // can use /ww with attachments without sending images through a text model.
5
+ const pending = new Map()
6
+ const MAX_SESSIONS = 64
7
+ const MAX_IMAGES = 16
8
+
9
+ export function takeImages(agentId) {
10
+ const refs = pending.get(agentId) ?? []
11
+ pending.delete(agentId)
12
+ return refs
13
+ }
14
+
15
+ export function registerImageBridge(ctx) {
16
+ ctx.inject(['agents'], (actx) => {
17
+ actx.on('agent/pre-step', async (payload, next) => {
18
+ const decision = await next()
19
+ const agentId = payload.agent.id
20
+ pending.delete(agentId)
21
+ if (decision.kind === 'reject' || payload.signal?.aborted) return decision
22
+ const refs = []
23
+ for (const message of decision.messages || []) {
24
+ if (message?.role !== 'user') continue
25
+ if (message.source?.kind && message.source.kind !== 'user') continue
26
+ for (const block of message.content || []) {
27
+ if (block?.type === 'image' && block.attachment && refs.length < MAX_IMAGES) {
28
+ refs.push(block.attachment)
29
+ }
30
+ }
31
+ }
32
+ if (refs.length) {
33
+ if (pending.size >= MAX_SESSIONS) pending.delete(pending.keys().next().value)
34
+ pending.set(agentId, refs)
35
+ }
36
+ return decision
37
+ }, { prepend: true })
38
+ actx.on('agent/disposed', (payload) => pending.delete(payload?.agent?.id))
39
+ actx.effect(() => () => pending.clear())
40
+ })
41
+ }
@@ -0,0 +1,91 @@
1
+ // Panel assets need a plugin owner; chat uses file-backed dsh-go/media.
2
+ import { readFileSync, statSync, realpathSync } from 'node:fs'
3
+ import { join, sep } from 'node:path'
4
+ import { createHash } from 'node:crypto'
5
+
6
+ const ASSETS = 'asset:'
7
+ const FILE = /^[a-f0-9]{20}\.native\.jpg$/
8
+
9
+ export function plainSummary(value) {
10
+ return String(value?.summary || (value?.ok ? '已执行' : '执行失败'))
11
+ .replace(/\[(?:图片|图文卡片)已在(?:卡片|界面)中展示\]\s*/g, '')
12
+ .trim() || '已执行,暂无文字摘要'
13
+ }
14
+
15
+ export function nativeResultView(value) {
16
+ const children = []
17
+ let count = 0
18
+ const walk = (segments, depth = 0) => {
19
+ if (depth > 5) return
20
+ for (const seg of (Array.isArray(segments) ? segments : []).slice(0, 40)) {
21
+ if (count >= 8) break
22
+ if (seg?.type === 'node') walk(seg.items, depth + 1)
23
+ else if (FILE.test(String(seg?.native_image || ''))) {
24
+ children.push({ type: 'image', src: ASSETS + seg.native_image, alt: '鸣潮图文结果' })
25
+ count++
26
+ }
27
+ }
28
+ }
29
+ walk(value?.segments)
30
+ const summary = plainSummary(value).slice(0, 1400)
31
+ children.push({ type: 'text', text: summary })
32
+ if (children.length === 1 && (value?.segments || []).some((s) => s.type === 'html' || s.type === 'image')) {
33
+ children.push({ type: 'callout', tone: 'info', text: '图片暂不可用,已保留文字摘要。' })
34
+ }
35
+ const view = { type: 'stack', title: '鸣潮', children }
36
+ return Buffer.byteLength(JSON.stringify(view)) <= 8000 ? view : {
37
+ type: 'stack', title: '鸣潮', children: [{ type: 'text', text: summary.slice(0, 800) }],
38
+ }
39
+ }
40
+
41
+ function jpegSize(bytes) {
42
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined
43
+ for (let offset = 2; offset + 9 < bytes.length;) {
44
+ if (bytes[offset] !== 0xff) return undefined
45
+ const marker = bytes[offset + 1]
46
+ const length = bytes.readUInt16BE(offset + 2)
47
+ if (length < 2 || offset + length + 2 > bytes.length) return undefined
48
+ if ([0xc0, 0xc1, 0xc2].includes(marker)) return {
49
+ width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5),
50
+ }
51
+ offset += length + 2
52
+ }
53
+ }
54
+
55
+ export function resultMeta(args, value, serviceDir) {
56
+ const base = { segments: value?.segments ?? [], matched: value?.matched ?? [] }
57
+ const items = []
58
+ if (serviceDir) {
59
+ try {
60
+ const root = realpathSync(join(serviceDir, 'data', 'media'))
61
+ const walk = (segments, depth = 0) => {
62
+ if (depth > 5) return
63
+ for (const segment of (Array.isArray(segments) ? segments : []).slice(0, 40)) {
64
+ if (items.length >= 8) break
65
+ if (segment?.type === 'node') walk(segment.items, depth + 1)
66
+ else if (FILE.test(String(segment?.native_image || ''))) {
67
+ try {
68
+ const path = realpathSync(join(root, segment.native_image))
69
+ if (!path.startsWith(root + sep)) continue
70
+ const stat = statSync(path)
71
+ if (!stat.isFile() || stat.size > 1048576) continue
72
+ const size = jpegSize(readFileSync(path))
73
+ if (size) items.push({ path, mime: 'image/jpeg', ...size, bytes: stat.size })
74
+ } catch { /* Missing/expired images fall back to the summary. */ }
75
+ }
76
+ }
77
+ }
78
+ walk(value?.segments)
79
+ } catch { /* Service media directory not initialized yet. */ }
80
+ }
81
+ if (items.length) return { ...base, 'dsh-go/media': {
82
+ v: 1,
83
+ rid: 'waves_' + createHash('sha256').update(items.map((item) => item.path).join('|')).digest('hex').slice(0, 24),
84
+ kind: 'image', status: 'done', items,
85
+ request: { provider: 'waves', model: 'local-render', label: '鸣潮', n: items.length },
86
+ prompt: String(args?.command || (args?.char ? args.char + '面板' : '鸣潮查询')).slice(0, 200),
87
+ } }
88
+ return { ...base, 'dsh-go/view': { type: 'stack', title: '鸣潮', children: [
89
+ { type: 'text', text: plainSummary(value).slice(0, 1400) },
90
+ ] } }
91
+ }
@@ -0,0 +1,191 @@
1
+ import { plainSummary } from './native-result.js'
2
+
3
+ const text = (value) => ({ type: 'text', text: String(value).slice(0, 1800) })
4
+ const action = (label, id, args) => ({ label, action: args ? { id, args } : id })
5
+ const canV2 = (caps) => caps?.components?.includes('form')
6
+ const keyOf = (scope) => scope.sessionId || 'global'
7
+ const editData = (item) => ({ form: { value: item.secret || item.upload && !item.upload.with_text ? '' :
8
+ item.type === 'DictConfig' ? JSON.stringify(item.value) : item.type.includes('List') ? (item.value || []).join(',') : item.value }, notice: '' })
9
+ const pending = (label, work) => ({ pending: { label, timeoutMs: 60000 }, result: async (signal) => {
10
+ try { return await work(signal) } catch { return { toast: '操作失败,请稍后重试' } }
11
+ } })
12
+
13
+ export function registerNative(ctx, client, results) {
14
+ ctx.inject(['dshGo'], (nctx) => {
15
+ // Keep only navigation identifiers, never secrets or user input. Bound memory.
16
+ const states = new Map()
17
+ const state = (scope) => {
18
+ const key = keyOf(scope)
19
+ if (!states.has(key)) {
20
+ if (states.size >= 64) states.delete(states.keys().next().value)
21
+ states.set(key, { group: 0, page: 0 })
22
+ }
23
+ return states.get(key)
24
+ }
25
+ const config = () => client.call('/api/config', { timeoutMs: 1800 })
26
+ const selected = async (scope) => {
27
+ const groups = (await config()).groups || []
28
+ const s = state(scope)
29
+ const group = groups.find((g) => g.name === s.editGroup)
30
+ const item = group?.items?.find((i) => i.key === s.editKey)
31
+ if (!item) throw Error('配置项不存在')
32
+ return { group, item }
33
+ }
34
+ const latest = (scope) => results.latest(scope.sessionId)
35
+ const resultPatch = (scope) => {
36
+ const value = latest(scope)
37
+ const images = []
38
+ const collect = (segments, depth = 0) => {
39
+ if (depth > 5) return
40
+ for (const seg of (segments || []).slice(0, 40)) {
41
+ if (seg?.type === 'node') collect(seg.items, depth + 1)
42
+ else if (/^[a-f0-9]{20}\.native\.jpg$/.test(seg?.native_image || '') && images.length < 8) {
43
+ images.push({ src: 'asset:' + seg.native_image, alt: '鸣潮结果' })
44
+ }
45
+ }
46
+ }
47
+ collect(value?.segments)
48
+ return { summary: value ? plainSummary(value).slice(0, 1800) : '暂无查询结果。可输入命令,或发送 /ww 查询。', images, form: { command: '' } }
49
+ }
50
+ const run = (scope, command) => pending('正在查询鸣潮', async (signal) => {
51
+ if (typeof command !== 'string' || !command.trim() || command.length > 500) return { toast: '请输入 1—500 字的命令' }
52
+ const value = await client.call('/api/chat', { method: 'POST', body: {
53
+ command: command.trim(), session_id: scope.sessionId || 'dsh', native: true,
54
+ }, signal, timeoutMs: 55000 })
55
+ results.put(`native:${keyOf(scope)}`, { ...value, command, sessionId: scope.sessionId })
56
+ return { patch: resultPatch(scope), toast: value.ok ? '查询完成' : '命令未完成,请查看结果' }
57
+ })
58
+
59
+ nctx.dshGo.panel('waves-home', {
60
+ view: (_scope, caps) => {
61
+ if (!canV2(caps)) return { type: 'stack', children: [text('请在对话中发送 /ww 查询;完整原生面板需要支持视图 v2 的 App。'),
62
+ { type: 'text', text: { $: 'data.summary' } }] }
63
+ return { v: 2, type: 'stack', children: [
64
+ { type: 'form', submit: 'query', submitLabel: '执行命令', children: [
65
+ { type: 'textField', label: '鸣潮命令', placeholder: '查询、体力、椿面板、帮助', bind: 'data.form.command', required: true, maxLength: 500 },
66
+ ] },
67
+ { type: 'actions', items: [action('帮助', 'help'), action('刷新结果', 'latest'), action('设置', 'settings')] },
68
+ { type: 'gallery', items: { $: 'data.images' }, columns: 2 },
69
+ { type: 'text', text: { $: 'data.summary' } },
70
+ ] }
71
+ },
72
+ data: (scope, emit) => { emit(resultPatch(scope)); return () => {} },
73
+ actions: {
74
+ query: (scope, event) => run(scope, event.value?.values?.['data.form.command']),
75
+ help: (scope) => run(scope, 'ww帮助'),
76
+ latest: (scope) => ({ patch: resultPatch(scope) }),
77
+ settings: () => ({ open: 'panel:waves-settings' }),
78
+ },
79
+ })
80
+
81
+ nctx.dshGo.panel('waves-settings', {
82
+ view: async (scope, caps) => {
83
+ if (!canV2(caps)) return { type: 'stack', children: [text('原生设置需要支持视图 v2 的 App。')] }
84
+ try {
85
+ const groups = (await config()).groups || []
86
+ const s = state(scope)
87
+ s.group = Math.max(0, Math.min(s.group, groups.length - 1))
88
+ const group = groups[s.group]
89
+ const items = group?.items || []
90
+ s.page = Math.max(0, Math.min(s.page, Math.ceil(items.length / 12) - 1))
91
+ return { v: 2, type: 'stack', children: [
92
+ text(`${group?.name || '配置'} · ${s.page + 1}/${Math.max(1, Math.ceil(items.length / 12))}`),
93
+ { type: 'actions', items: [action('上一组', 'group', { delta: -1 }), action('下一组', 'group', { delta: 1 }), action('刷新', 'reload')] },
94
+ { type: 'list', items: items.slice(s.page * 12, s.page * 12 + 12).map((item) => ({
95
+ title: String(item.title || item.key).slice(0, 80),
96
+ subtitle: item.secret ? (item.is_set ? '已设置,点按可从文件更新' : '未设置,点按可从文件导入') : String(typeof item.value === 'object' ? JSON.stringify(item.value) : item.value).slice(0, 120),
97
+ action: { id: 'edit', args: { group: group.name, key: item.key } },
98
+ })) },
99
+ { type: 'actions', items: [action('上一页', 'page', { delta: -1 }), action('下一页', 'page', { delta: 1 }), action('返回查询', 'home')] },
100
+ ] }
101
+ } catch { return { type: 'stack', children: [text('服务尚未就绪,请稍后重试。'), { type: 'actions', items: [action('重试', 'reload')] }] } }
102
+ },
103
+ actions: {
104
+ group: (scope, event) => { const s = state(scope); s.group = Math.max(0, s.group + (event.value?.delta === -1 ? -1 : 1)); s.page = 0; return { refresh: true } },
105
+ page: (scope, event) => { const s = state(scope); s.page = Math.max(0, s.page + (event.value?.delta === -1 ? -1 : 1)); return { refresh: true } },
106
+ reload: () => ({ refresh: true }),
107
+ home: () => ({ open: 'panel:waves-home' }),
108
+ edit: async (scope, event) => {
109
+ const groups = (await config()).groups || []
110
+ const group = groups.find((g) => g.name === event.value?.group)
111
+ const item = group?.items?.find((i) => i.key === event.value?.key)
112
+ if (!item) return { toast: '配置项不存在' }
113
+ Object.assign(state(scope), { editGroup: group.name, editKey: event.value.key })
114
+ // The bridge retains panel feeds for 30s. Reset a reused editor feed
115
+ // before opening the next setting, so an old draft is never reused.
116
+ return [{ type: 'patch', id: 'waves-edit', data: editData(item) }, { open: 'panel:waves-edit' }]
117
+ },
118
+ },
119
+ })
120
+
121
+ nctx.dshGo.panel('waves-edit', {
122
+ view: async (scope, caps) => {
123
+ if (!canV2(caps)) return { type: 'stack', children: [text('此设置需要支持视图 v2 的 App。')] }
124
+ try {
125
+ const { item } = await selected(scope)
126
+ const children = [text(item.title || item.key), text(item.desc || '修改后点保存。')]
127
+ if (item.needs_restart) children.push({ type: 'callout', tone: 'info', text: '修改此项后需要重启引擎或鸣潮服务。' })
128
+ if (item.secret) {
129
+ children.push(text(item.is_set ? '已设置,原值不会显示。' : '尚未设置。'))
130
+ children.push(text('将此项值单独保存为 UTF-8 文本文件,点下面按钮导入;内容不会出现在对话或面板中。'))
131
+ children.push({ type: 'actions', items: [action('从文件更新', 'pick'), action('返回设置', 'back')] })
132
+ } else if (item.upload && !item.upload.with_text) {
133
+ children.push(text(item.upload.exists ? '已有文件,可选择新文件替换。' : '尚未上传文件。'))
134
+ children.push({ type: 'actions', items: [action('选择文件', 'pick'), action('返回设置', 'back')] })
135
+ } else {
136
+ const type = item.type === 'BoolConfig' ? 'toggle' : ['IntConfig', 'FloatConfig'].includes(item.type) ? 'number' : item.options?.length ? 'select' : 'textField'
137
+ const input = { type, label: '新值', bind: 'data.form.value' }
138
+ if (type === 'select') input.options = item.options.slice(0, 24).map(String)
139
+ if (type === 'textField') Object.assign(input, { maxLength: 1000, multiline: item.type.includes('List') || item.type === 'DictConfig', maxLines: 4 })
140
+ children.push({ type: 'form', submit: 'save', submitLabel: '保存', children: [input] })
141
+ children.push({ type: 'actions', items: [action('返回设置', 'back')] })
142
+ }
143
+ children.push({ type: 'text', text: { $: 'data.notice' } })
144
+ return { v: 2, type: 'stack', children }
145
+ } catch { return { type: 'stack', children: [text('请先从设置列表选择一项。'), { type: 'actions', items: [action('返回设置', 'back')] }] } }
146
+ },
147
+ data: (scope, emit) => {
148
+ let active = true
149
+ selected(scope).then(({ item }) => {
150
+ if (!active) return
151
+ emit(editData(item))
152
+ }).catch(() => { if (active) emit({ form: { value: '' } }) })
153
+ return () => { active = false }
154
+ },
155
+ actions: {
156
+ back: () => ({ open: 'panel:waves-settings' }),
157
+ save: (scope, event) => pending('正在保存', async (signal) => {
158
+ const { group, item } = await selected(scope)
159
+ if (item.secret || item.upload && !item.upload.with_text) return { toast: '请使用文件导入' }
160
+ const value = event.value?.values?.['data.form.value']
161
+ if (value === undefined || value === null || JSON.stringify(value).length > 2000) return { toast: '请填写有效值' }
162
+ if (item.type === 'BoolConfig' && typeof value !== 'boolean') return { toast: '请填写开关值' }
163
+ if (['IntConfig', 'FloatConfig'].includes(item.type) && (typeof value !== 'number' || !Number.isFinite(value) || item.type === 'IntConfig' && !Number.isInteger(value))) return { toast: '请填写有效数字' }
164
+ if (item.options && !item.options.map(String).includes(String(value))) return { toast: '请选择列表中的值' }
165
+ const result = await client.call('/api/config', { method: 'POST', body: { group: group.name, key: item.key, value }, signal, timeoutMs: 5000 })
166
+ return { patch: { form: { value } }, toast: result.needs_restart ? '已保存,重启后生效' : '已保存' }
167
+ }),
168
+ pick: () => ({ type: 'pickFile', action: 'import-file', maxFiles: 1 }),
169
+ 'import-file': async (scope, event) => {
170
+ try {
171
+ if (event.value?.status !== 'selected') return { patch: { notice: '已取消' } }
172
+ const file = event.value.files?.[0]
173
+ if (!file || typeof nctx.dshGo.readPickedFile !== 'function') return { patch: { notice: '无法读取文件' } }
174
+ // Read while the callback lease is active; never return the contents.
175
+ const bytes = nctx.dshGo.readPickedFile(file.handle)
176
+ const { group, item } = await selected(scope)
177
+ if (item.secret) {
178
+ if (bytes.length > 16000) return { patch: { notice: '凭据文件过大' } }
179
+ const value = bytes.toString('utf8').trim()
180
+ if (!value || value.length > 8000) return { patch: { notice: '凭据文件无效' } }
181
+ await client.call('/api/config', { method: 'POST', body: { group: group.name, key: item.key, value }, timeoutMs: 1800 })
182
+ } else if (item.upload) {
183
+ await client.call('/api/config/upload', { method: 'POST', body: { group: group.name, key: item.key, data: bytes.toString('base64') }, timeoutMs: 1800 })
184
+ } else return { patch: { notice: '此项不支持文件导入' } }
185
+ return { patch: { notice: '已保存,原值不会显示', form: { value: '' } } }
186
+ } catch { return { patch: { notice: '导入失败,请检查文件后重试' } } }
187
+ },
188
+ },
189
+ })
190
+ })
191
+ }
@@ -0,0 +1,59 @@
1
+ // /waves-api 反代与命令结果回取
2
+ import { PROXY_PREFIX, RESULT_PATH, SESSION_PATH } from './consts.js'
3
+
4
+ export function registerProxy(ctx, client, results) {
5
+ // 会话查在这个作用域里拿, 拿不到就当查不了, 由前端按"能下命令"处理
6
+ let sessions = null
7
+ ctx.inject(['sessions'], (sctx) => {
8
+ sessions = sctx.sessions
9
+ sctx.effect(() => () => {
10
+ sessions = null
11
+ })
12
+ })
13
+
14
+ ctx.inject(['webServer'], (wctx) => {
15
+ wctx.effect(() =>
16
+ wctx.webServer.register({
17
+ kind: 'prefix',
18
+ path: PROXY_PREFIX,
19
+ handler: async (req, res) => {
20
+ const rest = req.url.slice(PROXY_PREFIX.length)
21
+ if (rest.startsWith(SESSION_PATH)) {
22
+ const id = decodeURIComponent(rest.slice(SESSION_PATH.length).split('?')[0])
23
+ const alive = sessions ? sessions.get(id) !== undefined : null
24
+ res.writeHead(200, { 'content-type': 'application/json' })
25
+ res.end(JSON.stringify({ alive }))
26
+ return
27
+ }
28
+ if (rest.startsWith(RESULT_PATH)) {
29
+ const id = decodeURIComponent(rest.slice(RESULT_PATH.length).split('?')[0])
30
+ const found = results.get(id)
31
+ res.writeHead(found ? 200 : 404, { 'content-type': 'application/json' })
32
+ res.end(JSON.stringify(found || { error: '结果已过期' }))
33
+ return
34
+ }
35
+ const target = client.base + rest
36
+ try {
37
+ const chunks = []
38
+ for await (const chunk of req) chunks.push(chunk)
39
+ const upstream = await fetch(target, {
40
+ method: req.method,
41
+ headers: req.headers['content-type']
42
+ ? { 'content-type': req.headers['content-type'] }
43
+ : undefined,
44
+ body: chunks.length ? Buffer.concat(chunks) : undefined,
45
+ })
46
+ const buf = Buffer.from(await upstream.arrayBuffer())
47
+ res.writeHead(upstream.status, {
48
+ 'content-type': upstream.headers.get('content-type') || 'application/octet-stream',
49
+ })
50
+ res.end(buf)
51
+ } catch (e) {
52
+ res.writeHead(502, { 'content-type': 'application/json' })
53
+ res.end(JSON.stringify({ error: String(e?.message || e) }))
54
+ }
55
+ },
56
+ }),
57
+ )
58
+ })
59
+ }
@@ -0,0 +1,67 @@
1
+ // 命令结果暂存与文本化
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ import { PROXY_PREFIX, RESULT_KEEP } from './consts.js'
6
+ import { plainSummary } from './native-result.js'
7
+
8
+ // 结果落盘: 重启后历史卡片还能还原成图文, 不至于退化成一段裸链接
9
+ export function makeResultStore(serviceDir) {
10
+ const file = serviceDir ? path.join(serviceDir, 'data', 'command_results.json') : ''
11
+ const items = new Map()
12
+ let timer = null
13
+
14
+ if (file && fs.existsSync(file)) {
15
+ try {
16
+ for (const [id, value] of Object.entries(JSON.parse(fs.readFileSync(file, 'utf8')))) {
17
+ items.set(id, value)
18
+ }
19
+ } catch {
20
+ // 存档坏了就当没有, 下次写入会覆盖
21
+ }
22
+ }
23
+
24
+ const flush = () => {
25
+ timer = null
26
+ if (!file) return
27
+ try {
28
+ fs.mkdirSync(path.dirname(file), { recursive: true })
29
+ fs.writeFileSync(file, JSON.stringify(Object.fromEntries(items)))
30
+ } catch {
31
+ // 落盘失败不影响本次会话
32
+ }
33
+ }
34
+
35
+ return {
36
+ put(id, value) {
37
+ // Updating a session's native result must also advance recency.
38
+ items.delete(String(id))
39
+ items.set(String(id), value)
40
+ while (items.size > RESULT_KEEP) items.delete(items.keys().next().value)
41
+ if (file && !timer) timer = setTimeout(flush, 400)
42
+ },
43
+ get(id) {
44
+ return items.get(String(id))
45
+ },
46
+ latest(sessionId) {
47
+ return [...items.values()].reverse().find((value) => value.sessionId === sessionId)
48
+ },
49
+ }
50
+ }
51
+
52
+ export function collectLinks(segments, out = []) {
53
+ for (const seg of segments || []) {
54
+ if (!seg) continue
55
+ if (seg.type === 'node') collectLinks(seg.items, out)
56
+ else if (seg.type === 'image' || seg.type === 'html' || seg.type === 'file') {
57
+ const url = String(seg.url || '')
58
+ if (url) out.push(url.startsWith('/api/') ? PROXY_PREFIX + url : url)
59
+ }
60
+ }
61
+ return out
62
+ }
63
+
64
+ // Native clients do not run the Web command renderer. Always retain the digest.
65
+ export function resultText(command, data) {
66
+ return `${command} · ${plainSummary(data).slice(0, 6000)}`
67
+ }