@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.
- package/README.md +91 -27
- package/lib/client.js +1254 -598
- package/lib/client.js.map +1 -1
- package/lib/index.js +573 -109
- package/lib/types/client/components/AsidePanel.d.ts +3 -19
- 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/components/NavPanel.d.ts +9 -11
- package/lib/types/client/components/RoleDrawer.d.ts +3 -2
- package/lib/types/client/components/ToolRow.d.ts +2 -2
- package/lib/types/client/hooks/useComposer.d.ts +6 -1
- package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
- package/lib/types/client/lib/composer-draft.d.ts +17 -0
- package/lib/types/client/lib/model.d.ts +1 -7
- package/lib/types/client/utils/utils.d.ts +4 -4
- package/lib/types/core/constraints.d.ts +61 -0
- package/lib/types/core/errors.d.ts +54 -0
- package/lib/types/core/json.d.ts +6 -0
- package/lib/types/core/types.d.ts +76 -60
- 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/defaults.d.ts +13 -0
- 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/lib/types/shared/file-mention-grammar.d.ts +3 -12
- package/package.json +1 -1
- package/src/client/GroupChatPanel.tsx +25 -59
- package/src/client/components/AsidePanel.tsx +50 -23
- 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 +64 -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 +78 -54
- package/src/client/components/MsgActions.tsx +85 -0
- package/src/client/components/NavPanel.tsx +102 -79
- package/src/client/components/RoleDrawer.tsx +29 -7
- package/src/client/components/ThinkRow.tsx +7 -3
- package/src/client/components/ToolRow.tsx +9 -5
- package/src/client/hooks/useComposer.ts +54 -56
- package/src/client/hooks/useGroupChatState.ts +7 -17
- package/src/client/lib/composer-draft.ts +47 -0
- package/src/client/lib/model.ts +1 -14
- package/src/client/lib/styles.ts +73 -12
- package/src/client/react-dom-shim.d.ts +2 -0
- package/src/client/utils/utils.ts +6 -6
- package/src/core/constraints.ts +204 -0
- package/src/core/errors.ts +174 -0
- package/src/core/json.ts +18 -0
- package/src/core/types.ts +52 -19
- package/src/host/api/actions.ts +49 -14
- package/src/host/broadcast.ts +3 -3
- package/src/host/engine/conversation.ts +104 -40
- package/src/host/engine/defaults.ts +28 -0
- package/src/host/engine/fold.ts +99 -0
- package/src/host/engine/index.ts +1 -0
- package/src/host/engine/retitle.ts +24 -41
- package/src/host/materials/materials.ts +2 -0
- package/src/host/persistence/persistence.ts +18 -1
- package/src/host/service.ts +1 -1
- package/src/host/state.ts +5 -4
- package/src/host/tools/tools.ts +2 -2
- package/src/index.ts +2 -0
- package/src/shared/file-mention-grammar.test.ts +1 -38
- package/src/shared/file-mention-grammar.ts +7 -33
- package/lib/types/client/Bubble.d.ts +0 -10
- package/lib/types/client/Glyph.d.ts +0 -12
- package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
- package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
- package/lib/types/client/PermissionSelect.d.ts +0 -13
- package/lib/types/client/RoleDrawer.d.ts +0 -17
- package/lib/types/client/ThinkRow.d.ts +0 -9
- package/lib/types/client/ToolRow.d.ts +0 -9
- package/lib/types/client/api.d.ts +0 -8
- package/lib/types/client/components.d.ts +0 -22
- package/lib/types/client/drawer.d.ts +0 -17
- package/lib/types/client/glyph.d.ts +0 -12
- package/lib/types/client/model.d.ts +0 -78
- package/lib/types/client/panel.d.ts +0 -10
- package/lib/types/client/settings.d.ts +0 -15
- package/lib/types/client/styles.d.ts +0 -10
- package/lib/types/client/ui.d.ts +0 -17
- package/lib/types/client/utils.d.ts +0 -22
- package/lib/types/host/actions.d.ts +0 -34
- package/lib/types/host/conversation.d.ts +0 -25
- package/lib/types/host/http.d.ts +0 -16
- package/lib/types/host/materials.d.ts +0 -32
- package/lib/types/host/persistence.d.ts +0 -31
- package/lib/types/host/routes.d.ts +0 -11
- package/lib/types/host/store.d.ts +0 -65
- package/lib/types/host/tools.d.ts +0 -28
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 对话引擎:消息追加、群聊记录转写、角色发言(speak:prompt 构建 + 流式
|
|
3
|
-
* 轮次 + 工具回注循环)、多轮 runLoop
|
|
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
|
|
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>;
|
|
@@ -1,16 +1,16 @@
|
|
|
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
|
import type { SessionRecord } from '../../core/types.ts';
|
|
8
|
-
import type
|
|
8
|
+
import { type HostState } from '../state.ts';
|
|
9
9
|
/**
|
|
10
10
|
* 每轮结束后根据聊天内容整理会话名称与主题:后台 fire-and-forget、不产生
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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;
|
package/lib/types/index.d.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:
|
|
@@ -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
|
@@ -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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
378
|
-
onCancel={() => { setRoleDraft(null)
|
|
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
|
|
6
|
+
import { useState, type ReactNode } from 'react'
|
|
7
7
|
import { Icon, P } from '../lib/ui.ts'
|
|
8
|
-
import {
|
|
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) =>
|
|
20
|
-
|
|
21
|
-
|
|
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,
|
|
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={() => {
|
|
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={() => {
|
|
58
|
-
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault();
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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();
|
|
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 {
|
|
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:
|
|
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
|
|
26
|
-
const
|
|
27
|
-
|
|
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
|
-
{
|
|
43
|
-
? <
|
|
44
|
-
:
|
|
45
|
-
<div className="dsgc-msgtext">
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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}>
|