@roaming-ai/dsh-group-chat 0.2.2 → 0.3.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/README.md +3 -2
- package/lib/client.js +984 -296
- package/lib/client.js.map +1 -1
- package/lib/index.js +524 -52
- package/lib/types/client/components/Bubble.d.ts +6 -3
- package/lib/types/client/components/ChatPanel.d.ts +1 -0
- package/lib/types/client/components/ConstraintList.d.ts +11 -0
- package/lib/types/client/components/FailCard.d.ts +9 -0
- package/lib/types/client/components/Fold.d.ts +19 -0
- package/lib/types/client/components/HoverTip.d.ts +22 -0
- package/lib/types/client/components/MessageFlow.d.ts +1 -0
- package/lib/types/client/components/MsgActions.d.ts +14 -0
- package/lib/types/client/hooks/useComposer.d.ts +6 -1
- package/lib/types/client/lib/composer-draft.d.ts +17 -0
- package/lib/types/core/constraints.d.ts +61 -0
- package/lib/types/core/errors.d.ts +54 -0
- package/lib/types/core/types.d.ts +22 -0
- package/lib/types/host/api/actions.d.ts +1 -1
- package/lib/types/host/engine/conversation.d.ts +5 -3
- package/lib/types/host/engine/fold.d.ts +17 -0
- package/lib/types/host/engine/index.d.ts +1 -0
- package/lib/types/host/engine/retitle.d.ts +5 -5
- package/lib/types/host/service.d.ts +1 -1
- package/lib/types/host/state.d.ts +2 -0
- package/lib/types/index.d.ts +2 -0
- package/package.json +1 -1
- package/src/client/GroupChatPanel.tsx +18 -3
- package/src/client/components/AsidePanel.tsx +10 -8
- package/src/client/components/Bubble.tsx +45 -18
- package/src/client/components/ChatPanel.tsx +17 -12
- package/src/client/components/Composer.tsx +26 -20
- package/src/client/components/ConstraintList.tsx +69 -0
- package/src/client/components/FailCard.tsx +49 -0
- package/src/client/components/Fold.tsx +72 -0
- package/src/client/components/HoverTip.tsx +134 -0
- package/src/client/components/MessageFlow.tsx +77 -54
- package/src/client/components/MsgActions.tsx +85 -0
- package/src/client/components/NavPanel.tsx +6 -1
- package/src/client/components/ThinkRow.tsx +7 -3
- package/src/client/components/ToolRow.tsx +7 -3
- package/src/client/hooks/useComposer.ts +40 -3
- package/src/client/hooks/useGroupChatState.ts +5 -4
- package/src/client/lib/composer-draft.ts +47 -0
- package/src/client/lib/styles.ts +73 -12
- package/src/client/react-dom-shim.d.ts +2 -0
- package/src/core/constraints.ts +210 -0
- package/src/core/errors.ts +184 -0
- package/src/core/json.ts +1 -0
- package/src/core/types.ts +28 -3
- package/src/host/api/actions.ts +35 -1
- package/src/host/broadcast.ts +3 -3
- package/src/host/engine/conversation.ts +103 -38
- package/src/host/engine/fold.ts +114 -0
- package/src/host/engine/index.ts +1 -0
- package/src/host/engine/retitle.ts +16 -11
- package/src/host/persistence/persistence.ts +18 -1
- package/src/host/service.ts +1 -1
- package/src/host/state.ts +5 -4
- package/src/index.ts +2 -0
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 对话引擎:消息追加、群聊记录转写、角色发言(speak:prompt 构建 + 流式
|
|
3
|
-
* 轮次 + 工具回注循环)、多轮 runLoop
|
|
3
|
+
* 轮次 + 工具回注循环)、多轮 runLoop(结束时后台 retitle + 窗口外约束折叠)。
|
|
4
4
|
* @module dsh-group-chat/host/engine/conversation
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { realpathSync } from 'node:fs'
|
|
8
8
|
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
9
|
+
import { constraintBlock, formatTranscriptLine, prefixIds, speakerLabel, squeezedMessages, tempTranscript, WINDOW_SIZE } from '../../core/constraints.ts'
|
|
10
|
+
import { unwrapSpeakFailure } from '../../core/errors.ts'
|
|
9
11
|
import { TOOL_FLOOR_COST_CHARS, TOOL_REPEAT_LIMIT, TOOL_RESULTS_TOTAL_MAX, TRANSCRIPT_TOOL_SUMMARY } from '../../core/tools.ts'
|
|
10
12
|
import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord, SpeakResult, ToolCallRecord } from '../../core/types.ts'
|
|
11
13
|
import { asEffort } from '../../core/types.ts'
|
|
12
14
|
import type { HostState } from '../state.ts'
|
|
15
|
+
import { createFold } from './fold.ts'
|
|
13
16
|
import { createRetitle } from './retitle.ts'
|
|
14
17
|
import type { Materials } from '../materials/index.ts'
|
|
15
18
|
import type { Tools } from '../tools/index.ts'
|
|
@@ -30,16 +33,18 @@ interface StreamRound {
|
|
|
30
33
|
export interface Conversation {
|
|
31
34
|
/** 追加一条消息到会话(touch + 落盘调度)。 */
|
|
32
35
|
appendMessage: (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>) => MessageRecord
|
|
33
|
-
/** 多轮 round-robin 主循环(send 触发;finally 复位 run)。 */
|
|
34
|
-
runLoop: (sess: SessionRecord) => Promise<void>
|
|
36
|
+
/** 多轮 round-robin 主循环(send / retrySpeak 触发;finally 复位 run)。 */
|
|
37
|
+
runLoop: (sess: SessionRecord, opts?: { replaceMessageId?: string }) => Promise<void>
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/** 创建对话引擎。 */
|
|
38
41
|
export function createConversation(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void, materials: Materials, tools: Tools }): Conversation {
|
|
39
42
|
const { llm, groups, roles, messages, run } = core
|
|
40
43
|
const { touch, schedulePersist, materials, tools } = deps
|
|
41
|
-
//
|
|
44
|
+
// 标题/主题自动整理 + 窗口外约束折叠(finally 之后并列后台跑,不占用 run)
|
|
42
45
|
const retitle = createRetitle(core, { touch, schedulePersist })
|
|
46
|
+
const fold = createFold(core, { touch, schedulePersist })
|
|
47
|
+
const nameOf = (speaker: string): string => speakerLabel(speaker, (roles.get(speaker) || { name: undefined }).name)
|
|
43
48
|
|
|
44
49
|
const appendMessage = (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>): MessageRecord => {
|
|
45
50
|
const msg: MessageRecord = { id: core.nid('msg'), sessionId: sess.id, seq: sess.messageIds.length + 1, speaker, text, reasoning: undefined, model: undefined, error: undefined, ts: Date.now() }
|
|
@@ -49,6 +54,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
49
54
|
if (extra.thinkingSummary !== undefined) msg.thinkingSummary = extra.thinkingSummary
|
|
50
55
|
if (extra.model !== undefined) msg.model = extra.model
|
|
51
56
|
if (extra.error !== undefined) msg.error = extra.error
|
|
57
|
+
if (extra.failedRoleId !== undefined) msg.failedRoleId = extra.failedRoleId
|
|
52
58
|
if (Array.isArray(extra.toolCalls) && extra.toolCalls.length > 0) msg.toolCalls = extra.toolCalls
|
|
53
59
|
}
|
|
54
60
|
messages.set(msg.id, msg)
|
|
@@ -58,38 +64,75 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
58
64
|
return msg
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
/**
|
|
62
|
-
const
|
|
67
|
+
/** 把失败回合写成该角色的消息(speaker = 角色 id),不再用系统胶囊顶替。 */
|
|
68
|
+
const writeFailure = (sess: SessionRecord, role: RoleRecord, err: unknown, replaceId?: string): void => {
|
|
69
|
+
const raw = unwrapSpeakFailure(String((err && (err as Error).message) || err))
|
|
70
|
+
const extra: Partial<MessageRecord> = {
|
|
71
|
+
error: true,
|
|
72
|
+
failedRoleId: role.id,
|
|
73
|
+
model: role.provider + ' / ' + role.model,
|
|
74
|
+
}
|
|
75
|
+
if (replaceId) {
|
|
76
|
+
const existing = messages.get(replaceId)
|
|
77
|
+
if (existing && existing.sessionId === sess.id) {
|
|
78
|
+
existing.speaker = role.id
|
|
79
|
+
existing.text = raw
|
|
80
|
+
existing.error = true
|
|
81
|
+
existing.failedRoleId = role.id
|
|
82
|
+
existing.model = extra.model
|
|
83
|
+
existing.reasoning = undefined
|
|
84
|
+
existing.reasoningFull = undefined
|
|
85
|
+
existing.thinkingSummary = undefined
|
|
86
|
+
existing.toolCalls = undefined
|
|
87
|
+
existing.ts = Date.now()
|
|
88
|
+
touch()
|
|
89
|
+
schedulePersist({ session: sess.id })
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
appendMessage(sess, role.id, raw, extra)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 成功发言写入:replaceId 存在则原地覆盖失败卡。 */
|
|
97
|
+
const writeSuccess = (sess: SessionRecord, role: RoleRecord, out: SpeakResult, replaceId?: string): void => {
|
|
98
|
+
const extra: Partial<MessageRecord> = { model: role.provider + ' / ' + role.model, reasoning: out.reasoning, toolCalls: out.toolCalls }
|
|
99
|
+
if (replaceId) {
|
|
100
|
+
const existing = messages.get(replaceId)
|
|
101
|
+
if (existing && existing.sessionId === sess.id) {
|
|
102
|
+
existing.speaker = role.id
|
|
103
|
+
existing.text = out.text
|
|
104
|
+
existing.error = undefined
|
|
105
|
+
existing.failedRoleId = undefined
|
|
106
|
+
existing.model = extra.model
|
|
107
|
+
existing.reasoning = out.reasoning
|
|
108
|
+
existing.reasoningFull = undefined
|
|
109
|
+
existing.thinkingSummary = undefined
|
|
110
|
+
existing.toolCalls = Array.isArray(out.toolCalls) && out.toolCalls.length > 0 ? out.toolCalls : undefined
|
|
111
|
+
existing.ts = Date.now()
|
|
112
|
+
touch()
|
|
113
|
+
schedulePersist({ session: sess.id })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
appendMessage(sess, role.id, out.text, extra)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 群聊记录 → 角色上下文块(最近 40 条 + 未折入临时原文)。失败卡不进上下文。重试截到该条之前。 */
|
|
121
|
+
const transcriptBlock = (sess: SessionRecord, skipId?: string): string => {
|
|
63
122
|
const out: string[] = []
|
|
64
|
-
for (const mid of sess.messageIds) {
|
|
123
|
+
for (const mid of prefixIds(sess.messageIds, skipId).slice(-WINDOW_SIZE)) {
|
|
65
124
|
const m = messages.get(mid)
|
|
66
|
-
if (!m) continue
|
|
67
|
-
|
|
68
|
-
let text = m.text || ''
|
|
69
|
-
if (text.length > 8000) text = text.slice(0, 8000) + '…(已截断)'
|
|
70
|
-
let line = '【' + name + '】' + text
|
|
71
|
-
if (Array.isArray(m.toolCalls)) {
|
|
72
|
-
for (const c of m.toolCalls) {
|
|
73
|
-
if (!c || typeof c.tool !== 'string') continue
|
|
74
|
-
let brief = ''
|
|
75
|
-
try {
|
|
76
|
-
brief = JSON.stringify(c.args) || ''
|
|
77
|
-
} catch {
|
|
78
|
-
brief = ''
|
|
79
|
-
}
|
|
80
|
-
if (brief.length > 60) brief = brief.slice(0, 60) + '…'
|
|
81
|
-
const st = c.status === 'ok' ? '成功' : c.status === 'denied' ? '用户拒绝' : '失败'
|
|
82
|
-
let ob = String(c.output || '')
|
|
83
|
-
if (ob.length > TRANSCRIPT_TOOL_SUMMARY) ob = ob.slice(0, TRANSCRIPT_TOOL_SUMMARY) + '…'
|
|
84
|
-
line += '\n [工具] ' + c.tool + ' ' + brief + ' → ' + st + (ob ? '(' + ob.replace(/\s+/g, ' ') + ')' : '')
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
out.push(line)
|
|
125
|
+
if (!m || m.id === skipId || m.error) continue
|
|
126
|
+
out.push(formatTranscriptLine(m, nameOf(m.speaker)))
|
|
88
127
|
}
|
|
89
|
-
|
|
128
|
+
const live = out.join('\n\n')
|
|
129
|
+
const temp = tempTranscript(squeezedMessages(messages, sess, skipId), (m) => nameOf(m.speaker))
|
|
130
|
+
if (!temp) return live
|
|
131
|
+
if (!live) return temp
|
|
132
|
+
return temp + '\n\n' + live
|
|
90
133
|
}
|
|
91
134
|
|
|
92
|
-
const speak = async (g: GroupRecord, sess: SessionRecord, role: RoleRecord): Promise<SpeakResult> => {
|
|
135
|
+
const speak = async (g: GroupRecord, sess: SessionRecord, role: RoleRecord, skipId?: string): Promise<SpeakResult> => {
|
|
93
136
|
const ws = await materials.loadWorkspaceFiles(g)
|
|
94
137
|
const parts = ws.parts
|
|
95
138
|
const sys = [
|
|
@@ -99,6 +142,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
99
142
|
'- 名称:' + role.name,
|
|
100
143
|
'- 人设:' + (role.persona ? role.persona : '(未填写,请以积极协作者的身份参与讨论)'),
|
|
101
144
|
sess.topic ? '\n# 本会话主题\n' + sess.topic : '',
|
|
145
|
+
constraintBlock(sess.constraints),
|
|
102
146
|
materials.materialBlock(parts, ws.dir),
|
|
103
147
|
ws.dir
|
|
104
148
|
? '\n# 可用工具\n你可以调用工具在群组工作区目录(' + ws.dir + ')内查看文件与目录' +
|
|
@@ -115,7 +159,7 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
115
159
|
'- 保持简洁,通常不超过 300 字',
|
|
116
160
|
].filter((s) => s !== '').join('\n')
|
|
117
161
|
|
|
118
|
-
const history = transcriptBlock(sess)
|
|
162
|
+
const history = transcriptBlock(sess, skipId)
|
|
119
163
|
const intro = history
|
|
120
164
|
? '以下是本会话的群聊记录(从旧到新):\n\n' + history
|
|
121
165
|
: '本会话刚刚开始,请围绕主题做简短开场发言。'
|
|
@@ -281,10 +325,12 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
281
325
|
return { text: texts.join('\n\n').trim(), reasoning: reasonings.join('\n\n').trim() || undefined, toolCalls }
|
|
282
326
|
}
|
|
283
327
|
|
|
284
|
-
const runLoop = async (sess: SessionRecord): Promise<void> => {
|
|
328
|
+
const runLoop = async (sess: SessionRecord, opts?: { replaceMessageId?: string }): Promise<void> => {
|
|
285
329
|
const g = groups.get(sess.groupId)
|
|
286
330
|
const startCount = sess.messageIds.length
|
|
287
331
|
let failed = false // 发言失败 → 会话列表「已出错」标记
|
|
332
|
+
let replaceId = opts && opts.replaceMessageId
|
|
333
|
+
run.replaceMessageId = replaceId || null
|
|
288
334
|
try {
|
|
289
335
|
while (run.queue.length > 0 && !run.stopping) {
|
|
290
336
|
const roleId = run.queue.shift()!
|
|
@@ -294,15 +340,30 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
294
340
|
run.partialReasoning = ''
|
|
295
341
|
touch()
|
|
296
342
|
if (!role) continue
|
|
343
|
+
const targetId = replaceId
|
|
344
|
+
replaceId = undefined
|
|
297
345
|
try {
|
|
298
|
-
const out = await speak(g!, sess, role)
|
|
346
|
+
const out = await speak(g!, sess, role, targetId)
|
|
299
347
|
// 停止后不落部分消息(已有「已停止本次对话」系统消息承接)
|
|
300
348
|
if (!run.stopping && (out.text || (Array.isArray(out.toolCalls) && out.toolCalls.length > 0))) {
|
|
301
|
-
|
|
349
|
+
writeSuccess(sess, role, out, targetId)
|
|
350
|
+
} else if (!run.stopping && targetId) {
|
|
351
|
+
// 重试得到空输出:保留失败卡,避免成功覆盖成空白气泡
|
|
352
|
+
failed = true
|
|
353
|
+
run.currentRoleId = null
|
|
354
|
+
run.partial = ''
|
|
355
|
+
run.partialReasoning = ''
|
|
356
|
+
run.replaceMessageId = null
|
|
357
|
+
writeFailure(sess, role, '模型没有返回内容', targetId)
|
|
358
|
+
break
|
|
302
359
|
}
|
|
303
360
|
} catch (e) {
|
|
304
361
|
failed = true
|
|
305
|
-
|
|
362
|
+
run.currentRoleId = null
|
|
363
|
+
run.partial = ''
|
|
364
|
+
run.partialReasoning = ''
|
|
365
|
+
run.replaceMessageId = null
|
|
366
|
+
writeFailure(sess, role, e, targetId)
|
|
306
367
|
break
|
|
307
368
|
}
|
|
308
369
|
}
|
|
@@ -321,10 +382,14 @@ export function createConversation(core: HostState, deps: { touch: () => void, s
|
|
|
321
382
|
run.confirmSignal = null
|
|
322
383
|
run.childProc = null
|
|
323
384
|
run.stopping = false
|
|
385
|
+
run.replaceMessageId = null
|
|
324
386
|
touch()
|
|
325
387
|
}
|
|
326
|
-
//
|
|
327
|
-
if (sess.messageIds.length > startCount
|
|
388
|
+
// 本轮有新消息落盘,或原地重试覆盖了失败卡 → 后台整理标题/主题 + 窗口外约束
|
|
389
|
+
if (sess.messageIds.length > startCount || (opts && opts.replaceMessageId)) {
|
|
390
|
+
void retitle(sess)
|
|
391
|
+
void fold(sess)
|
|
392
|
+
}
|
|
328
393
|
}
|
|
329
394
|
|
|
330
395
|
return { appendMessage, runLoop }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 窗口外结论/约束备忘:整次 runLoop 结束后后台折叠(仿 retitle)。
|
|
3
|
+
* 立刻 idle;未折完用水位 + 临时原文表达。v1 复用 purpose session-title 关思考。
|
|
4
|
+
* @module dsh-group-chat/host/engine/fold
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
8
|
+
import {
|
|
9
|
+
CONSTRAINT_MAX_CHARS,
|
|
10
|
+
CONSTRAINT_MAX_ITEMS,
|
|
11
|
+
KIND_LABEL,
|
|
12
|
+
downgradeWithoutUser,
|
|
13
|
+
parseConstraints,
|
|
14
|
+
squeezedMaxSeq,
|
|
15
|
+
squeezedMessages,
|
|
16
|
+
speakerLabel,
|
|
17
|
+
takeFoldBatch,
|
|
18
|
+
} from '../../core/constraints.ts'
|
|
19
|
+
import type { SessionRecord } from '../../core/types.ts'
|
|
20
|
+
import type { HostState } from '../state.ts'
|
|
21
|
+
|
|
22
|
+
/** DSH 默认模型(与 retitle 同一读取面;缺位返回 null)。 */
|
|
23
|
+
const defaultModel = (core: HostState): { provider: string, model: string } | null => {
|
|
24
|
+
try {
|
|
25
|
+
const svc = core.ctx.reflect.get('agentDefaultModel')
|
|
26
|
+
const sel = svc ? svc.currentSelection() : null
|
|
27
|
+
return sel && typeof sel.provider === 'string' && typeof sel.model === 'string' && sel.provider && sel.model
|
|
28
|
+
? { provider: sel.provider, model: sel.model }
|
|
29
|
+
: null
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 每轮 send 结束后折叠窗口外约束:fire-and-forget、不产生消息、静默失败。
|
|
37
|
+
* 同会话去重;会话已清空则 abort。内存 {constraints, constraintsUpToSeq} 一次挂上。
|
|
38
|
+
*/
|
|
39
|
+
export function createFold(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void }): (sess: SessionRecord) => Promise<void> {
|
|
40
|
+
const { llm, messages, roles } = core
|
|
41
|
+
const { touch, schedulePersist } = deps
|
|
42
|
+
const folding = new Set<string>()
|
|
43
|
+
|
|
44
|
+
const nameOf = (speaker: string): string => speakerLabel(speaker, (roles.get(speaker) || { name: undefined }).name)
|
|
45
|
+
|
|
46
|
+
return async (sess: SessionRecord): Promise<void> => {
|
|
47
|
+
if (folding.has(sess.id)) return
|
|
48
|
+
const squeezed = squeezedMessages(messages, sess)
|
|
49
|
+
if (!squeezed.length) return
|
|
50
|
+
const input = takeFoldBatch(squeezed, (m) => nameOf(m.speaker))
|
|
51
|
+
const watermark = squeezedMaxSeq(input.consumed)
|
|
52
|
+
if (watermark <= 0) return
|
|
53
|
+
|
|
54
|
+
const commit = (next: SessionRecord['constraints'] | undefined): void => {
|
|
55
|
+
const live = core.sessions.get(sess.id)
|
|
56
|
+
if (!live || live.messageIds.length === 0) return
|
|
57
|
+
if (next && next.length) live.constraints = next
|
|
58
|
+
live.constraintsUpToSeq = watermark
|
|
59
|
+
schedulePersist({ session: live.id })
|
|
60
|
+
touch()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (input.allSystem) {
|
|
64
|
+
commit(undefined)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const dm = defaultModel(core)
|
|
69
|
+
if (!dm) return
|
|
70
|
+
|
|
71
|
+
folding.add(sess.id)
|
|
72
|
+
try {
|
|
73
|
+
const existing = (sess.constraints || []).map((c) => '- ' + KIND_LABEL[c.kind] + ':' + c.text)
|
|
74
|
+
const sys = [
|
|
75
|
+
'你是群聊会话的约束整理助手。把已经离开最近对话窗口的旧消息压成无主结论/约束备忘。',
|
|
76
|
+
'',
|
|
77
|
+
'# 规则',
|
|
78
|
+
'- 只输出一行 JSON:{"constraints":[{"kind":"decided|rejected|open","text":"…"}, ...]}',
|
|
79
|
+
'- kind 只能是 decided(已定)/ rejected(否决)/ open(未决)',
|
|
80
|
+
'- 无主:条目不写说话人。8–12 条、合计不超过 ' + CONSTRAINT_MAX_CHARS + ' 字,最多 ' + CONSTRAINT_MAX_ITEMS + ' 条',
|
|
81
|
+
'- 已定/否决只能依据【用户】原文;角色对打一律标 open(未决)',
|
|
82
|
+
'- 同主题:新已定覆盖旧未决;旧已定不能因角色反对改写,除非【用户】改口',
|
|
83
|
+
'- 没有新结论时输出 {"constraints":[]}(保留旧备忘)',
|
|
84
|
+
'- 超预算时按 已定 > 未决 > 过程叙述 取舍;语言跟随记录',
|
|
85
|
+
'',
|
|
86
|
+
'当前备忘:',
|
|
87
|
+
existing.length ? existing.join('\n') : '(空)',
|
|
88
|
+
].join('\n')
|
|
89
|
+
const user = '旧消息(从旧到新,含工具一行摘要):\n\n' + input.lines.join('\n\n')
|
|
90
|
+
let acc = ''
|
|
91
|
+
for await (const chunk of llm.stream({
|
|
92
|
+
provider: dm.provider,
|
|
93
|
+
model: dm.model,
|
|
94
|
+
system: sys,
|
|
95
|
+
purpose: 'session-title',
|
|
96
|
+
messages: [{ id: ('g' + core.revision + '-c0') as Message['id'], role: 'user', content: [{ type: 'text', text: user }], source: { kind: 'user' } }],
|
|
97
|
+
} as GenerateOptions)) {
|
|
98
|
+
if (chunk.type === 'text-delta') {
|
|
99
|
+
acc += chunk.text
|
|
100
|
+
if (acc.length > 4000) break
|
|
101
|
+
} else if (chunk.type === 'finish') {
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const parsed = parseConstraints(acc)
|
|
106
|
+
if (parsed === null) return
|
|
107
|
+
commit(parsed.length ? downgradeWithoutUser(parsed, input.hasUser) : undefined)
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error('[dsh-group-chat] 会话约束折叠失败(跳过,不影响对话):', e)
|
|
110
|
+
} finally {
|
|
111
|
+
folding.delete(sess.id)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/host/engine/index.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 会话标题/主题自动整理(参照 oil-codex-title):每轮结束后用 DSH 默认模型
|
|
3
|
-
*
|
|
3
|
+
* 后台生成。名称只在仍为默认占位时写一次;主题每轮演进。手动编辑过的字段
|
|
4
4
|
* 永久跳过(隐式固定)。
|
|
5
5
|
* @module dsh-group-chat/host/engine/retitle
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
9
9
|
import type { SessionRecord } from '../../core/types.ts'
|
|
10
|
-
import type
|
|
10
|
+
import { DEFAULT_SESSION_NAME, type HostState } from '../state.ts'
|
|
11
|
+
|
|
12
|
+
/** 仍是新建占位名(含改名之前的「会话 N」存量),自动标题尚未落地。 */
|
|
13
|
+
const isPlaceholderName = (name: string): boolean =>
|
|
14
|
+
name === DEFAULT_SESSION_NAME || /^会话 \d+$/.test(name)
|
|
11
15
|
|
|
12
16
|
/** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
|
|
13
17
|
const defaultModel = (core: HostState): { provider: string, model: string } | null => {
|
|
@@ -30,7 +34,7 @@ const titleTranscript = (core: HostState, sess: SessionRecord): string => {
|
|
|
30
34
|
const out: string[] = []
|
|
31
35
|
for (const mid of sess.messageIds.slice(-40)) {
|
|
32
36
|
const m = core.messages.get(mid)
|
|
33
|
-
if (!m || m.speaker === 'system' || !m.text) continue
|
|
37
|
+
if (!m || m.speaker === 'system' || m.error || !m.text) continue
|
|
34
38
|
const name = m.speaker === 'user' ? '用户' : (core.roles.get(m.speaker) || { name: undefined }).name || '成员'
|
|
35
39
|
out.push('【' + name + '】' + m.text.replace(/\s+/g, ' ').slice(0, 500))
|
|
36
40
|
}
|
|
@@ -55,9 +59,9 @@ const parseRetitle = (raw: string): { name?: string, topic?: string } => {
|
|
|
55
59
|
|
|
56
60
|
/**
|
|
57
61
|
* 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
62
|
+
* 消息、静默失败。名称只在仍为默认占位时生成一次(「类别 emoji + 对象|目标」);
|
|
63
|
+
* 主题 = 演进式一句话摘要(对象+目标+当前焦点),每轮更新,注入后续角色上下文。
|
|
64
|
+
* 手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
|
|
61
65
|
*/
|
|
62
66
|
export function createRetitle(core: HostState, deps: { touch: () => void, schedulePersist: (targets?: { session?: string | null }) => void }): (sess: SessionRecord) => Promise<void> {
|
|
63
67
|
const { llm } = core
|
|
@@ -69,7 +73,8 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
|
|
|
69
73
|
return async (sess: SessionRecord): Promise<void> => {
|
|
70
74
|
if (retitling.has(sess.id)) return
|
|
71
75
|
const dm = defaultModel(core)
|
|
72
|
-
|
|
76
|
+
const nameFrozen = !!sess.namePinned || !isPlaceholderName(sess.name)
|
|
77
|
+
if (!dm || (nameFrozen && sess.topicPinned)) return
|
|
73
78
|
const transcript = titleTranscript(core, sess)
|
|
74
79
|
if (!transcript) return
|
|
75
80
|
retitling.add(sess.id)
|
|
@@ -80,9 +85,9 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
|
|
|
80
85
|
'# 名称规则',
|
|
81
86
|
'- 格式:「类别 emoji + 对象|目标」,例如「🔎 缓存选型|Redis 与本地 KV 对比」',
|
|
82
87
|
'- 类别固定六选一:🔎 调研对比(多方案/多观点比较)、💡 头脑风暴(创意发散)、⚖️ 方案评审(评审已有方案或产物)、🛠️ 排查修复(定位与解决问题)、📝 方法整理(总结沉淀方法与知识)、🗣️ 通用讨论(其余兜底)',
|
|
83
|
-
'-
|
|
88
|
+
'- 对象在前:把辨识度最高的讨论对象放最前;省略群组名(外层已展示)',
|
|
84
89
|
'- 目标 = 当前正在做的事,动宾短语,保持简洁',
|
|
85
|
-
'- 名称总长不超过 16
|
|
90
|
+
'- 名称总长不超过 16 个字;名称只生成一次,不要为了追问/继续而改名',
|
|
86
91
|
'',
|
|
87
92
|
'# 主题规则',
|
|
88
93
|
'- 一句话演进式摘要:讨论对象 + 当前目标 + 当前焦点/分歧点',
|
|
@@ -92,7 +97,7 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
|
|
|
92
97
|
'- 语言跟随用户消息的主要语言;保留产品名与技术名词',
|
|
93
98
|
'- 只输出一行 JSON:{"name": "…", "topic": "…"},不要输出其他内容',
|
|
94
99
|
'',
|
|
95
|
-
'当前名称:' + (
|
|
100
|
+
'当前名称:' + (nameFrozen ? '(已固定,本次不要输出 name 字段)' : sess.name),
|
|
96
101
|
'当前主题:' + (sess.topicPinned ? '(已手动固定,本次不要输出 topic 字段)' : (sess.topic || '(空)')),
|
|
97
102
|
].join('\n')
|
|
98
103
|
let acc = ''
|
|
@@ -120,7 +125,7 @@ export function createRetitle(core: HostState, deps: { touch: () => void, schedu
|
|
|
120
125
|
}
|
|
121
126
|
const parsed = parseRetitle(acc)
|
|
122
127
|
let changed = false
|
|
123
|
-
if (parsed.name && !sess.namePinned) { sess.name = parsed.name; changed = true }
|
|
128
|
+
if (parsed.name && !sess.namePinned && isPlaceholderName(sess.name)) { sess.name = parsed.name; changed = true }
|
|
124
129
|
if (parsed.topic && !sess.topicPinned) { sess.topic = parsed.topic; changed = true }
|
|
125
130
|
if (changed) {
|
|
126
131
|
schedulePersist({ session: sess.id })
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
|
+
import { repairFailedMessage } from '../../core/errors.ts'
|
|
8
9
|
import { messageJson, roleJson } from '../../core/json.ts'
|
|
10
|
+
import { sanitizeConstraints } from '../../core/constraints.ts'
|
|
9
11
|
import { asNumber, migrateTier } from '../../core/types.ts'
|
|
10
12
|
import type { GroupRecord, MessageRecord, RoleRecord, SessionRecord } from '../../core/types.ts'
|
|
11
13
|
import { emptyGroup, LedgerDocument, scanGroupIds, scanSessionIds, STORE_DIR, Store } from './store.ts'
|
|
@@ -17,6 +19,8 @@ interface SessionDocument {
|
|
|
17
19
|
topic?: string
|
|
18
20
|
namePinned?: boolean
|
|
19
21
|
topicPinned?: boolean
|
|
22
|
+
constraints?: unknown
|
|
23
|
+
constraintsUpToSeq?: number
|
|
20
24
|
createdAt?: number
|
|
21
25
|
messages?: Partial<MessageRecord>[]
|
|
22
26
|
}
|
|
@@ -74,6 +78,8 @@ export function createPersistence(core: HostState): Persistence {
|
|
|
74
78
|
topic: s.topic,
|
|
75
79
|
...(s.namePinned ? { namePinned: true } : {}),
|
|
76
80
|
...(s.topicPinned ? { topicPinned: true } : {}),
|
|
81
|
+
...(s.constraints && s.constraints.length ? { constraints: s.constraints } : {}),
|
|
82
|
+
...(typeof s.constraintsUpToSeq === 'number' && s.constraintsUpToSeq > 0 ? { constraintsUpToSeq: s.constraintsUpToSeq } : {}),
|
|
77
83
|
createdAt: s.createdAt,
|
|
78
84
|
messages: s.messageIds.map((mid) => core.messages.get(mid)).filter(Boolean).map((m) => messageJson(m)).filter(Boolean),
|
|
79
85
|
})
|
|
@@ -178,6 +184,9 @@ export function createPersistence(core: HostState): Persistence {
|
|
|
178
184
|
if (typeof doc.topic === 'string') sess.topic = doc.topic
|
|
179
185
|
if (doc.namePinned === true) sess.namePinned = true
|
|
180
186
|
if (doc.topicPinned === true) sess.topicPinned = true
|
|
187
|
+
const constraints = sanitizeConstraints(doc.constraints)
|
|
188
|
+
if (constraints.length) sess.constraints = constraints
|
|
189
|
+
if (typeof doc.constraintsUpToSeq === 'number' && doc.constraintsUpToSeq > 0) sess.constraintsUpToSeq = doc.constraintsUpToSeq
|
|
181
190
|
if (typeof doc.createdAt === 'number') sess.createdAt = doc.createdAt
|
|
182
191
|
let fallbackSeq = 0
|
|
183
192
|
for (const m of (Array.isArray(doc.messages) ? doc.messages : [])) {
|
|
@@ -194,11 +203,19 @@ export function createPersistence(core: HostState): Persistence {
|
|
|
194
203
|
thinkingSummary: m.thinkingSummary !== undefined ? String(m.thinkingSummary) : undefined,
|
|
195
204
|
model: m.model,
|
|
196
205
|
error: m.error,
|
|
206
|
+
failedRoleId: typeof m.failedRoleId === 'string' && m.failedRoleId ? m.failedRoleId : undefined,
|
|
197
207
|
toolCalls: Array.isArray(m.toolCalls) ? m.toolCalls : undefined,
|
|
198
208
|
ts: typeof m.ts === 'number' ? m.ts : Date.now(),
|
|
199
209
|
})
|
|
200
210
|
sess.messageIds.push(m.id)
|
|
201
211
|
}
|
|
212
|
+
const groupRoles = g.roleIds.map((rid) => core.roles.get(rid)).filter((r): r is RoleRecord => Boolean(r))
|
|
213
|
+
let repaired = false
|
|
214
|
+
for (const mid of sess.messageIds) {
|
|
215
|
+
const rec = core.messages.get(mid)
|
|
216
|
+
if (rec && repairFailedMessage(rec, groupRoles)) repaired = true
|
|
217
|
+
}
|
|
218
|
+
if (repaired) schedulePersist({ session: sid })
|
|
202
219
|
}
|
|
203
220
|
core.sessions.set(sid, sess)
|
|
204
221
|
g.sessionIds.push(sid)
|
|
@@ -274,7 +291,7 @@ export function createPersistence(core: HostState): Persistence {
|
|
|
274
291
|
if (core.groups.size === 0) {
|
|
275
292
|
const g: GroupRecord = { id: core.nid('grp'), name: '默认群组', workspaceDir: '', permissionTier: 'view_only', roleIds: [], sessionIds: [] }
|
|
276
293
|
core.groups.set(g.id, g)
|
|
277
|
-
const sess = core.newSession(g.id
|
|
294
|
+
const sess = core.newSession(g.id)
|
|
278
295
|
g.sessionIds.push(sess.id)
|
|
279
296
|
autoSessions.push(sess.id)
|
|
280
297
|
}
|
package/src/host/service.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* persistence/ 持久化(store 文件原语 + 脏标记合并落盘 + 启动恢复)
|
|
7
7
|
* materials/ 资料读取与路径解析 + 目录浏览器
|
|
8
8
|
* tools/ 工具执行(沙箱 + 确认闸门)
|
|
9
|
-
* engine/ 对话引擎(conversation:speak/runLoop;retitle
|
|
9
|
+
* engine/ 对话引擎(conversation:speak/runLoop;retitle:标题整理;fold:窗口外约束)
|
|
10
10
|
* api/ HTTP 传输(http 护栏 + routes 路由)与动作分发(actions)
|
|
11
11
|
* @module dsh-group-chat/host/service
|
|
12
12
|
*/
|
package/src/host/state.ts
CHANGED
|
@@ -28,6 +28,9 @@ declare module '@deepseek-ai/cordis' {
|
|
|
28
28
|
/** 角色标识色板(新增角色依序取色)。 */
|
|
29
29
|
export const PALETTE = ['#5b8def', '#22a06b', '#e8912d', '#c678dd', '#e05661', '#56b6c2', '#98c379', '#d19a66']
|
|
30
30
|
|
|
31
|
+
/** 新建会话默认名称(自动标题只在仍为此占位名时生成一次)。 */
|
|
32
|
+
export const DEFAULT_SESSION_NAME = '新会话'
|
|
33
|
+
|
|
31
34
|
/** 宿主服务共享状态容器(见模块注释;可变原始值一律经 core.* 访问)。 */
|
|
32
35
|
export interface HostState {
|
|
33
36
|
ctx: Context
|
|
@@ -60,19 +63,17 @@ export function createHostState(ctx: Context): HostState {
|
|
|
60
63
|
sessions: new Map(),
|
|
61
64
|
roles: new Map(),
|
|
62
65
|
messages: new Map(),
|
|
63
|
-
run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, childProc: null, finished: null },
|
|
66
|
+
run: { running: false, sessionId: null, currentRoleId: null, partial: '', partialReasoning: '', stopping: false, queue: [], pendingConfirm: null, confirmSignal: null, childProc: null, finished: null, replaceMessageId: null },
|
|
64
67
|
store: null,
|
|
65
68
|
revision: 1,
|
|
66
69
|
idSeq: 1,
|
|
67
70
|
nid: (p: string): string => p + '-' + (core.idSeq++),
|
|
68
71
|
lastCreated: null,
|
|
69
72
|
newSession: (groupId: string, name?: string): SessionRecord => {
|
|
70
|
-
let n = 0
|
|
71
|
-
for (const s of core.sessions.values()) if (s.groupId === groupId) n++
|
|
72
73
|
const s: SessionRecord = {
|
|
73
74
|
id: randomUUID(),
|
|
74
75
|
groupId,
|
|
75
|
-
name: name ||
|
|
76
|
+
name: name || DEFAULT_SESSION_NAME,
|
|
76
77
|
topic: '',
|
|
77
78
|
messageIds: [],
|
|
78
79
|
createdAt: Date.now(),
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* 数据模型:Group 1..N Session,消息挂在会话上;角色与工作区目录挂在群组上
|
|
11
11
|
* - 群组与会话的增删改;群组/会话检索由客户端在快照上过滤
|
|
12
12
|
* - 角色发言经 `llm` 服务流式生成,按角色绑定的 provider/model 路由
|
|
13
|
+
* - 整次 runLoop 结束后后台折叠被 40 条窗口挤出的消息为会话级约束备忘
|
|
14
|
+
* (engine/fold.ts;立刻 idle、fire-and-forget,不产生消息)
|
|
13
15
|
* - 群组工作区目录经 `fs` 服务读取(根下一层文本文件,最多 20 个),
|
|
14
16
|
* 以「共享资料」块注入每个角色的 system 提示词;无独立笔记/文件清单
|
|
15
17
|
* - 经 `webServer` 暴露 HTTP API:
|