@roaming-ai/dsh-group-chat 0.2.1 → 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 +986 -298
- 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 +74 -13
- 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
package/src/core/types.ts
CHANGED
|
@@ -41,6 +41,23 @@ export interface GroupRecord {
|
|
|
41
41
|
sessionIds: string[]
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** 会话约束条目类型(窗口外结论/约束备忘)。 */
|
|
45
|
+
export type ConstraintKind = 'decided' | 'rejected' | 'open'
|
|
46
|
+
|
|
47
|
+
/** 全部合法约束类型。 */
|
|
48
|
+
export const CONSTRAINT_KINDS: readonly ConstraintKind[] = ['decided', 'rejected', 'open']
|
|
49
|
+
|
|
50
|
+
/** 合法 kind 原样,其余 undefined。 */
|
|
51
|
+
export function asConstraintKind(value: unknown): ConstraintKind | undefined {
|
|
52
|
+
return typeof value === 'string' && (CONSTRAINT_KINDS as readonly string[]).includes(value) ? value as ConstraintKind : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 一条无主结论/约束(已定 / 否决 / 未决)。 */
|
|
56
|
+
export interface SessionConstraint {
|
|
57
|
+
kind: ConstraintKind
|
|
58
|
+
text: string
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
/** 会话:消息挂在会话上。 */
|
|
45
62
|
export interface SessionRecord {
|
|
46
63
|
id: string
|
|
@@ -51,6 +68,10 @@ export interface SessionRecord {
|
|
|
51
68
|
namePinned?: boolean
|
|
52
69
|
/** 主题已被手动编辑:自动整理永久跳过(隐式固定)。 */
|
|
53
70
|
topicPinned?: boolean
|
|
71
|
+
/** 窗口外结论/约束备忘;空则省略。 */
|
|
72
|
+
constraints?: SessionConstraint[]
|
|
73
|
+
/** 已折入备忘的最大消息 seq;0/缺省 = 尚未折过。 */
|
|
74
|
+
constraintsUpToSeq?: number
|
|
54
75
|
messageIds: string[]
|
|
55
76
|
createdAt: number
|
|
56
77
|
}
|
|
@@ -91,6 +112,8 @@ export interface MessageRecord {
|
|
|
91
112
|
thinkingSummary?: string
|
|
92
113
|
model?: string
|
|
93
114
|
error?: boolean
|
|
115
|
+
/** 发言失败时的角色 id;刷新后仍可对该条点重试。角色消息 speaker 即角色 id,此字段冗余兼容旧系统错误行。 */
|
|
116
|
+
failedRoleId?: string
|
|
94
117
|
toolCalls?: ToolCallRecord[]
|
|
95
118
|
ts: number
|
|
96
119
|
}
|
|
@@ -129,6 +152,8 @@ export interface RunState {
|
|
|
129
152
|
childProc: import('node:child_process').ChildProcess | null
|
|
130
153
|
/** 最近一次 run 的结束标记:会话列表「已完成/已出错」状态的数据源。 */
|
|
131
154
|
finished: RunFinished | null
|
|
155
|
+
/** 原地重试时被覆盖的失败消息 id;普通 send 为 null。 */
|
|
156
|
+
replaceMessageId: string | null
|
|
132
157
|
}
|
|
133
158
|
|
|
134
159
|
/** 角色发言的引擎产物。 */
|
|
@@ -180,12 +205,12 @@ export interface FileSearchResult {
|
|
|
180
205
|
/** 发到客户端的全量快照(wire 形态)。 */
|
|
181
206
|
export interface Snapshot {
|
|
182
207
|
revision: number
|
|
183
|
-
run: { running: boolean, sessionId: string | null, currentRoleId: string | null, partial: string, partialReasoning: string, pendingConfirm: PendingConfirm | null, finished: RunFinished | null }
|
|
208
|
+
run: { running: boolean, sessionId: string | null, currentRoleId: string | null, partial: string, partialReasoning: string, pendingConfirm: PendingConfirm | null, finished: RunFinished | null, replaceMessageId: string | null }
|
|
184
209
|
lastCreated: LastCreated | null
|
|
185
210
|
groups: { id: string, name: string, workspaceDir: string, permissionTier: PermissionTier, roleIds: string[], sessionIds: string[] }[]
|
|
186
|
-
sessions: { id: string, groupId: string, name: string, topic: string, messageIds: string[], createdAt: number }[]
|
|
211
|
+
sessions: { id: string, groupId: string, name: string, topic: string, constraints?: SessionConstraint[], messageIds: string[], createdAt: number }[]
|
|
187
212
|
roles: { id: string, groupId: string, name: string, color?: string, persona: string, provider: string, model: string, temperature?: number, reasoningEffort?: string, enabled: boolean, thinking: boolean }[]
|
|
188
|
-
messages: { id: string, sessionId: string, seq: number, speaker: string, text: string, reasoning?: string, model?: string, error?: boolean, toolCalls?: ToolCallRecord[], ts: number }[]
|
|
213
|
+
messages: { id: string, sessionId: string, seq: number, speaker: string, text: string, reasoning?: string, model?: string, error?: boolean, failedRoleId?: string, toolCalls?: ToolCallRecord[], ts: number }[]
|
|
189
214
|
error?: string
|
|
190
215
|
}
|
|
191
216
|
|
package/src/host/api/actions.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 动作分发(handleAction,POST /api/group-chat/action 的载荷):
|
|
3
|
-
* mutate(12 种 CRUD/配置操作)| send | stop | confirmCommand | models | efforts。
|
|
3
|
+
* mutate(12 种 CRUD/配置操作)| send | retrySpeak | stop | confirmCommand | models | efforts。
|
|
4
4
|
* @module dsh-group-chat/host/api/actions
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { rmSync, unlinkSync } from 'node:fs'
|
|
8
|
+
import { isSpeakFailure, repairFailedMessage } from '../../core/errors.ts'
|
|
8
9
|
import type { BrowseResult, EffortOptions, GroupRecord, ModelCatalog, MutateArgs, RoleRecord, SendArgs, Snapshot } from '../../core/types.ts'
|
|
9
10
|
import { asEffort, asNumber, asPermissionTier } from '../../core/types.ts'
|
|
10
11
|
import { PALETTE } from '../state.ts'
|
|
@@ -210,6 +211,8 @@ export function createActions(core: HostState, deps: {
|
|
|
210
211
|
if (run.running && run.sessionId === sess.id) return { ...snapshot(), error: '对话进行中,无法清空' }
|
|
211
212
|
for (const mid of sess.messageIds) messages.delete(mid)
|
|
212
213
|
sess.messageIds = []
|
|
214
|
+
sess.constraints = undefined
|
|
215
|
+
sess.constraintsUpToSeq = undefined
|
|
213
216
|
schedulePersist({ session: sess.id })
|
|
214
217
|
touch()
|
|
215
218
|
}
|
|
@@ -243,11 +246,41 @@ export function createActions(core: HostState, deps: {
|
|
|
243
246
|
run.queue = queue
|
|
244
247
|
run.stopping = false
|
|
245
248
|
run.finished = null // 新 run 覆盖旧的「输出完毕」未读标记
|
|
249
|
+
run.replaceMessageId = null
|
|
246
250
|
touch()
|
|
247
251
|
void runLoop(sess).catch((e) => console.error('group-chat run failed', e))
|
|
248
252
|
return { ok: true }
|
|
249
253
|
}
|
|
250
254
|
|
|
255
|
+
/** 对失败卡原地重试:只让该角色再讲一次,成功后覆盖同一条消息。 */
|
|
256
|
+
const retrySpeak = (args: { sessionId?: string, messageId?: string }): { ok: boolean, error?: string } => {
|
|
257
|
+
const sess = sessions.get(String(args && args.sessionId || ''))
|
|
258
|
+
if (!sess) return { ok: false, error: '会话不存在' }
|
|
259
|
+
if (run.running) return { ok: false, error: '已有对话进行中,请先停止' }
|
|
260
|
+
const msg = messages.get(String(args && args.messageId || ''))
|
|
261
|
+
if (!msg || msg.sessionId !== sess.id || !isSpeakFailure(msg)) return { ok: false, error: '没有可重试的失败发言' }
|
|
262
|
+
const g = groups.get(sess.groupId)
|
|
263
|
+
if (!g) return { ok: false, error: '群组不存在' }
|
|
264
|
+
const groupRoles = g.roleIds.map((id) => roles.get(id)).filter((r): r is RoleRecord => Boolean(r))
|
|
265
|
+
if (repairFailedMessage(msg, groupRoles)) {
|
|
266
|
+
schedulePersist({ session: sess.id })
|
|
267
|
+
touch()
|
|
268
|
+
}
|
|
269
|
+
const roleId = msg.failedRoleId || (msg.speaker !== 'user' && msg.speaker !== 'system' ? msg.speaker : '')
|
|
270
|
+
const role = roleId ? roles.get(roleId) : undefined
|
|
271
|
+
if (!role || role.groupId !== sess.groupId) return { ok: false, error: '失败角色已不存在,无法重试' }
|
|
272
|
+
if (!role.enabled) return { ok: false, error: '该角色已停用,无法重试' }
|
|
273
|
+
run.running = true
|
|
274
|
+
run.sessionId = sess.id
|
|
275
|
+
run.queue = [role.id]
|
|
276
|
+
run.stopping = false
|
|
277
|
+
run.finished = null
|
|
278
|
+
run.replaceMessageId = msg.id
|
|
279
|
+
touch()
|
|
280
|
+
void runLoop(sess, { replaceMessageId: msg.id }).catch((e) => console.error('group-chat retry failed', e))
|
|
281
|
+
return { ok: true }
|
|
282
|
+
}
|
|
283
|
+
|
|
251
284
|
const stop = (args: { sessionId?: string }): { ok: boolean } => {
|
|
252
285
|
if (run.running && (!args || !args.sessionId || run.sessionId === args.sessionId)) {
|
|
253
286
|
run.stopping = true
|
|
@@ -322,6 +355,7 @@ export function createActions(core: HostState, deps: {
|
|
|
322
355
|
const kind = body && body.kind
|
|
323
356
|
if (kind === 'mutate') return { ok: true, snapshot: mutate(body as unknown as MutateArgs), lastCreated: core.lastCreated }
|
|
324
357
|
if (kind === 'send') return send(body as unknown as SendArgs)
|
|
358
|
+
if (kind === 'retrySpeak') return retrySpeak(body as { sessionId?: string, messageId?: string })
|
|
325
359
|
if (kind === 'stop') return stop(body as { sessionId?: string })
|
|
326
360
|
if (kind === 'confirmCommand') return confirmCommand(body as { toolCallId?: string, allow?: boolean })
|
|
327
361
|
if (kind === 'models') return { ok: true, ...(await models()) }
|
package/src/host/broadcast.ts
CHANGED
|
@@ -41,12 +41,12 @@ export function createBroadcast(core: HostState): Broadcast {
|
|
|
41
41
|
|
|
42
42
|
const snapshot = (): Snapshot => ({
|
|
43
43
|
revision: core.revision,
|
|
44
|
-
run: { running: core.run.running, sessionId: core.run.sessionId, currentRoleId: core.run.currentRoleId, partial: core.run.partial, partialReasoning: core.run.partialReasoning, pendingConfirm: core.run.pendingConfirm, finished: core.run.finished },
|
|
44
|
+
run: { running: core.run.running, sessionId: core.run.sessionId, currentRoleId: core.run.currentRoleId, partial: core.run.partial, partialReasoning: core.run.partialReasoning, pendingConfirm: core.run.pendingConfirm, finished: core.run.finished, replaceMessageId: core.run.replaceMessageId },
|
|
45
45
|
lastCreated: core.lastCreated,
|
|
46
46
|
groups: [...core.groups.values()].map((g) => ({ id: g.id, name: g.name, workspaceDir: g.workspaceDir, permissionTier: g.permissionTier, roleIds: g.roleIds.slice(), sessionIds: g.sessionIds.slice() })),
|
|
47
|
-
sessions: [...core.sessions.values()].map((s) => ({ id: s.id, groupId: s.groupId, name: s.name, topic: s.topic, messageIds: s.messageIds.slice(), createdAt: s.createdAt })),
|
|
47
|
+
sessions: [...core.sessions.values()].map((s) => ({ id: s.id, groupId: s.groupId, name: s.name, topic: s.topic, ...(s.constraints && s.constraints.length ? { constraints: s.constraints } : {}), messageIds: s.messageIds.slice(), createdAt: s.createdAt })),
|
|
48
48
|
roles: [...core.roles.values()].map((r) => ({ id: r.id, groupId: r.groupId, name: r.name, color: r.color, persona: r.persona, provider: r.provider, model: r.model, temperature: r.temperature, reasoningEffort: r.reasoningEffort, enabled: r.enabled, thinking: r.thinking === true })),
|
|
49
|
-
messages: [...core.messages.values()].map((m) => ({ id: m.id, sessionId: m.sessionId, seq: m.seq, speaker: m.speaker, text: m.text, reasoning: m.reasoning, model: m.model, error: m.error, toolCalls: m.toolCalls, ts: m.ts })),
|
|
49
|
+
messages: [...core.messages.values()].map((m) => ({ id: m.id, sessionId: m.sessionId, seq: m.seq, speaker: m.speaker, text: m.text, reasoning: m.reasoning, model: m.model, error: m.error, failedRoleId: m.failedRoleId, toolCalls: m.toolCalls, ts: m.ts })),
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
return {
|
|
@@ -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 })
|