@roaming-ai/dsh-group-chat 0.2.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 +56 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +3308 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +2583 -0
- package/lib/types/client/Bubble.d.ts +10 -0
- package/lib/types/client/Glyph.d.ts +12 -0
- package/lib/types/client/GroupChatPanel.d.ts +10 -0
- package/lib/types/client/GroupChatPanel.old.d.ts +10 -0
- package/lib/types/client/GroupChatSettingsSection.d.ts +15 -0
- package/lib/types/client/PermissionSelect.d.ts +13 -0
- package/lib/types/client/RoleDrawer.d.ts +17 -0
- package/lib/types/client/ThinkRow.d.ts +9 -0
- package/lib/types/client/ToolRow.d.ts +9 -0
- package/lib/types/client/api.d.ts +8 -0
- package/lib/types/client/components/AsidePanel.d.ts +31 -0
- package/lib/types/client/components/Bubble.d.ts +19 -0
- package/lib/types/client/components/ChatPanel.d.ts +57 -0
- package/lib/types/client/components/Composer.d.ts +48 -0
- package/lib/types/client/components/Glyph.d.ts +12 -0
- package/lib/types/client/components/GroupChatSettingsSection.d.ts +15 -0
- package/lib/types/client/components/MessageFlow.d.ts +15 -0
- package/lib/types/client/components/NavPanel.d.ts +40 -0
- package/lib/types/client/components/PermissionSelect.d.ts +13 -0
- package/lib/types/client/components/RoleDrawer.d.ts +17 -0
- package/lib/types/client/components/ThinkRow.d.ts +9 -0
- package/lib/types/client/components/ToolRow.d.ts +9 -0
- package/lib/types/client/components/index.d.ts +9 -0
- package/lib/types/client/components.d.ts +22 -0
- package/lib/types/client/drawer.d.ts +17 -0
- package/lib/types/client/glyph.d.ts +12 -0
- package/lib/types/client/hooks/index.d.ts +6 -0
- package/lib/types/client/hooks/useComposer.d.ts +27 -0
- package/lib/types/client/hooks/useFileSearch.d.ts +19 -0
- package/lib/types/client/hooks/useGroupChatState.d.ts +95 -0
- package/lib/types/client/hooks/useMentionState.d.ts +27 -0
- package/lib/types/client/index.d.ts +52 -0
- package/lib/types/client/lib/api.d.ts +8 -0
- package/lib/types/client/lib/model.d.ts +80 -0
- package/lib/types/client/lib/styles.d.ts +10 -0
- package/lib/types/client/lib/ui.d.ts +17 -0
- package/lib/types/client/model.d.ts +78 -0
- package/lib/types/client/panel.d.ts +10 -0
- package/lib/types/client/settings.d.ts +15 -0
- package/lib/types/client/styles.d.ts +10 -0
- package/lib/types/client/ui.d.ts +17 -0
- package/lib/types/client/utils/utils.d.ts +29 -0
- package/lib/types/client/utils.d.ts +22 -0
- package/lib/types/core/json.d.ts +9 -0
- package/lib/types/core/status.d.ts +18 -0
- package/lib/types/core/tools.d.ts +59 -0
- package/lib/types/core/types.d.ts +256 -0
- package/lib/types/host/actions.d.ts +34 -0
- package/lib/types/host/api/actions.d.ts +36 -0
- package/lib/types/host/api/http.d.ts +16 -0
- package/lib/types/host/api/index.d.ts +7 -0
- package/lib/types/host/api/routes.d.ts +11 -0
- package/lib/types/host/broadcast.d.ts +20 -0
- package/lib/types/host/conversation.d.ts +25 -0
- package/lib/types/host/engine/conversation.d.ts +25 -0
- package/lib/types/host/engine/index.d.ts +7 -0
- package/lib/types/host/engine/retitle.d.ts +20 -0
- package/lib/types/host/http.d.ts +16 -0
- package/lib/types/host/index.d.ts +8 -0
- package/lib/types/host/materials/index.d.ts +6 -0
- package/lib/types/host/materials/materials.d.ts +40 -0
- package/lib/types/host/materials.d.ts +32 -0
- package/lib/types/host/persistence/index.d.ts +6 -0
- package/lib/types/host/persistence/persistence.d.ts +31 -0
- package/lib/types/host/persistence/store.d.ts +65 -0
- package/lib/types/host/persistence.d.ts +31 -0
- package/lib/types/host/routes.d.ts +11 -0
- package/lib/types/host/service.d.ts +28 -0
- package/lib/types/host/state.d.ts +45 -0
- package/lib/types/host/store.d.ts +65 -0
- package/lib/types/host/tools/index.d.ts +6 -0
- package/lib/types/host/tools/tools.d.ts +28 -0
- package/lib/types/host/tools.d.ts +28 -0
- package/lib/types/index.d.ts +41 -0
- package/lib/types/mount-once.d.ts +9 -0
- package/lib/types/shared/file-mention-grammar.d.ts +37 -0
- package/lib/types/shared/file-mention-grammar.test.d.ts +5 -0
- package/package.json +98 -0
- package/src/client/GroupChatPanel.tsx +385 -0
- package/src/client/components/AsidePanel.tsx +188 -0
- package/src/client/components/Bubble.tsx +76 -0
- package/src/client/components/ChatPanel.tsx +214 -0
- package/src/client/components/Composer.tsx +238 -0
- package/src/client/components/Glyph.tsx +27 -0
- package/src/client/components/GroupChatSettingsSection.tsx +74 -0
- package/src/client/components/MessageFlow.tsx +97 -0
- package/src/client/components/NavPanel.tsx +249 -0
- package/src/client/components/PermissionSelect.tsx +159 -0
- package/src/client/components/RoleDrawer.tsx +186 -0
- package/src/client/components/ThinkRow.tsx +32 -0
- package/src/client/components/ToolRow.tsx +51 -0
- package/src/client/components/index.ts +10 -0
- package/src/client/hooks/index.ts +7 -0
- package/src/client/hooks/useComposer.ts +272 -0
- package/src/client/hooks/useFileSearch.ts +115 -0
- package/src/client/hooks/useGroupChatState.ts +214 -0
- package/src/client/hooks/useMentionState.ts +56 -0
- package/src/client/index.ts +110 -0
- package/src/client/lib/api.ts +23 -0
- package/src/client/lib/model.ts +119 -0
- package/src/client/lib/styles.ts +317 -0
- package/src/client/lib/ui.ts +27 -0
- package/src/client/react-dom-shim.d.ts +13 -0
- package/src/client/utils/utils.ts +104 -0
- package/src/core/json.ts +28 -0
- package/src/core/status.ts +33 -0
- package/src/core/tools.ts +41 -0
- package/src/core/types.ts +222 -0
- package/src/host/api/actions.ts +335 -0
- package/src/host/api/http.ts +80 -0
- package/src/host/api/index.ts +8 -0
- package/src/host/api/routes.ts +95 -0
- package/src/host/broadcast.ts +65 -0
- package/src/host/engine/conversation.ts +331 -0
- package/src/host/engine/index.ts +8 -0
- package/src/host/engine/retitle.ts +135 -0
- package/src/host/index.ts +9 -0
- package/src/host/materials/index.ts +7 -0
- package/src/host/materials/materials.ts +193 -0
- package/src/host/persistence/index.ts +7 -0
- package/src/host/persistence/persistence.ts +311 -0
- package/src/host/persistence/store.ts +375 -0
- package/src/host/service.ts +78 -0
- package/src/host/state.ts +85 -0
- package/src/host/tools/index.ts +7 -0
- package/src/host/tools/tools.ts +194 -0
- package/src/index.ts +99 -0
- package/src/mount-once.ts +49 -0
- package/src/shared/file-mention-grammar.test.ts +77 -0
- package/src/shared/file-mention-grammar.ts +82 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具执行(TOOLS.md §2:沙箱 / §3:确认闸门):read_file / list_dir /
|
|
3
|
+
* run_command 三件套;realpath 硬边界 + 分隔符比较;run_command 按群组
|
|
4
|
+
* 权限档位走逐条确认或直接执行。
|
|
5
|
+
* @module dsh-group-chat/host/tools
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
9
|
+
import { closeSync, openSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
10
|
+
import { isAbsolute, join, sep } from 'node:path'
|
|
11
|
+
import { CMD_CAPTURE_MAX_BYTES, CMD_OUTPUT_MAX_CHARS, READ_FILE_MAX_BYTES, RUN_CMD_TIMEOUT_MS, TOOL_SCHEMAS } from '../../core/tools.ts'
|
|
12
|
+
import type { GroupRecord, RunState, ToolExecution } from '../../core/types.ts'
|
|
13
|
+
import type { HostState } from '../state.ts'
|
|
14
|
+
|
|
15
|
+
/** 工具面。 */
|
|
16
|
+
export interface Tools {
|
|
17
|
+
/** 执行一次工具调用(args 解析 + 分发 + 计时;确认闸门在 run_command 内)。 */
|
|
18
|
+
executeTool: (g: GroupRecord, root: string, tc: { id: string, name: string, args: string }) => Promise<ToolExecution>
|
|
19
|
+
/** 工具 schema(view_only 档剔除 run_command)。 */
|
|
20
|
+
buildToolSchemas: (g: GroupRecord) => (typeof TOOL_SCHEMAS)[number][]
|
|
21
|
+
/** 置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
|
|
22
|
+
requestConfirmation: (toolCallId: string, args: Record<string, unknown>) => Promise<boolean>
|
|
23
|
+
/** 唤醒确认等待(以拒绝放行;置空 pendingConfirm)。 */
|
|
24
|
+
wakeConfirm: () => void
|
|
25
|
+
/** kill 正在执行的命令子进程。 */
|
|
26
|
+
killChild: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 创建工具面。 */
|
|
30
|
+
export function createTools(core: HostState, touch: () => void): Tools {
|
|
31
|
+
const { run } = core
|
|
32
|
+
|
|
33
|
+
/** realpath 硬边界:目标必须在群组工作区内(带分隔符比较,防 /ws/foo 放行 /ws/foobar)。 */
|
|
34
|
+
const resolveInWorkspace = (root: string, rawPath: unknown): { ok: true, target: string } | { ok: false, error: string } => {
|
|
35
|
+
const p = rawPath === undefined || rawPath === null || String(rawPath).trim() === '' ? '.' : String(rawPath)
|
|
36
|
+
let target: string
|
|
37
|
+
try {
|
|
38
|
+
target = realpathSync(isAbsolute(p) ? p : join(root, p))
|
|
39
|
+
} catch {
|
|
40
|
+
return { ok: false, error: '路径不存在:' + p }
|
|
41
|
+
}
|
|
42
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
43
|
+
return { ok: false, error: '路径超出群组工作区范围:' + p }
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, target }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const toolReadFile = (root: string, args: Record<string, unknown>): { status: 'ok' | 'error', output: string } => {
|
|
49
|
+
const r = resolveInWorkspace(root, args.path)
|
|
50
|
+
if (!r.ok) return { status: 'error', output: r.error }
|
|
51
|
+
let fd: number | undefined
|
|
52
|
+
try {
|
|
53
|
+
const st = statSync(r.target)
|
|
54
|
+
if (!st.isFile()) return { status: 'error', output: '不是文件:' + String(args.path) }
|
|
55
|
+
fd = openSync(r.target, 'r')
|
|
56
|
+
const buf = Buffer.alloc(READ_FILE_MAX_BYTES + 1)
|
|
57
|
+
const n = readSync(fd, buf, 0, buf.length, 0)
|
|
58
|
+
let text = buf.subarray(0, Math.min(n, READ_FILE_MAX_BYTES)).toString('utf8')
|
|
59
|
+
if (n > READ_FILE_MAX_BYTES) text += '\n…(文件超过 100KB,已截断)'
|
|
60
|
+
return { status: 'ok', output: text || '(空文件)' }
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return { status: 'error', output: '读取失败:' + String((e && (e as Error).message) || e) }
|
|
63
|
+
} finally {
|
|
64
|
+
if (fd !== undefined) {
|
|
65
|
+
try {
|
|
66
|
+
closeSync(fd)
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const toolListDir = (root: string, args: Record<string, unknown>): { status: 'ok' | 'error', output: string } => {
|
|
73
|
+
const r = resolveInWorkspace(root, args.path)
|
|
74
|
+
if (!r.ok) return { status: 'error', output: r.error }
|
|
75
|
+
try {
|
|
76
|
+
const st = statSync(r.target)
|
|
77
|
+
if (!st.isDirectory()) return { status: 'error', output: '不是目录:' + String(args.path) }
|
|
78
|
+
const entries = readdirSync(r.target, { withFileTypes: true }).sort((a, b) => (a.isDirectory() ? 0 : 1) - (b.isDirectory() ? 0 : 1) || a.name.localeCompare(b.name))
|
|
79
|
+
const lines = entries.slice(0, 500).map((e) => {
|
|
80
|
+
if (e.isDirectory()) return e.name + '/'
|
|
81
|
+
try {
|
|
82
|
+
return e.name + ' (' + statSync(join(r.target, e.name)).size + ' B)'
|
|
83
|
+
} catch {
|
|
84
|
+
return e.name
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
if (entries.length > 500) lines.push('…(共 ' + entries.length + ' 项,仅显示前 500)')
|
|
88
|
+
const out = lines.join('\n') || '(空目录)'
|
|
89
|
+
return { status: 'ok', output: out.length > CMD_OUTPUT_MAX_CHARS ? out.slice(0, CMD_OUTPUT_MAX_CHARS) + '\n…(已截断)' : out }
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return { status: 'error', output: '列出失败:' + String((e && (e as Error).message) || e) }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const runCommandTool = (root: string, command: string): Promise<{ status: 'ok' | 'error', output: string }> => new Promise((resolve) => {
|
|
96
|
+
let child: ChildProcess
|
|
97
|
+
try {
|
|
98
|
+
child = spawn('bash', ['-c', command], { cwd: root })
|
|
99
|
+
} catch (e) {
|
|
100
|
+
resolve({ status: 'error', output: '无法启动命令:' + String((e && (e as Error).message) || e) })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
run.childProc = child
|
|
104
|
+
let out = ''
|
|
105
|
+
let dropped = 0
|
|
106
|
+
let timedOut = false
|
|
107
|
+
const onChunk = (chunk: Buffer) => {
|
|
108
|
+
if (out.length < CMD_CAPTURE_MAX_BYTES) out += chunk.toString('utf8')
|
|
109
|
+
else dropped += chunk.length
|
|
110
|
+
}
|
|
111
|
+
child.stdout?.on('data', onChunk)
|
|
112
|
+
child.stderr?.on('data', onChunk)
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
timedOut = true
|
|
115
|
+
try {
|
|
116
|
+
child.kill('SIGKILL')
|
|
117
|
+
} catch {}
|
|
118
|
+
}, RUN_CMD_TIMEOUT_MS)
|
|
119
|
+
const finish = (result: { status: 'ok' | 'error', output: string }) => {
|
|
120
|
+
clearTimeout(timer)
|
|
121
|
+
if (run.childProc === child) run.childProc = null
|
|
122
|
+
resolve(result)
|
|
123
|
+
}
|
|
124
|
+
child.on('error', (e) => finish({ status: 'error', output: '执行失败:' + String((e && e.message) || e) }))
|
|
125
|
+
child.on('close', (code) => {
|
|
126
|
+
let text = out.length > CMD_CAPTURE_MAX_BYTES ? out.slice(0, CMD_CAPTURE_MAX_BYTES) : out
|
|
127
|
+
if (dropped > 0) text += '\n[输出采集超限,已丢弃 ' + dropped + ' 字节]'
|
|
128
|
+
if (timedOut) text += '\n[执行超时(' + Math.round(RUN_CMD_TIMEOUT_MS / 1000) + 's),已强制终止]'
|
|
129
|
+
if (code !== 0 && code !== null && !timedOut) text += '\n[退出码 ' + code + ']'
|
|
130
|
+
if (text.length > CMD_OUTPUT_MAX_CHARS) text = text.slice(0, CMD_OUTPUT_MAX_CHARS) + '\n…(输出超长,已截断)'
|
|
131
|
+
finish({ status: code === 0 ? 'ok' : 'error', output: text || '(无输出)' })
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/** run_command 确认闸门:置 pendingConfirm 后无限等待,confirmCommand/stop/dispose 唤醒。 */
|
|
136
|
+
const requestConfirmation = (toolCallId: string, args: Record<string, unknown>): Promise<boolean> => new Promise((resolve) => {
|
|
137
|
+
run.pendingConfirm = { toolCallId, tool: 'run_command', args: args as RunState['pendingConfirm'] extends null ? never : NonNullable<RunState['pendingConfirm']>['args'] }
|
|
138
|
+
run.confirmSignal = { resolve }
|
|
139
|
+
touch()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const wakeConfirm = (): void => {
|
|
143
|
+
const signal = run.confirmSignal
|
|
144
|
+
run.pendingConfirm = null
|
|
145
|
+
run.confirmSignal = null
|
|
146
|
+
if (signal) {
|
|
147
|
+
try {
|
|
148
|
+
signal.resolve(false)
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const killChild = (): void => {
|
|
154
|
+
if (run.childProc) {
|
|
155
|
+
try {
|
|
156
|
+
run.childProc.kill('SIGKILL')
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const executeTool = async (g: GroupRecord, root: string, tc: { id: string, name: string, args: string }): Promise<ToolExecution> => {
|
|
162
|
+
let args: Record<string, unknown> = {}
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(tc.args)
|
|
165
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) args = parsed as Record<string, unknown>
|
|
166
|
+
} catch {}
|
|
167
|
+
const started = Date.now()
|
|
168
|
+
let res: { status: 'ok' | 'error' | 'denied', output: string }
|
|
169
|
+
if (tc.name === 'read_file') res = toolReadFile(root, args)
|
|
170
|
+
else if (tc.name === 'list_dir') res = toolListDir(root, args)
|
|
171
|
+
else if (tc.name === 'run_command') {
|
|
172
|
+
if (g.permissionTier === 'view_only') {
|
|
173
|
+
res = { status: 'error', output: '群组权限为「仅可查看」,该命令未被运行' }
|
|
174
|
+
} else if (g.permissionTier === 'workspace_write') {
|
|
175
|
+
const allowed = await requestConfirmation(tc.id, args)
|
|
176
|
+
if (run.stopping) res = { status: 'error', output: '对话已被用户停止,命令未执行' }
|
|
177
|
+
else if (!allowed) res = { status: 'denied', output: '用户拒绝了这次命令执行' }
|
|
178
|
+
else res = await runCommandTool(root, String(args.command || ''))
|
|
179
|
+
} else {
|
|
180
|
+
res = await runCommandTool(root, String(args.command || ''))
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
res = { status: 'error', output: '未知工具:' + tc.name }
|
|
184
|
+
}
|
|
185
|
+
return { status: res.status, output: res.output, args, durationMs: Date.now() - started }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const buildToolSchemas = (g: GroupRecord) => {
|
|
189
|
+
if (!g.workspaceDir) return []
|
|
190
|
+
return TOOL_SCHEMAS.filter((t) => t.name !== 'run_command' || g.permissionTier !== 'view_only')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { executeTool, buildToolSchemas, requestConfirmation, wakeConfirm, killChild }
|
|
194
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-group-chat — DSH 模型群聊(host 平面组合插件,host + client 双半)。
|
|
3
|
+
*
|
|
4
|
+
* Host 半职责:
|
|
5
|
+
* - 群组 / 会话 / 角色 / 消息的状态机,会话级文件持久化于
|
|
6
|
+
* ~/.dsh/storages/group-chat/(PERSISTENCE.md v2.1:每群组一目录,
|
|
7
|
+
* sessions/session-<uuid>.json 一会话一文件;roles.json 与 workspaceDir
|
|
8
|
+
* 随群组目录;ledger.json 纯清单;原子写 tmp+fsync+rename(每目录一次
|
|
9
|
+
* fsync);单实例 .lock;损坏隔离重建;写失败保留脏标记;v1 自动迁移)
|
|
10
|
+
* 数据模型:Group 1..N Session,消息挂在会话上;角色与工作区目录挂在群组上
|
|
11
|
+
* - 群组与会话的增删改;群组/会话检索由客户端在快照上过滤
|
|
12
|
+
* - 角色发言经 `llm` 服务流式生成,按角色绑定的 provider/model 路由
|
|
13
|
+
* - 群组工作区目录经 `fs` 服务读取(根下一层文本文件,最多 20 个),
|
|
14
|
+
* 以「共享资料」块注入每个角色的 system 提示词;无独立笔记/文件清单
|
|
15
|
+
* - 经 `webServer` 暴露 HTTP API:
|
|
16
|
+
* GET /api/group-chat/state 全量快照
|
|
17
|
+
* POST /api/group-chat/action { kind: mutate|send|stop|confirmCommand|models|efforts|browse|fileSearch, ... }
|
|
18
|
+
* GET /api/group-chat/events SSE,状态变化时推送节流后的全量快照
|
|
19
|
+
*
|
|
20
|
+
* 设置命名空间 `group-chat`(settings.yaml 持久化):`enabled` 控制浏览器半
|
|
21
|
+
* 的侧边栏入口与主面板挂载;关闭时会中止正在进行的群聊。
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-group-chat
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
27
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
28
|
+
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
29
|
+
// 类型合并:ctx.settings(宿主面)
|
|
30
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
31
|
+
import z from 'schemastery'
|
|
32
|
+
import { mountOnce } from './mount-once.ts'
|
|
33
|
+
import { createGroupChatService, makeGroupChatRoutes } from './host/index.ts'
|
|
34
|
+
|
|
35
|
+
export const name = 'group-chat'
|
|
36
|
+
|
|
37
|
+
export const inject = ['llm', 'fs', 'webServer', 'workspaceRegistry']
|
|
38
|
+
|
|
39
|
+
/** 设置命名空间;浏览器半拼写同一值,两边不共享代码。 */
|
|
40
|
+
export const SETTINGS_NAMESPACE = 'group-chat' as SettingsNamespace
|
|
41
|
+
|
|
42
|
+
export interface Config {
|
|
43
|
+
enabled?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 设置 schema:目前只有启停一个开关。 */
|
|
47
|
+
export const Config: z<Config> = z.object({
|
|
48
|
+
enabled: z.boolean().default(true),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Host 半插件体:状态机服务 + HTTP 路由 + 设置接入。
|
|
53
|
+
* mountOnce 保证同名包每进程至多挂载一次(bundle 聚合与独立安装并存场景)。
|
|
54
|
+
*/
|
|
55
|
+
export const apply = mountOnce('dsh-group-chat', (ctx: Context, config?: Config): void => {
|
|
56
|
+
const service = createGroupChatService(ctx)
|
|
57
|
+
|
|
58
|
+
// ---------- HTTP 路由 ----------
|
|
59
|
+
ctx.effect(() => {
|
|
60
|
+
const disposers = makeGroupChatRoutes(service).map((route) => ctx.webServer.register(route))
|
|
61
|
+
return () => {
|
|
62
|
+
for (const dispose of disposers) dispose()
|
|
63
|
+
}
|
|
64
|
+
}, 'group-chat: host API routes')
|
|
65
|
+
|
|
66
|
+
// 插件卸载/热重载:唤醒确认等待 + kill 子进程 → 同步最终 flush → 释放锁
|
|
67
|
+
ctx.effect(() => () => {
|
|
68
|
+
service.dispose()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ---------- 设置(enabled 持久化于 settings.yaml) ----------
|
|
72
|
+
|
|
73
|
+
let current = (): Config => config ?? {}
|
|
74
|
+
const sync = (): void => {
|
|
75
|
+
const enabled = current().enabled ?? true
|
|
76
|
+
if (!enabled) service.stopAll()
|
|
77
|
+
}
|
|
78
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
79
|
+
try {
|
|
80
|
+
if (typeof settingsCtx.settings?.installSection === 'function') {
|
|
81
|
+
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
82
|
+
setSource: (source: () => Config) => {
|
|
83
|
+
current = source
|
|
84
|
+
},
|
|
85
|
+
onChange: sync,
|
|
86
|
+
})
|
|
87
|
+
} else if (typeof settingsCtx.settings?.register === 'function') {
|
|
88
|
+
const scope = settingsCtx.settings.register(SETTINGS_NAMESPACE, Config, { base: config ?? {} })
|
|
89
|
+
current = () => scope?.get?.() ?? (config ?? {})
|
|
90
|
+
scope?.watch?.(() => {
|
|
91
|
+
sync()
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
/* 设置面不可用时插件照常工作,始终视为启用 */
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
sync()
|
|
99
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Generated by sync-shared (dsh-web shared/host/mount-once.ts). Do not edit this copy; edit the shared source and re-run the sync.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
5
|
+
* (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
6
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
7
|
+
* without this guard the second instance would still re-register the same
|
|
8
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
9
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
10
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
11
|
+
* package name in the client module host).
|
|
12
|
+
*
|
|
13
|
+
* The registry rides a global symbol so two module instances of the same
|
|
14
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
15
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
16
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
20
|
+
|
|
21
|
+
interface MountRegistry {
|
|
22
|
+
[MOUNTED]?: Set<string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mountedSet(): Set<string> {
|
|
26
|
+
const registry = globalThis as MountRegistry
|
|
27
|
+
return (registry[MOUNTED] ??= new Set())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
32
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
33
|
+
* any later mount of the same package name is a no-op.
|
|
34
|
+
* @param packageName - npm package identity shared by every install source.
|
|
35
|
+
* @param fn - the original plugin apply.
|
|
36
|
+
* @returns an apply of the same shape.
|
|
37
|
+
*/
|
|
38
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
39
|
+
return ((...args: unknown[]) => {
|
|
40
|
+
const mounted = mountedSet()
|
|
41
|
+
if (mounted.has(packageName)) return
|
|
42
|
+
mounted.add(packageName)
|
|
43
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
44
|
+
ctx?.effect?.(() => () => {
|
|
45
|
+
mounted.delete(packageName)
|
|
46
|
+
})
|
|
47
|
+
return fn(...args)
|
|
48
|
+
}) as T
|
|
49
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Tests for vendored @file token grammar.
|
|
3
|
+
* @vitest-environment node
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { activeAtToken, formatFileMention } from './file-mention-grammar.ts'
|
|
8
|
+
|
|
9
|
+
describe('activeAtToken', () => {
|
|
10
|
+
it('extracts plain @path at cursor', () => {
|
|
11
|
+
expect(activeAtToken('@fil', 4)).toEqual({ prefix: '@fil', query: 'fil', quoted: false })
|
|
12
|
+
expect(activeAtToken('@src/', 5)).toEqual({ prefix: '@src/', query: 'src/', quoted: false })
|
|
13
|
+
expect(activeAtToken('text @lib/index.ts', 18)).toEqual({ prefix: '@lib/index.ts', query: 'lib/index.ts', quoted: false })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('extracts quoted @"path with spaces at cursor', () => {
|
|
17
|
+
expect(activeAtToken('@"My Documents', 14)).toEqual({ prefix: '@"My Documents', query: 'My Documents', quoted: true })
|
|
18
|
+
expect(activeAtToken('open @"src/my file.ts', 22)).toEqual({ prefix: '@"src/my file.ts', query: 'src/my file.ts', quoted: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns undefined when @ is not at word boundary', () => {
|
|
22
|
+
expect(activeAtToken('user@example.com', 16)).toBeUndefined()
|
|
23
|
+
expect(activeAtToken('no@@', 4)).toBeUndefined()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns undefined when cursor is not inside @token', () => {
|
|
27
|
+
expect(activeAtToken('@file extra', 11)).toBeUndefined()
|
|
28
|
+
expect(activeAtToken('before @file', 6)).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('handles @ at line start', () => {
|
|
32
|
+
expect(activeAtToken('@', 1)).toEqual({ prefix: '@', query: '', quoted: false })
|
|
33
|
+
expect(activeAtToken('@"', 2)).toEqual({ prefix: '@"', query: '', quoted: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('handles @ after whitespace', () => {
|
|
37
|
+
expect(activeAtToken(' @qu', 5)).toEqual({ prefix: '@qu', query: 'qu', quoted: false })
|
|
38
|
+
expect(activeAtToken('\t@"test', 7)).toEqual({ prefix: '@"test', query: 'test', quoted: true })
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('formatFileMention', () => {
|
|
43
|
+
it('formats plain file without whitespace', () => {
|
|
44
|
+
expect(formatFileMention({ kind: 'file', path: 'README.md' }, false)).toBe('@README.md')
|
|
45
|
+
expect(formatFileMention({ kind: 'file', path: 'src/index.ts' }, false)).toBe('@src/index.ts')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('formats plain directory with trailing slash', () => {
|
|
49
|
+
expect(formatFileMention({ kind: 'directory', path: 'src' }, false)).toBe('@src/')
|
|
50
|
+
expect(formatFileMention({ kind: 'directory', path: 'lib/utils' }, false)).toBe('@lib/utils/')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('quotes file with whitespace and closes quote', () => {
|
|
54
|
+
expect(formatFileMention({ kind: 'file', path: 'My Document.txt' }, false)).toBe('@"My Document.txt"')
|
|
55
|
+
expect(formatFileMention({ kind: 'file', path: 'src/my file.ts' }, false)).toBe('@"src/my file.ts"')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('quotes directory with whitespace but keeps quote open', () => {
|
|
59
|
+
expect(formatFileMention({ kind: 'directory', path: 'My Folder' }, false)).toBe('@"My Folder/')
|
|
60
|
+
expect(formatFileMention({ kind: 'directory', path: 'src/my lib' }, false)).toBe('@"src/my lib/')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('preserves quote even when unnecessary', () => {
|
|
64
|
+
expect(formatFileMention({ kind: 'file', path: 'plain.txt' }, true)).toBe('@"plain.txt"')
|
|
65
|
+
expect(formatFileMention({ kind: 'directory', path: 'lib' }, true)).toBe('@"lib/')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('rejects paths with control characters', () => {
|
|
69
|
+
expect(formatFileMention({ kind: 'file', path: 'file\x00.txt' }, false)).toBeUndefined()
|
|
70
|
+
expect(formatFileMention({ kind: 'file', path: 'file\n.txt' }, false)).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('rejects paths with embedded quotes', () => {
|
|
74
|
+
expect(formatFileMention({ kind: 'file', path: 'file"name.txt' }, false)).toBeUndefined()
|
|
75
|
+
expect(formatFileMention({ kind: 'directory', path: 'dir"name' }, false)).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored @file token grammar from dsh-file-reference (browser-safe, zero Node API).
|
|
3
|
+
*
|
|
4
|
+
* Sourced from @deepseek-ai/dsh-file-reference/lib/types/grammar.js
|
|
5
|
+
* Cannot import directly due to client bundle purity gate (dsh-client-bundle-purity).
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-group-chat/shared
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface AtToken {
|
|
11
|
+
/** Full matched prefix including @ and optional quotes */
|
|
12
|
+
prefix: string
|
|
13
|
+
/** Query fragment after @ (unquoted content) */
|
|
14
|
+
query: string
|
|
15
|
+
/** Whether the token uses quoted form @"..." */
|
|
16
|
+
quoted: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FileCandidate {
|
|
20
|
+
kind: 'file' | 'directory'
|
|
21
|
+
path: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract active @token at cursor position.
|
|
26
|
+
* Matches @ at word boundary (line start or after whitespace).
|
|
27
|
+
*
|
|
28
|
+
* @param line - Current line text
|
|
29
|
+
* @param cursor - Cursor offset in line
|
|
30
|
+
* @returns Token or undefined if no active @token at cursor
|
|
31
|
+
*/
|
|
32
|
+
export function activeAtToken(line: string, cursor: number): AtToken | undefined {
|
|
33
|
+
const before = line.slice(0, cursor)
|
|
34
|
+
|
|
35
|
+
// Try quoted @"... first (more specific)
|
|
36
|
+
const quotedMatch = /(?:^|\s)@"([^"]*)$/.exec(before)
|
|
37
|
+
if (quotedMatch) {
|
|
38
|
+
const prefix = quotedMatch[0].trimStart()
|
|
39
|
+
return {
|
|
40
|
+
prefix,
|
|
41
|
+
query: quotedMatch[1],
|
|
42
|
+
quoted: true,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Then try plain @...
|
|
47
|
+
const plainMatch = /(?:^|\s)@([^\s@]*)$/.exec(before)
|
|
48
|
+
if (plainMatch) {
|
|
49
|
+
const prefix = plainMatch[0].trimStart()
|
|
50
|
+
return {
|
|
51
|
+
prefix,
|
|
52
|
+
query: plainMatch[1],
|
|
53
|
+
quoted: false,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Format file/directory candidate as @mention text.
|
|
62
|
+
*
|
|
63
|
+
* @param candidate - File or directory to format
|
|
64
|
+
* @param preserveQuote - Keep quotes even when unnecessary (for drill-down continuity)
|
|
65
|
+
* @returns Formatted @path or undefined for invalid paths
|
|
66
|
+
*/
|
|
67
|
+
export function formatFileMention(candidate: FileCandidate, preserveQuote: boolean): string | undefined {
|
|
68
|
+
const { path } = candidate
|
|
69
|
+
|
|
70
|
+
// Reject control characters and embedded quotes
|
|
71
|
+
if (/[\x00-\x1F"]/.test(path)) return undefined
|
|
72
|
+
|
|
73
|
+
const needsQuote = /\s/.test(path)
|
|
74
|
+
const useQuote = needsQuote || preserveQuote
|
|
75
|
+
|
|
76
|
+
if (candidate.kind === 'file') {
|
|
77
|
+
return useQuote ? `@"${path}"` : `@${path}`
|
|
78
|
+
} else {
|
|
79
|
+
// Directory: trailing slash, quote stays open for drill-down
|
|
80
|
+
return useQuote ? `@"${path}/` : `@${path}/`
|
|
81
|
+
}
|
|
82
|
+
}
|