@roaming-ai/dsh-group-chat 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +91 -27
  2. package/lib/client.js +1254 -598
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +573 -109
  5. package/lib/types/client/components/AsidePanel.d.ts +3 -19
  6. package/lib/types/client/components/Bubble.d.ts +6 -3
  7. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  8. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  9. package/lib/types/client/components/FailCard.d.ts +9 -0
  10. package/lib/types/client/components/Fold.d.ts +19 -0
  11. package/lib/types/client/components/HoverTip.d.ts +22 -0
  12. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  13. package/lib/types/client/components/MsgActions.d.ts +14 -0
  14. package/lib/types/client/components/NavPanel.d.ts +9 -11
  15. package/lib/types/client/components/RoleDrawer.d.ts +3 -2
  16. package/lib/types/client/components/ToolRow.d.ts +2 -2
  17. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  18. package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
  19. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  20. package/lib/types/client/lib/model.d.ts +1 -7
  21. package/lib/types/client/utils/utils.d.ts +4 -4
  22. package/lib/types/core/constraints.d.ts +61 -0
  23. package/lib/types/core/errors.d.ts +54 -0
  24. package/lib/types/core/json.d.ts +6 -0
  25. package/lib/types/core/types.d.ts +76 -60
  26. package/lib/types/host/api/actions.d.ts +1 -1
  27. package/lib/types/host/engine/conversation.d.ts +5 -3
  28. package/lib/types/host/engine/defaults.d.ts +13 -0
  29. package/lib/types/host/engine/fold.d.ts +17 -0
  30. package/lib/types/host/engine/index.d.ts +1 -0
  31. package/lib/types/host/engine/retitle.d.ts +5 -5
  32. package/lib/types/host/service.d.ts +1 -1
  33. package/lib/types/host/state.d.ts +2 -0
  34. package/lib/types/index.d.ts +2 -0
  35. package/lib/types/shared/file-mention-grammar.d.ts +3 -12
  36. package/package.json +1 -1
  37. package/src/client/GroupChatPanel.tsx +25 -59
  38. package/src/client/components/AsidePanel.tsx +50 -23
  39. package/src/client/components/Bubble.tsx +45 -18
  40. package/src/client/components/ChatPanel.tsx +17 -12
  41. package/src/client/components/Composer.tsx +26 -20
  42. package/src/client/components/ConstraintList.tsx +64 -0
  43. package/src/client/components/FailCard.tsx +49 -0
  44. package/src/client/components/Fold.tsx +72 -0
  45. package/src/client/components/HoverTip.tsx +134 -0
  46. package/src/client/components/MessageFlow.tsx +78 -54
  47. package/src/client/components/MsgActions.tsx +85 -0
  48. package/src/client/components/NavPanel.tsx +102 -79
  49. package/src/client/components/RoleDrawer.tsx +29 -7
  50. package/src/client/components/ThinkRow.tsx +7 -3
  51. package/src/client/components/ToolRow.tsx +9 -5
  52. package/src/client/hooks/useComposer.ts +54 -56
  53. package/src/client/hooks/useGroupChatState.ts +7 -17
  54. package/src/client/lib/composer-draft.ts +47 -0
  55. package/src/client/lib/model.ts +1 -14
  56. package/src/client/lib/styles.ts +73 -12
  57. package/src/client/react-dom-shim.d.ts +2 -0
  58. package/src/client/utils/utils.ts +6 -6
  59. package/src/core/constraints.ts +204 -0
  60. package/src/core/errors.ts +174 -0
  61. package/src/core/json.ts +18 -0
  62. package/src/core/types.ts +52 -19
  63. package/src/host/api/actions.ts +49 -14
  64. package/src/host/broadcast.ts +3 -3
  65. package/src/host/engine/conversation.ts +104 -40
  66. package/src/host/engine/defaults.ts +28 -0
  67. package/src/host/engine/fold.ts +99 -0
  68. package/src/host/engine/index.ts +1 -0
  69. package/src/host/engine/retitle.ts +24 -41
  70. package/src/host/materials/materials.ts +2 -0
  71. package/src/host/persistence/persistence.ts +18 -1
  72. package/src/host/service.ts +1 -1
  73. package/src/host/state.ts +5 -4
  74. package/src/host/tools/tools.ts +2 -2
  75. package/src/index.ts +2 -0
  76. package/src/shared/file-mention-grammar.test.ts +1 -38
  77. package/src/shared/file-mention-grammar.ts +7 -33
  78. package/lib/types/client/Bubble.d.ts +0 -10
  79. package/lib/types/client/Glyph.d.ts +0 -12
  80. package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
  81. package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
  82. package/lib/types/client/PermissionSelect.d.ts +0 -13
  83. package/lib/types/client/RoleDrawer.d.ts +0 -17
  84. package/lib/types/client/ThinkRow.d.ts +0 -9
  85. package/lib/types/client/ToolRow.d.ts +0 -9
  86. package/lib/types/client/api.d.ts +0 -8
  87. package/lib/types/client/components.d.ts +0 -22
  88. package/lib/types/client/drawer.d.ts +0 -17
  89. package/lib/types/client/glyph.d.ts +0 -12
  90. package/lib/types/client/model.d.ts +0 -78
  91. package/lib/types/client/panel.d.ts +0 -10
  92. package/lib/types/client/settings.d.ts +0 -15
  93. package/lib/types/client/styles.d.ts +0 -10
  94. package/lib/types/client/ui.d.ts +0 -17
  95. package/lib/types/client/utils.d.ts +0 -22
  96. package/lib/types/host/actions.d.ts +0 -34
  97. package/lib/types/host/conversation.d.ts +0 -25
  98. package/lib/types/host/http.d.ts +0 -16
  99. package/lib/types/host/materials.d.ts +0 -32
  100. package/lib/types/host/persistence.d.ts +0 -31
  101. package/lib/types/host/routes.d.ts +0 -11
  102. package/lib/types/host/store.d.ts +0 -65
  103. package/lib/types/host/tools.d.ts +0 -28
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 对话引擎:消息追加、群聊记录转写、角色发言(speak:prompt 构建 + 流式
3
- * 轮次 + 工具回注循环)、多轮 runLoop(每轮结束触发 retitle 标题整理)。
3
+ * 轮次 + 工具回注循环)、多轮 runLoop(结束时后台 retitle + 窗口外约束折叠)。
4
4
  * @module dsh-group-chat/host/engine/conversation
5
5
  */
6
6
  import type { MessageRecord, SessionRecord } from '../../core/types.ts';
@@ -11,8 +11,10 @@ import type { Tools } from '../tools/index.ts';
11
11
  export interface Conversation {
12
12
  /** 追加一条消息到会话(touch + 落盘调度)。 */
13
13
  appendMessage: (sess: SessionRecord, speaker: string, text: string, extra?: Partial<MessageRecord>) => MessageRecord;
14
- /** 多轮 round-robin 主循环(send 触发;finally 复位 run)。 */
15
- runLoop: (sess: SessionRecord) => Promise<void>;
14
+ /** 多轮 round-robin 主循环(send / retrySpeak 触发;finally 复位 run)。 */
15
+ runLoop: (sess: SessionRecord, opts?: {
16
+ replaceMessageId?: string;
17
+ }) => Promise<void>;
16
18
  }
17
19
  /** 创建对话引擎。 */
18
20
  export declare function createConversation(core: HostState, deps: {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 引擎共享小件:DSH 默认模型读取 + 发言人展示名(retitle / fold / conversation 共用)。
3
+ * @module dsh-group-chat/host/engine/defaults
4
+ */
5
+ import type { RoleRecord } from '../../core/types.ts';
6
+ import type { HostState } from '../state.ts';
7
+ /** DSH 默认模型(agentDefaultModel 服务缺位或未配置时返回 null,调用方静默跳过)。 */
8
+ export declare const defaultModel: (core: HostState) => {
9
+ provider: string;
10
+ model: string;
11
+ } | null;
12
+ /** 发言人展示名(roles 表内查角色名;user/system 固定文案,见 speakerLabel)。 */
13
+ export declare const speakerNameOf: (roles: Map<string, RoleRecord>, speaker: string) => string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 窗口外结论/约束备忘:整次 runLoop 结束后后台折叠(仿 retitle)。
3
+ * 立刻 idle;未折完用水位 + 临时原文表达。v1 复用 purpose session-title 关思考。
4
+ * @module dsh-group-chat/host/engine/fold
5
+ */
6
+ import type { SessionRecord } from '../../core/types.ts';
7
+ import type { HostState } from '../state.ts';
8
+ /**
9
+ * 每轮 send 结束后折叠窗口外约束:fire-and-forget、不产生消息、静默失败。
10
+ * 同会话去重;会话已清空则 abort。内存 {constraints, constraintsUpToSeq} 一次挂上。
11
+ */
12
+ export declare function createFold(core: HostState, deps: {
13
+ touch: () => void;
14
+ schedulePersist: (targets?: {
15
+ session?: string | null;
16
+ }) => void;
17
+ }): (sess: SessionRecord) => Promise<void>;
@@ -4,4 +4,5 @@
4
4
  */
5
5
  export { createConversation } from './conversation.ts';
6
6
  export type { Conversation } from './conversation.ts';
7
+ export { createFold } from './fold.ts';
7
8
  export { createRetitle } from './retitle.ts';
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * 会话标题/主题自动整理(参照 oil-codex-title):每轮结束后用 DSH 默认模型
3
- * 后台生成「类别 emoji + 对象|目标」名称与演进式主题;手动编辑过的字段
3
+ * 后台生成。名称只在仍为默认占位时写一次;主题每轮演进。手动编辑过的字段
4
4
  * 永久跳过(隐式固定)。
5
5
  * @module dsh-group-chat/host/engine/retitle
6
6
  */
7
7
  import type { SessionRecord } from '../../core/types.ts';
8
- import type { HostState } from '../state.ts';
8
+ import { type HostState } from '../state.ts';
9
9
  /**
10
10
  * 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
11
- * 消息、静默失败。名称 =「类别 emoji + 对象|目标」(类别固定、对象稳定、
12
- * 目标实质变化才改);主题 = 演进式一句话摘要(对象+目标+当前焦点),注入
13
- * 后续轮次的角色上下文。手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
11
+ * 消息、静默失败。名称只在仍为默认占位时生成一次(「类别 emoji + 对象|目标」);
12
+ * 主题 = 演进式一句话摘要(对象+目标+当前焦点),每轮更新,注入后续角色上下文。
13
+ * 手动编辑过的字段永久跳过(隐式固定,apply 时复查)。
14
14
  */
15
15
  export declare function createRetitle(core: HostState, deps: {
16
16
  touch: () => void;
@@ -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
  */
@@ -20,6 +20,8 @@ declare module '@deepseek-ai/cordis' {
20
20
  }
21
21
  /** 角色标识色板(新增角色依序取色)。 */
22
22
  export declare const PALETTE: string[];
23
+ /** 新建会话默认名称(自动标题只在仍为此占位名时生成一次)。 */
24
+ export declare const DEFAULT_SESSION_NAME = "\u65B0\u4F1A\u8BDD";
23
25
  /** 宿主服务共享状态容器(见模块注释;可变原始值一律经 core.* 访问)。 */
24
26
  export interface HostState {
25
27
  ctx: Context;
@@ -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:
@@ -4,6 +4,9 @@
4
4
  * Sourced from @deepseek-ai/dsh-file-reference/lib/types/grammar.js
5
5
  * Cannot import directly due to client bundle purity gate (dsh-client-bundle-purity).
6
6
  *
7
+ * 只保留输入侧的 @token 识别(activeAtToken);候选的文本化(formatFileMention)
8
+ * 未被本插件采用——目录钻入走「插入整颗芯片」语义,见 docs/DESIGN.md Composer 契约。
9
+ *
7
10
  * @module dsh-group-chat/shared
8
11
  */
9
12
  export interface AtToken {
@@ -14,10 +17,6 @@ export interface AtToken {
14
17
  /** Whether the token uses quoted form @"..." */
15
18
  quoted: boolean;
16
19
  }
17
- export interface FileCandidate {
18
- kind: 'file' | 'directory';
19
- path: string;
20
- }
21
20
  /**
22
21
  * Extract active @token at cursor position.
23
22
  * Matches @ at word boundary (line start or after whitespace).
@@ -27,11 +26,3 @@ export interface FileCandidate {
27
26
  * @returns Token or undefined if no active @token at cursor
28
27
  */
29
28
  export declare function activeAtToken(line: string, cursor: number): AtToken | undefined;
30
- /**
31
- * Format file/directory candidate as @mention text.
32
- *
33
- * @param candidate - File or directory to format
34
- * @param preserveQuote - Keep quotes even when unnecessary (for drill-down continuity)
35
- * @returns Formatted @path or undefined for invalid paths
36
- */
37
- export declare function formatFileMention(candidate: FileCandidate, preserveQuote: boolean): string | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@roaming-ai/dsh-group-chat",
3
3
  "description": "DSH 模型群聊:多模型角色群组对话面板。角色绑定不同 provider/model,群内共享对话记录;设置页支持启用/停用。",
4
- "version": "0.2.2",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -15,9 +15,10 @@ import { NavPanel } from './components/NavPanel.tsx'
15
15
  import { AsidePanel } from './components/AsidePanel.tsx'
16
16
  import { ChatPanel } from './components/ChatPanel.tsx'
17
17
  import { useGroupChatState, type ActionOk } from './hooks/useGroupChatState.ts'
18
- import { useComposerEffects, useComposerInput, useMentionChip, useInputKeyboard } from './hooks/useComposer.ts'
18
+ import { useComposerEffects, useComposerInput, useComposerDraft, useMentionChip, useInputKeyboard } from './hooks/useComposer.ts'
19
19
  import { useFileSearch } from './hooks/useFileSearch.ts'
20
20
  import { useMentionCandidates, useMentionedRoles, useSafeMentionIndex } from './hooks/useMentionState.ts'
21
+ import { clearComposerDraft } from './lib/composer-draft.ts'
21
22
  import { draftFromRole, blankDraft, groupById, roleById, sessById, type ClientSnapshot, type ModelsResponse, type SnapshotRole } from './lib/model.ts'
22
23
 
23
24
  export function GroupChatPanel(): ReactNode {
@@ -40,16 +41,10 @@ export function GroupChatPanel(): ReactNode {
40
41
  setConfirmClear,
41
42
  roleDraft,
42
43
  setRoleDraft,
43
- roleFormError,
44
- setRoleFormError,
45
44
  models,
46
45
  setModels,
47
46
  modelsError,
48
47
  setModelsError,
49
- fileBrowser,
50
- setFileBrowser,
51
- wsDraft,
52
- setWsDraft,
53
48
  partsSel,
54
49
  setPartsSel,
55
50
  rounds,
@@ -83,7 +78,6 @@ export function GroupChatPanel(): ReactNode {
83
78
  // ---- 所有 Hooks 必须在条件返回之前调用 ----
84
79
  // Composer effects
85
80
  const { onMsgsScroll } = useComposerEffects(inputRef, scrollRef, input, atBottom, snap)
86
- const { syncFromDOM, onInputCE, onPasteCE, onDragOverCE, onDropCE } = useComposerInput(inputRef, setInput, setMention, setMentionIdx)
87
81
 
88
82
  // 选中群组/会话解析(用于 hooks 依赖)
89
83
  let group = snap && gid ? groupById(snap, gid) : null
@@ -96,6 +90,9 @@ export function GroupChatPanel(): ReactNode {
96
90
  sess = group.sessionIds.length ? sessById(snap, group.sessionIds[group.sessionIds.length - 1]) : null
97
91
  }
98
92
 
93
+ const { syncFromDOM, onInputCE, onPasteCE, onDragOverCE, onDropCE } = useComposerInput(inputRef, setInput, setMention, setMentionIdx, sess ? sess.id : null)
94
+ useComposerDraft(sess ? sess.id : null, inputRef, setInput, setMention)
95
+
99
96
  const enabledRoles = group ? group.roleIds.map((id) => roleById(snap!, id)).filter((r): r is SnapshotRole => !!r && r.enabled) : []
100
97
  const participants = partsSel || enabledRoles.map((r) => r.id)
101
98
  const busyNow = !!(sess && snap && snap.run.running && snap.run.sessionId === sess.id)
@@ -126,11 +123,11 @@ export function GroupChatPanel(): ReactNode {
126
123
  if (busyNow || !sess || sendingRef.current) return
127
124
  sendingRef.current = true
128
125
  try {
129
- const parts = mentionedRoles.length ? mentionedRoles.map((r) => r.id) : participants
130
- if (!parts.length) {
131
- setErr(mentionedRoles.length ? '' : '请至少选择一个参与角色(或在消息中 @成员)')
132
- if (!mentionedRoles.length) return
126
+ if (!participants.length && !mentionedRoles.length) {
127
+ setErr('请至少选择一个参与角色(或在消息中 @成员)')
128
+ return
133
129
  }
130
+ const parts = mentionedRoles.length ? mentionedRoles.map((r) => r.id) : participants
134
131
  const res = await action({ kind: 'send', sessionId: sess.id, text: input, participantRoleIds: parts, rounds }) as ActionOk | null
135
132
  if (res && !res.ok && res.error) setErr(res.error)
136
133
  else if (res && res.ok) {
@@ -140,6 +137,7 @@ export function GroupChatPanel(): ReactNode {
140
137
  setMention(null)
141
138
  setErr('')
142
139
  setAtBottom(true)
140
+ clearComposerDraft(sess.id)
143
141
  syncFromDOM()
144
142
  }
145
143
  } finally {
@@ -174,6 +172,16 @@ export function GroupChatPanel(): ReactNode {
174
172
  setPartsSel(has ? participants.filter((x) => x !== rid) : participants.concat([rid]))
175
173
  }
176
174
 
175
+ const retrySpeak = async (messageId: string): Promise<void> => {
176
+ if (!sess) return
177
+ if (busyNow || (snap && snap.run.running)) {
178
+ setToast({ text: '已有对话进行中,请先停止', seq: Date.now() })
179
+ return
180
+ }
181
+ const res = await action({ kind: 'retrySpeak', sessionId: sess.id, messageId }) as ActionOk | null
182
+ if (res && !res.ok && res.error) setToast({ text: res.error, seq: Date.now() })
183
+ }
184
+
177
185
  // 清空确认:不可清空(对话进行中)走 toast 提示,不再落到输入框上方的红字
178
186
  const doClear = async (): Promise<void> => {
179
187
  if (!sess) return
@@ -206,51 +214,14 @@ export function GroupChatPanel(): ReactNode {
206
214
 
207
215
  const openRoleEditor = async (role: SnapshotRole | null): Promise<void> => {
208
216
  setErr('')
209
- setRoleFormError('')
210
217
  if (!models || modelsError) await fetchModels()
211
218
  setRoleDraft(role ? draftFromRole(role) : blankDraft())
212
219
  }
213
220
 
214
- const saveRole = async (): Promise<void> => {
215
- if (!roleDraft) return
216
- if (!roleDraft.name.trim()) {
217
- setRoleFormError('角色名称不能为空')
218
- return
219
- }
220
- if (!roleDraft.provider || !roleDraft.model) {
221
- setRoleFormError('请选择角色绑定的模型')
222
- return
223
- }
224
- const res = await mutate({ op: 'upsertRole', groupId: group.id, role: roleDraft })
225
- if (res && res.ok && res.snapshot && !res.snapshot.error) {
226
- setRoleDraft(null)
227
- setRoleFormError('')
228
- }
229
- }
230
-
231
221
  const stopRun = async (): Promise<void> => {
232
222
  await action({ kind: 'stop', sessionId: sess!.id })
233
223
  }
234
224
 
235
- const openBrowser = async (path: string | undefined): Promise<void> => {
236
- setFileBrowser({ open: true, loading: true, list: null, error: '' })
237
- try {
238
- const res = await api.action({ kind: 'browse', path: path || '' }) as import('../core/types.ts').BrowseResult
239
- if (res && res.ok) setFileBrowser({ open: true, loading: false, list: res, error: '' })
240
- else setFileBrowser({ open: true, loading: false, list: null, error: (res && res.error) || '浏览失败' })
241
- } catch (e) {
242
- setFileBrowser({ open: true, loading: false, list: null, error: String((e && (e as Error).message) || e) })
243
- }
244
- }
245
-
246
- const selectCurrentDir = (): void => {
247
- if (!fileBrowser || !fileBrowser.list || !group) return
248
- const path = fileBrowser.list.path!
249
- void mutate({ op: 'setWorkspaceDir', groupId: group.id, path })
250
- setWsDraft(null)
251
- setFileBrowser(null)
252
- }
253
-
254
225
  return (
255
226
  <div className="dsgc-root">
256
227
  <NavPanel
@@ -318,19 +289,14 @@ export function GroupChatPanel(): ReactNode {
318
289
  action={action}
319
290
  mutate={mutate}
320
291
  setMention={setMention}
292
+ onRetrySpeak={(messageId) => { void retrySpeak(messageId) }}
321
293
  />
322
294
  <AsidePanel
323
295
  snap={snap}
324
296
  group={group}
325
297
  asideOpen={asideOpen}
326
- wsDraft={wsDraft}
327
- setWsDraft={setWsDraft}
328
- fileBrowser={fileBrowser}
329
- setFileBrowser={setFileBrowser}
330
298
  mutate={mutate}
331
299
  openRoleEditor={openRoleEditor}
332
- openBrowser={openBrowser}
333
- selectCurrentDir={selectCurrentDir}
334
300
  />
335
301
  {sess && confirmClear
336
302
  ? (
@@ -348,7 +314,7 @@ export function GroupChatPanel(): ReactNode {
348
314
  )}
349
315
  >
350
316
  <ul className="dsgc-clearnotes">
351
- <li>删除内容:本会话的用户消息与角色发言(含思考、工具调用记录),确认后立即落盘</li>
317
+ <li>删除内容:本会话的用户消息与角色发言(含思考、工具调用记录)以及本会话结论/约束备忘,确认后立即落盘</li>
352
318
  <li>不可恢复:此操作没有回收站,也没有撤销</li>
353
319
  <li>不受影响:会话本身与主题、群成员角色、工作区目录、权限档位</li>
354
320
  </ul>
@@ -371,12 +337,12 @@ export function GroupChatPanel(): ReactNode {
371
337
  <RoleDrawer
372
338
  draft={roleDraft}
373
339
  set={setRoleDraft}
340
+ groupId={group.id}
374
341
  models={models}
375
342
  modelsError={modelsError}
376
343
  onRetryModels={() => { void fetchModels() }}
377
- onSave={() => { void saveRole() }}
378
- onCancel={() => { setRoleDraft(null); setRoleFormError('') }}
379
- formError={roleFormError}
344
+ mutate={mutate}
345
+ onCancel={() => { setRoleDraft(null) }}
380
346
  />
381
347
  )
382
348
  : null}
@@ -1,28 +1,34 @@
1
1
  /**
2
- * 右侧成员与工作区栏组件
2
+ * 右侧成员与工作区栏组件。工作区目录编辑态(草稿、文件浏览器)为栏内自有状态。
3
3
  * @module dsh-group-chat/client/components
4
4
  */
5
5
 
6
- import type { ReactNode } from 'react'
6
+ import { useState, type ReactNode } from 'react'
7
7
  import { Icon, P } from '../lib/ui.ts'
8
- import { roleById, type ClientSnapshot, type ModelsResponse, type RoleDraft, type SnapshotRole } from '../lib/model.ts'
8
+ import { api } from '../lib/api.ts'
9
+ import { roleById, type ClientSnapshot, type SnapshotRole } from '../lib/model.ts'
10
+ import type { BrowseResult } from '../../core/types.ts'
9
11
 
10
12
  interface AsidePanelProps {
11
13
  snap: ClientSnapshot
12
14
  group: ClientSnapshot['groups'][number]
13
15
  asideOpen: boolean
14
- wsDraft: string | null
15
- setWsDraft: (val: string | null) => void
16
- fileBrowser: { open: boolean, loading: boolean, list: import('../../core/types.ts').BrowseResult | null, error: string } | null
17
- setFileBrowser: (val: { open: boolean, loading: boolean, list: import('../../core/types.ts').BrowseResult | null, error: string } | null) => void
18
16
  mutate: (args: Record<string, unknown>) => Promise<unknown>
19
- openRoleEditor: (role: SnapshotRole | null) => Promise<void>
20
- openBrowser: (path: string | undefined) => Promise<void>
21
- selectCurrentDir: () => void
17
+ openRoleEditor: (role: SnapshotRole | null) => void
18
+ }
19
+
20
+ /** 文件浏览器态(栏内自有)。 */
21
+ interface FileBrowserState {
22
+ open: boolean
23
+ loading: boolean
24
+ list: BrowseResult | null
25
+ error: string
22
26
  }
23
27
 
24
28
  export function AsidePanel(props: AsidePanelProps): ReactNode {
25
- const { snap, group, asideOpen, wsDraft, setWsDraft, fileBrowser, setFileBrowser, mutate, openRoleEditor, openBrowser, selectCurrentDir } = props
29
+ const { snap, group, asideOpen, mutate, openRoleEditor } = props
30
+ const [wsDraft, setWsDraft] = useState<string | null>(null)
31
+ const [fileBrowser, setFileBrowser] = useState<FileBrowserState | null>(null)
26
32
 
27
33
  const commitWsDir = (): void => {
28
34
  if (wsDraft !== null && group && wsDraft !== (group.workspaceDir || '')) {
@@ -31,6 +37,25 @@ export function AsidePanel(props: AsidePanelProps): ReactNode {
31
37
  setWsDraft(null)
32
38
  }
33
39
 
40
+ const openBrowser = async (path: string | undefined): Promise<void> => {
41
+ setFileBrowser({ open: true, loading: true, list: null, error: '' })
42
+ try {
43
+ const res = await api.action({ kind: 'browse', path: path || '' }) as BrowseResult
44
+ if (res && res.ok) setFileBrowser({ open: true, loading: false, list: res, error: '' })
45
+ else setFileBrowser({ open: true, loading: false, list: null, error: (res && res.error) || '浏览失败' })
46
+ } catch (e) {
47
+ setFileBrowser({ open: true, loading: false, list: null, error: String((e && (e as Error).message) || e) })
48
+ }
49
+ }
50
+
51
+ const selectCurrentDir = (): void => {
52
+ if (!fileBrowser || !fileBrowser.list || !group) return
53
+ const path = fileBrowser.list.path!
54
+ void mutate({ op: 'setWorkspaceDir', groupId: group.id, path })
55
+ setWsDraft(null)
56
+ setFileBrowser(null)
57
+ }
58
+
34
59
  return (
35
60
  <div className={'dsgc-aside' + (asideOpen ? '' : ' closed')} aria-hidden={asideOpen ? undefined : 'true'}>
36
61
  <div className="dsgc-sec">
@@ -38,7 +63,7 @@ export function AsidePanel(props: AsidePanelProps): ReactNode {
38
63
  群成员
39
64
  <span className="dsgc-secspacer" />
40
65
  <span className="dsgc-seccount">{group.roleIds.length ? group.roleIds.length + ' 个' : ''}</span>
41
- <P.Button variant="ghost" size="sm" onClick={() => { void openRoleEditor(null) }} aria-label="添加角色">
66
+ <P.Button variant="ghost" size="sm" onClick={() => { openRoleEditor(null) }} aria-label="添加角色">
42
67
  {Icon(P.IconPlusOutline16, 14)}添加
43
68
  </P.Button>
44
69
  </div>
@@ -54,8 +79,8 @@ export function AsidePanel(props: AsidePanelProps): ReactNode {
54
79
  className={'dsgc-role' + (r.enabled ? '' : ' off')}
55
80
  role="button"
56
81
  tabIndex={0}
57
- onClick={() => { void openRoleEditor(r) }}
58
- onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void openRoleEditor(r) } }}
82
+ onClick={() => { openRoleEditor(r) }}
83
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); openRoleEditor(r) } }}
59
84
  title="点击编辑角色"
60
85
  >
61
86
  <div className="dsgc-rolehead">
@@ -78,21 +103,23 @@ export function AsidePanel(props: AsidePanelProps): ReactNode {
78
103
  </div>
79
104
  {r.persona ? <div className="dsgc-rolepersona" title={r.persona}>{r.persona}</div> : null}
80
105
  <div className="dsgc-rolemenu">
81
- <span
82
- className="dsgc-rolemodel"
83
- title={r.provider + ' / ' + r.model + (r.thinking ? ' · 深度思考' + (r.reasoningEffort && r.reasoningEffort !== 'default' ? '(' + r.reasoningEffort + ')' : '') : '')}
84
- >
85
- {r.provider} / {r.model}
106
+ <span className="dsgc-rolemeta">
107
+ <span
108
+ className="dsgc-rolemodel"
109
+ title={r.provider + ' / ' + r.model + (r.thinking ? ' · 深度思考' + (r.reasoningEffort && r.reasoningEffort !== 'default' ? '(' + r.reasoningEffort + ')' : '') : '')}
110
+ >
111
+ {r.provider} / {r.model}
112
+ </span>
113
+ {r.thinking
114
+ ? <span className="dsgc-rolethink" title="深度思考">{Icon(P.IconThinkOutline14, 14)}</span>
115
+ : null}
86
116
  </span>
87
- {r.thinking
88
- ? <span title="深度思考" style={{ display: 'inline-flex', alignItems: 'center', color: 'var(--dsw-alias-label-tertiary,inherit)' }}>{Icon(P.IconThinkOutline14, 14)}</span>
89
- : null}
90
117
  <span className="dsgc-roleops">
91
118
  <button
92
119
  className="dsgc-opbtn"
93
120
  title="编辑角色"
94
121
  aria-label="编辑角色"
95
- onClick={(e) => { e.stopPropagation(); void openRoleEditor(r) }}
122
+ onClick={(e) => { e.stopPropagation(); openRoleEditor(r) }}
96
123
  >
97
124
  {Icon(P.IconEditOutline16, 14)}
98
125
  </button>
@@ -10,23 +10,40 @@
10
10
 
11
11
  import { memo, type ReactNode } from 'react'
12
12
  import { P } from '../lib/ui.ts'
13
- import { fmtTime, MD_LABELS, type ClientSnapshot, type SnapshotRole } from '../lib/model.ts'
13
+ import { classifySpeakFailure, formatSpeakFailureCopy, isSpeakFailure } from '../../core/errors.ts'
14
+ import { fmtTime, MD_LABELS, type SnapshotRole } from '../lib/model.ts'
15
+ import type { SnapshotMessage } from '../lib/model.ts'
14
16
  import { ThinkRow } from './ThinkRow.tsx'
15
17
  import { ToolRow } from './ToolRow.tsx'
18
+ import { FailCard } from './FailCard.tsx'
19
+ import { MsgActions } from './MsgActions.tsx'
16
20
 
17
21
  export interface BubbleProps {
18
- m: ClientSnapshot['messages'][number]
22
+ m: SnapshotMessage
19
23
  /** 父级解析好的发言角色(user/system 消息为 null)——避免 Bubble 依赖 snap identity。 */
20
24
  role: SnapshotRole | null
25
+ busy?: boolean
26
+ onRetry?: (messageId: string) => void
21
27
  }
22
28
 
23
- function BubbleInner({ m, role }: BubbleProps): ReactNode {
29
+ function BubbleInner({ m, role, busy, onRetry }: BubbleProps): ReactNode {
24
30
  const isUser = m.speaker === 'user'
25
- const isSys = m.speaker === 'system'
26
- const name = isUser ? '' : isSys ? '系统' : role ? role.name : '成员'
27
- if (isSys) return <div className={'dsgc-sysmsg' + (m.error ? ' err' : '')}>{m.text}</div>
31
+ const isFail = isSpeakFailure(m)
32
+ const isSys = m.speaker === 'system' && !isFail && !role
33
+ const name = isUser ? '' : role ? role.name : isSys ? '系统' : '成员'
34
+ if (isSys) return <div className="dsgc-sysmsg">{m.text}</div>
35
+
36
+ const retryTitle = !role
37
+ ? '失败角色已不存在,无法重试'
38
+ : !role.enabled
39
+ ? '该角色已停用,无法重试'
40
+ : busy
41
+ ? '已有对话进行中,请先停止'
42
+ : '重试该角色发言'
43
+ const copyText = isFail ? formatSpeakFailureCopy(classifySpeakFailure(m.text)) : (m.text || '')
44
+
28
45
  return (
29
- <div className={'dsgc-msg' + (isUser ? ' mine' : '')}>
46
+ <div className={'dsgc-msg' + (isUser ? ' mine' : '') + (isFail ? ' fail' : '')}>
30
47
  <div
31
48
  className={'dsgc-avatar' + (isUser ? ' mine' : '')}
32
49
  style={isUser || !role ? undefined : { border: '2px solid ' + (role.color || '#888') }}
@@ -39,15 +56,24 @@ function BubbleInner({ m, role }: BubbleProps): ReactNode {
39
56
  {m.model ? <span className="dsgc-msgmodel" title={m.model}>{m.model}</span> : null}
40
57
  {m.ts ? <span className="dsgc-msgtime">{fmtTime(m.ts)}</span> : null}
41
58
  </div>
42
- {isUser
43
- ? <div className="dsgc-msgtext">{m.text}</div>
44
- : (
45
- <div className="dsgc-msgtext">
46
- {m.reasoning ? <ThinkRow text={m.reasoning} /> : null}
47
- {(Array.isArray(m.toolCalls) ? m.toolCalls : []).map((c, i) => <ToolRow key={'tc' + i} c={c} />)}
48
- <P.MarkdownText text={m.text || '(无内容)'} labels={MD_LABELS} />
49
- </div>
50
- )}
59
+ {isFail
60
+ ? <FailCard raw={m.text} />
61
+ : isUser
62
+ ? <div className="dsgc-msgtext">{m.text}</div>
63
+ : (
64
+ <div className="dsgc-msgtext">
65
+ {m.reasoning ? <ThinkRow text={m.reasoning} /> : null}
66
+ {(Array.isArray(m.toolCalls) ? m.toolCalls : []).map((c, i) => <ToolRow key={'tc' + i} c={c} />)}
67
+ <P.MarkdownText text={m.text || '(无内容)'} labels={MD_LABELS} />
68
+ </div>
69
+ )}
70
+ <MsgActions
71
+ copyText={copyText}
72
+ always={isFail}
73
+ onRetry={isFail && onRetry ? () => { onRetry(m.id) } : undefined}
74
+ retryDisabled={busy || !role || !role.enabled}
75
+ retryTitle={retryTitle}
76
+ />
51
77
  </div>
52
78
  </div>
53
79
  )
@@ -55,10 +81,11 @@ function BubbleInner({ m, role }: BubbleProps): ReactNode {
55
81
 
56
82
  /** 渲染相关字段的值比较(消息不可变;角色仅名/色参与渲染)。 */
57
83
  function bubblePropsEqual(a: BubbleProps, b: BubbleProps): boolean {
84
+ if (a.busy !== b.busy || (a.onRetry == null) !== (b.onRetry == null)) return false
58
85
  const x = a.m
59
86
  const y = b.m
60
87
  if (x !== y) {
61
- if (x.id !== y.id || x.speaker !== y.speaker || x.text !== y.text || x.reasoning !== y.reasoning || x.model !== y.model || x.error !== y.error || x.ts !== y.ts) return false
88
+ if (x.id !== y.id || x.speaker !== y.speaker || x.text !== y.text || x.reasoning !== y.reasoning || x.model !== y.model || x.error !== y.error || x.failedRoleId !== y.failedRoleId || x.ts !== y.ts) return false
62
89
  const ta = Array.isArray(x.toolCalls) ? x.toolCalls : []
63
90
  const tb = Array.isArray(y.toolCalls) ? y.toolCalls : []
64
91
  if (ta.length !== tb.length) return false
@@ -70,7 +97,7 @@ function bubblePropsEqual(a: BubbleProps, b: BubbleProps): boolean {
70
97
  }
71
98
  const ra = a.role
72
99
  const rb = b.role
73
- return (ra ? ra.name + '\u0000' + (ra.color || '') : '') === (rb ? rb.name + '\u0000' + (rb.color || '') : '')
100
+ return (ra ? ra.id + '\u0000' + ra.name + '\u0000' + (ra.color || '') + '\u0000' + (ra.enabled ? '1' : '0') : '') === (rb ? rb.id + '\u0000' + rb.name + '\u0000' + (rb.color || '') + '\u0000' + (rb.enabled ? '1' : '0') : '')
74
101
  }
75
102
 
76
103
  export const Bubble = memo(BubbleInner, bubblePropsEqual)
@@ -54,6 +54,7 @@ interface ChatPanelProps {
54
54
  action: (payload: Record<string, unknown>) => Promise<unknown>
55
55
  mutate: (args: Record<string, unknown>) => Promise<unknown>
56
56
  setMention: (val: AtToken | null) => void
57
+ onRetrySpeak: (messageId: string) => void
57
58
  }
58
59
 
59
60
  export function ChatPanel(props: ChatPanelProps): ReactNode {
@@ -101,6 +102,7 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
101
102
  action,
102
103
  mutate,
103
104
  setMention,
105
+ onRetrySpeak,
104
106
  } = props
105
107
 
106
108
  const commitTopic = (): void => {
@@ -115,6 +117,7 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
115
117
  busyNow={busyNow}
116
118
  msgById={msgById}
117
119
  action={action}
120
+ onRetrySpeak={onRetrySpeak}
118
121
  />
119
122
  )
120
123
 
@@ -143,18 +146,20 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
143
146
  </button>
144
147
 
145
148
  <div className="dsgc-chathead">
146
- {sess ? <span className="dsgc-sess-title" title={'当前会话:' + sess.name}>{sess.name}</span> : null}
147
- <input
148
- className="dsgc-topic"
149
- value={topicDraft === null ? (sess ? sess.topic : '') : topicDraft}
150
- placeholder="设置本会话主题(可选)…"
151
- onChange={(e) => { setTopicDraft(e.target.value) }}
152
- onBlur={commitTopic}
153
- onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
154
- />
155
- <P.Button variant="ghost" size="sm" className="dsgc-clearbtn" title="清空当前会话的消息记录" onClick={() => { if (sess) setConfirmClear(true) }}>
156
- 清空
157
- </P.Button>
149
+ <div className="dsgc-chathead-row">
150
+ {sess ? <span className="dsgc-sess-title" title={'当前会话:' + sess.name}>{sess.name}</span> : null}
151
+ <input
152
+ className="dsgc-topic"
153
+ value={topicDraft === null ? (sess ? sess.topic : '') : topicDraft}
154
+ placeholder="设置本会话主题(可选)…"
155
+ onChange={(e) => { setTopicDraft(e.target.value) }}
156
+ onBlur={commitTopic}
157
+ onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
158
+ />
159
+ <P.Button variant="ghost" size="sm" className="dsgc-clearbtn" title="清空当前会话的消息记录" onClick={() => { if (sess) setConfirmClear(true) }}>
160
+ 清空
161
+ </P.Button>
162
+ </div>
158
163
  </div>
159
164
 
160
165
  <div className="dsgc-msgs" ref={scrollRef} onScroll={onMsgsScroll}>