dsh-sidenote 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +2143 -0
- package/lib/index.js +6 -0
- package/lib/types/client/annotate/anchor.d.ts +57 -0
- package/lib/types/client/annotate/chip.d.ts +11 -0
- package/lib/types/client/annotate/draft.d.ts +29 -0
- package/lib/types/client/annotate/format.d.ts +52 -0
- package/lib/types/client/annotate/index.d.ts +2 -0
- package/lib/types/client/annotate/model.d.ts +59 -0
- package/lib/types/client/annotate/overlay.d.ts +27 -0
- package/lib/types/client/annotate/selection.d.ts +49 -0
- package/lib/types/client/bridge.d.ts +19 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/locales.d.ts +77 -0
- package/lib/types/client/sidechat/SideChatPanel.d.ts +2 -0
- package/lib/types/client/sidechat/composer.d.ts +26 -0
- package/lib/types/client/sidechat/index.d.ts +2 -0
- package/lib/types/client/sidechat/model.d.ts +101 -0
- package/lib/types/client/sidechat/open.d.ts +16 -0
- package/lib/types/client/sidechat/slash.d.ts +12 -0
- package/lib/types/context-types.d.ts +335 -0
- package/lib/types/index.d.ts +9 -0
- package/package.json +96 -0
- package/src/client/annotate/anchor.ts +158 -0
- package/src/client/annotate/annotate.module.css +301 -0
- package/src/client/annotate/chip.tsx +115 -0
- package/src/client/annotate/css.d.ts +9 -0
- package/src/client/annotate/draft.ts +67 -0
- package/src/client/annotate/format.ts +83 -0
- package/src/client/annotate/index.tsx +66 -0
- package/src/client/annotate/model.ts +138 -0
- package/src/client/annotate/overlay.tsx +531 -0
- package/src/client/annotate/selection.ts +203 -0
- package/src/client/bridge.ts +19 -0
- package/src/client/index.tsx +22 -0
- package/src/client/locales.ts +175 -0
- package/src/client/sidechat/SideChatPanel.tsx +366 -0
- package/src/client/sidechat/composer.ts +129 -0
- package/src/client/sidechat/css-modules.d.ts +5 -0
- package/src/client/sidechat/index.tsx +56 -0
- package/src/client/sidechat/model.ts +331 -0
- package/src/client/sidechat/open.ts +66 -0
- package/src/client/sidechat/sidechat.module.css +230 -0
- package/src/client/sidechat/slash.ts +84 -0
- package/src/context-types.ts +369 -0
- package/src/index.ts +13 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧边聊天 Tab 面板:fork 编排(首开)/ 绑定恢复(刷新后)/ 消息流 / composer。
|
|
3
|
+
*
|
|
4
|
+
* 打开流程(design.md 详细方案 2):组件挂载时 tab.meta 无 childId →
|
|
5
|
+
* ctx.sessions.fork({ sessionId: scope.sessionId })(fork 时刻全量历史快照)
|
|
6
|
+
* → ctx.workspaces.archiveSession(childId)(durable 隐藏出会话列表)
|
|
7
|
+
* → ctx.betterSidebar.updateTab(tab.id, { meta: { childId, parentSessionId } })
|
|
8
|
+
* (Tab meta 即注册表,随布局持久化,刷新/重启后恢复)。
|
|
9
|
+
* fork 失败(blank 会话无已完成 turn 等)→ 中文错误态 + 关闭指引,不崩页面。
|
|
10
|
+
*
|
|
11
|
+
* 恢复流程:有 meta.childId → ctx.sessions.binding(childId) 直接绑定;
|
|
12
|
+
* 列表就绪后仍不在列 → 「会话已不存在」态。
|
|
13
|
+
*
|
|
14
|
+
* 消息流:binding.session 快照订阅(useSyncExternalStore),visible=false 时
|
|
15
|
+
* 暂停订阅。已知偏差(记录于此):client-runtime 只为 staged(当前选中)会话
|
|
16
|
+
* 打开事件窗口,非 staged 会话 openState 停留 'cold' 且 acceptLiveEvent 丢弃
|
|
17
|
+
* 事件 —— 因此绑定后对该会话调一次 concrete Session 的 open()(off-face、
|
|
18
|
+
* feature-check、幂等),否则消息流永远为空。
|
|
19
|
+
*/
|
|
20
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
21
|
+
import { IconNewChatOutline16, IconSendOutline16, IconStopFill16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
22
|
+
import type { Context, SessionFace, TabComponentProps } from '../../context-types.ts'
|
|
23
|
+
import { useComposer, type Composer } from './composer.ts'
|
|
24
|
+
import { clearPendingDraft, parseSideChatMeta, phaseOf, transcriptOf, type ChatMessage } from './model.ts'
|
|
25
|
+
import { readTab } from './open.ts'
|
|
26
|
+
import { t, useLocaleTick } from '../locales.ts'
|
|
27
|
+
import css from './sidechat.module.css'
|
|
28
|
+
|
|
29
|
+
/** MarkdownText 代码块复制按钮文案(契约要求中文;引用稳定,变了会清流式缓存)。 */
|
|
30
|
+
/** MarkdownText 代码块复制按钮文案(跟随 DSH 语言;函数内读取保持引用稳定)。 */
|
|
31
|
+
function codeLabels() {
|
|
32
|
+
return { copyLabel: t('codeCopy'), copiedLabel: t('codeCopied') }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NOOP_UNSUBSCRIBE = (): void => {}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 打开非 staged 会话的事件窗口(off-face:open() 在 concrete Session 上是
|
|
39
|
+
* public 且幂等,但不在 SessionFace 契约上 —— 契约面只有 staged 会话会被
|
|
40
|
+
* 运行时自动 open)。feature-check + 吞错,运行时若移除则降级为只发不收。
|
|
41
|
+
*/
|
|
42
|
+
function openSessionWindow(session: SessionFace | undefined): void {
|
|
43
|
+
const openable = session as unknown as { open?: () => Promise<void> } | undefined
|
|
44
|
+
if (typeof openable?.open !== 'function') return
|
|
45
|
+
openable.open().catch((error: unknown) => {
|
|
46
|
+
console.warn('[dsh-sidenote] 会话窗口打开失败:', error)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function SideChatPanel(props: TabComponentProps) {
|
|
51
|
+
useLocaleTick()
|
|
52
|
+
const { ctx, scope, tab, visible } = props
|
|
53
|
+
const meta = parseSideChatMeta(tab.meta)
|
|
54
|
+
const childId = meta.childId
|
|
55
|
+
const [forkError, setForkError] = useState<string | null>(null)
|
|
56
|
+
const forkStarted = useRef(false)
|
|
57
|
+
|
|
58
|
+
// 程序化入口(/side、bridge 划选提问)打开 Tab 时面板可能处于折叠态——
|
|
59
|
+
// 类型型 openTab 不自动展开(better-sidebar 只对 path/url 内容型打开展开),
|
|
60
|
+
// 用户会看不见刚开的侧边聊天。面板组件在折叠时也挂载(visible=false),
|
|
61
|
+
// 挂载即幂等展开(store.update 是 SidebarStore 的公开 mutator 面;
|
|
62
|
+
// feature-check + 吞错)。
|
|
63
|
+
const { store } = props
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const mutable = store as { update?: (mutate: (state: { panelOpen?: boolean }) => void) => void } | undefined
|
|
66
|
+
try {
|
|
67
|
+
mutable?.update?.((state) => {
|
|
68
|
+
if (state.panelOpen === false) state.panelOpen = true
|
|
69
|
+
})
|
|
70
|
+
} catch {
|
|
71
|
+
// 面板折叠兜底失败不影响功能——用户手动展开即可。
|
|
72
|
+
}
|
|
73
|
+
}, [store])
|
|
74
|
+
|
|
75
|
+
// ── 首开:fork → archive → 登记 meta(注册表写进布局,随其持久化) ──
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (childId !== undefined || forkStarted.current) return
|
|
78
|
+
forkStarted.current = true
|
|
79
|
+
let cancelled = false
|
|
80
|
+
void (async () => {
|
|
81
|
+
try {
|
|
82
|
+
const forked = await ctx.sessions.fork({ sessionId: scope.sessionId })
|
|
83
|
+
// meta 先行:fork resolve 后立即登记 childId(会话切换导致组件卸载
|
|
84
|
+
// 时 updateTab 找不到 tab 也只是 no-op,绝不丢登记——否则该 Tab 永远
|
|
85
|
+
// 停在 forking 且下次挂载会重复 fork 出孤儿会话)。归档与模型同步
|
|
86
|
+
// 都是 best-effort 后手。
|
|
87
|
+
const current = parseSideChatMeta(readTab(ctx, tab.id)?.meta)
|
|
88
|
+
ctx.betterSidebar.updateTab(tab.id, {
|
|
89
|
+
meta: { ...current, childId: forked, parentSessionId: scope.sessionId },
|
|
90
|
+
})
|
|
91
|
+
// 隐藏出会话列表(durable KV,刷新/重启后仍生效)。归档失败不阻断
|
|
92
|
+
// 面板(会话已 fork 出来),只告警 —— 无 unarchive API,失败残留可见。
|
|
93
|
+
try {
|
|
94
|
+
await ctx.workspaces.archiveSession(forked)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.warn('[dsh-sidenote] 归档侧边会话失败(会话列表可能短暂可见):', error)
|
|
97
|
+
}
|
|
98
|
+
// 模型跟随主会话:fork 继承 agent preset 但不继承模型选择——读主会话
|
|
99
|
+
// 当前模型(session.models.current)并 selectModel 到子会话(best-effort,
|
|
100
|
+
// 失败则子会话用宿主默认模型,面板标签如实回退)。
|
|
101
|
+
try {
|
|
102
|
+
const parentModels = await ctx.connection.api.sessions.models({ sessionId: scope.sessionId })
|
|
103
|
+
if (parentModels.result.ok) {
|
|
104
|
+
const current = parentModels.result.value.current
|
|
105
|
+
await ctx.connection.api.sessions.selectModel({
|
|
106
|
+
sessionId: forked,
|
|
107
|
+
provider: current.provider,
|
|
108
|
+
model: current.model,
|
|
109
|
+
...(current.reasoningEffort !== undefined ? { reasoningEffort: current.reasoningEffort } : {}),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.warn('[dsh-sidenote] 同步主会话模型失败(子会话用默认模型):', error)
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (!cancelled) setForkError(error instanceof Error ? error.message : String(error))
|
|
117
|
+
}
|
|
118
|
+
})()
|
|
119
|
+
return () => { cancelled = true }
|
|
120
|
+
}, [ctx, scope.sessionId, tab.id, childId])
|
|
121
|
+
|
|
122
|
+
// ── 列表订阅:phase(就绪与否)+ byId(在列与否)驱动「会话已不存在」判定 ──
|
|
123
|
+
const listSnap = useSyncExternalStore(
|
|
124
|
+
useCallback((notify: () => void) => ctx.sessions.list.subscribe(notify), [ctx]),
|
|
125
|
+
() => ctx.sessions.list.getSnapshot(),
|
|
126
|
+
)
|
|
127
|
+
const listed = childId !== undefined && listSnap.byId?.[childId] !== undefined
|
|
128
|
+
const binding = childId === undefined ? undefined : ctx.sessions.binding(childId)
|
|
129
|
+
const session = binding?.session
|
|
130
|
+
|
|
131
|
+
// 绑定即开窗口(幂等):拉历史尾页 + 开始接收实时事件。
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
openSessionWindow(session)
|
|
134
|
+
}, [session])
|
|
135
|
+
|
|
136
|
+
const phase = phaseOf({
|
|
137
|
+
childId,
|
|
138
|
+
forkError,
|
|
139
|
+
bound: session !== undefined,
|
|
140
|
+
listPhase: listSnap.phase,
|
|
141
|
+
listed,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ── 消息流订阅:visible=false 时暂停(订阅身份随 visible 变化即断开) ──
|
|
145
|
+
const snapshot = useSyncExternalStore(
|
|
146
|
+
useCallback(
|
|
147
|
+
(notify: () => void) => (visible && session !== undefined ? session.subscribe(notify) : NOOP_UNSUBSCRIBE),
|
|
148
|
+
[visible, session],
|
|
149
|
+
),
|
|
150
|
+
() => (session === undefined ? null : session.getSnapshot()),
|
|
151
|
+
)
|
|
152
|
+
const messages = useMemo(() => transcriptOf(snapshot), [snapshot])
|
|
153
|
+
|
|
154
|
+
// ── composer(input 机器优先,降级本地草稿 + session.prompt) ──
|
|
155
|
+
const composer = useComposer(ctx, session, childId)
|
|
156
|
+
|
|
157
|
+
// ── 模型标签:读子会话当前模型(fork 时已同步主会话选择;读取失败保持默认文案) ──
|
|
158
|
+
const [modelName, setModelName] = useState<string | null>(null)
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (childId === undefined || session === undefined) return
|
|
161
|
+
let cancelled = false
|
|
162
|
+
void ctx.connection.api.sessions.models({ sessionId: childId })
|
|
163
|
+
.then((res) => {
|
|
164
|
+
if (!cancelled && res.result.ok) setModelName(res.result.value.current.model)
|
|
165
|
+
})
|
|
166
|
+
.catch(() => {})
|
|
167
|
+
return () => { cancelled = true }
|
|
168
|
+
}, [ctx, childId, session])
|
|
169
|
+
|
|
170
|
+
// ── 桥接草稿移交:meta.pendingDraft → composer 草稿,应用后清除 ──
|
|
171
|
+
const pendingDraft = meta.pendingDraft
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (pendingDraft === undefined || pendingDraft === '' || phase !== 'chat') return
|
|
174
|
+
composer.appendDraft(pendingDraft)
|
|
175
|
+
const current = parseSideChatMeta(readTab(ctx, tab.id)?.meta)
|
|
176
|
+
ctx.betterSidebar.updateTab(tab.id, { meta: clearPendingDraft(current) })
|
|
177
|
+
// appendDraft 随草稿逐键换身份;不列入依赖 —— effect 只在
|
|
178
|
+
// pendingDraft/相位变化时真正动作(清除后 pendingDraft 为 undefined,幂等)。
|
|
179
|
+
}, [pendingDraft, phase, ctx, tab.id])
|
|
180
|
+
|
|
181
|
+
// 新消息到底自动滚动(窄栏面板,不做「接近底部才跟」的判定)。
|
|
182
|
+
// tailKey 并入 reasoning 长度:纯思考流式增长(text 为空)也要跟滚。
|
|
183
|
+
const bodyRef = useRef<HTMLDivElement>(null)
|
|
184
|
+
const tailKey = messages.length === 0 ? '' : `${messages[messages.length - 1]!.key}:${messages[messages.length - 1]!.text.length}:${messages[messages.length - 1]!.reasoning?.length ?? 0}`
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const el = bodyRef.current
|
|
187
|
+
if (el !== null && visible) el.scrollTop = el.scrollHeight
|
|
188
|
+
}, [tailKey, visible])
|
|
189
|
+
|
|
190
|
+
if (phase === 'fork-error') {
|
|
191
|
+
return (
|
|
192
|
+
<StateScreen
|
|
193
|
+
title={t('forkErrorTitle')}
|
|
194
|
+
detail={forkError ?? undefined}
|
|
195
|
+
hint={t('forkErrorHint')}
|
|
196
|
+
/>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
if (phase === 'missing') {
|
|
200
|
+
return (
|
|
201
|
+
<StateScreen
|
|
202
|
+
title={t('missingTitle')}
|
|
203
|
+
detail={t('missingDetail')}
|
|
204
|
+
hint={t('missingHint')}
|
|
205
|
+
/>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
if (phase === 'forking' || phase === 'loading') {
|
|
209
|
+
return <StateScreen title={t('preparing')} />
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const running = snapshot?.running === true
|
|
213
|
+
const openFailed = snapshot?.openState === 'error'
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div className={css.root}>
|
|
217
|
+
<div ref={bodyRef} className={css.body}>
|
|
218
|
+
{messages.length === 0 && !running
|
|
219
|
+
? <EmptyState />
|
|
220
|
+
: <MessageList messages={messages} />}
|
|
221
|
+
{openFailed && <div className={css.errorRow}>{t('historyFailed')}</div>}
|
|
222
|
+
</div>
|
|
223
|
+
<ComposerBar ctx={ctx} session={session} composer={composer} running={running} visible={visible} modelName={modelName} />
|
|
224
|
+
</div>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 空状态:💬 类图标 + 标题 + fork 语义文案(形态规格)。 */
|
|
229
|
+
function EmptyState() {
|
|
230
|
+
useLocaleTick()
|
|
231
|
+
return (
|
|
232
|
+
<div className={css.empty}>
|
|
233
|
+
<div className={css.emptyIcon}><IconNewChatOutline16 size={32} /></div>
|
|
234
|
+
<div className={css.emptyTitle}>{t('emptyTitle')}</div>
|
|
235
|
+
<div className={css.emptyText}>{t('emptyText')}</div>
|
|
236
|
+
</div>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 加载 / 错误整屏态(标题 + 可选详情 + 可选指引)。 */
|
|
241
|
+
function StateScreen(props: { title: string; detail?: string; hint?: string }) {
|
|
242
|
+
useLocaleTick()
|
|
243
|
+
return (
|
|
244
|
+
<div className={css.stateScreen}>
|
|
245
|
+
<div className={css.emptyIcon}><IconNewChatOutline16 size={32} /></div>
|
|
246
|
+
<div className={css.emptyTitle}>{props.title}</div>
|
|
247
|
+
{props.detail !== undefined && props.detail !== '' && <div className={css.stateDetail}>{props.detail}</div>}
|
|
248
|
+
{props.hint !== undefined && props.hint !== '' && <div className={css.emptyText}>{props.hint}</div>}
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function MessageList({ messages }: { messages: readonly ChatMessage[] }) {
|
|
254
|
+
return (
|
|
255
|
+
<div className={css.transcript}>
|
|
256
|
+
{messages.map(message => <MessageRow key={message.key} message={message} />)}
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function MessageRow({ message }: { message: ChatMessage }) {
|
|
262
|
+
useLocaleTick()
|
|
263
|
+
switch (message.role) {
|
|
264
|
+
case 'user':
|
|
265
|
+
return (
|
|
266
|
+
<div className={css.userRow}>
|
|
267
|
+
<div className={css.userBubble}>{message.text}</div>
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
case 'assistant':
|
|
271
|
+
return (
|
|
272
|
+
<div className={css.assistantRow}>
|
|
273
|
+
<div className={css.assistantBody}>
|
|
274
|
+
{message.reasoning !== undefined && message.reasoning !== '' && (
|
|
275
|
+
<details className={css.reasoning}>
|
|
276
|
+
<summary>{t('thinking')}</summary>
|
|
277
|
+
<div className={css.reasoningBody}>{message.reasoning}</div>
|
|
278
|
+
</details>
|
|
279
|
+
)}
|
|
280
|
+
{message.text !== ''
|
|
281
|
+
? <MarkdownText text={message.text} streaming={message.streaming} codeLabels={codeLabels()} />
|
|
282
|
+
: message.streaming === true && <div className={css.streamingHint}>{t('writing')}</div>}
|
|
283
|
+
{message.interrupted === true && <div className={css.noticeRow}>{t('stopped')}</div>}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
)
|
|
287
|
+
case 'tool':
|
|
288
|
+
return (
|
|
289
|
+
<div className={css.toolCard}>
|
|
290
|
+
<div className={css.toolHead}>
|
|
291
|
+
{t('toolLabel')} · {message.toolName}
|
|
292
|
+
{message.isError === true && <span className={css.toolError}>{t('failed')}</span>}
|
|
293
|
+
{message.streaming === true && <span className={css.toolRunning}>{t('running')}</span>}
|
|
294
|
+
</div>
|
|
295
|
+
{message.text !== '' && <div className={css.toolBody}>{message.text}</div>}
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
case 'error':
|
|
299
|
+
return <div className={css.errorRow}>{message.text}</div>
|
|
300
|
+
case 'notice':
|
|
301
|
+
return <div className={css.noticeRow}>{message.text}</div>
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 底部 composer:自绘输入框;模型标签显示子会话真实当前模型(fork 时同步主会话选择)。 */
|
|
306
|
+
function ComposerBar(props: {
|
|
307
|
+
ctx: Context
|
|
308
|
+
session: SessionFace | undefined
|
|
309
|
+
composer: Composer
|
|
310
|
+
running: boolean
|
|
311
|
+
visible: boolean
|
|
312
|
+
modelName: string | null
|
|
313
|
+
}) {
|
|
314
|
+
useLocaleTick()
|
|
315
|
+
const { session, composer, running, visible } = props
|
|
316
|
+
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
317
|
+
|
|
318
|
+
// 面板可见时预聚焦输入框(sidebar-qa AskPanel 同款)。
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (visible) inputRef.current?.focus()
|
|
321
|
+
}, [visible])
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<div className={css.composer}>
|
|
325
|
+
<textarea
|
|
326
|
+
ref={inputRef}
|
|
327
|
+
className={css.input}
|
|
328
|
+
placeholder={t('inputPlaceholder')}
|
|
329
|
+
value={composer.draft}
|
|
330
|
+
onChange={(event) => { composer.setDraft(event.target.value) }}
|
|
331
|
+
onKeyDown={(event) => {
|
|
332
|
+
// IME 保护:组合中(候选窗未提交)的 Enter 属于输入法。
|
|
333
|
+
if (event.key !== 'Enter' || event.shiftKey) return
|
|
334
|
+
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
|
|
335
|
+
event.preventDefault()
|
|
336
|
+
composer.submit()
|
|
337
|
+
}}
|
|
338
|
+
/>
|
|
339
|
+
<div className={css.composerFoot}>
|
|
340
|
+
<span className={css.modelLabel}>{t('modelLabel', { name: props.modelName ?? t('modelFollowsMain') })}</span>
|
|
341
|
+
{running
|
|
342
|
+
? (
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
className={css.stopButton}
|
|
346
|
+
title={t('stopReplyTitle')}
|
|
347
|
+
onClick={() => { session?.cancel().catch(() => {}) }}
|
|
348
|
+
>
|
|
349
|
+
<IconStopFill16 size={14} /> {t('stopReply')}
|
|
350
|
+
</button>
|
|
351
|
+
)
|
|
352
|
+
: (
|
|
353
|
+
<button
|
|
354
|
+
type="button"
|
|
355
|
+
className={css.sendButton}
|
|
356
|
+
disabled={composer.draft.trim() === ''}
|
|
357
|
+
onClick={() => { composer.submit() }}
|
|
358
|
+
>
|
|
359
|
+
<IconSendOutline16 size={14} /> {t('send')}
|
|
360
|
+
</button>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
{composer.sendError !== null && <div className={css.errorRow}>{composer.sendError}</div>}
|
|
364
|
+
</div>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧边聊天 composer 的状态层:优先复用会话 input 机器(草稿机),
|
|
3
|
+
* 拿不到时整体降级为本地草稿 + session.prompt。
|
|
4
|
+
*
|
|
5
|
+
* 机器路径(dsh-better-sidebar src/client/conversation-draft.ts 同款惰性模式):
|
|
6
|
+
* ctx.sessions.scope(childId) 取会话 scope ctx
|
|
7
|
+
* → ctx.get('conversation') 惰性取会话服务
|
|
8
|
+
* → conversation.input.for(actx) 取该会话的 SessionInput 草稿机
|
|
9
|
+
* (draft 读写 / submit / notify 全在这张脸上)。
|
|
10
|
+
*
|
|
11
|
+
* 已知风险(design.md 已标注的 spike 点):InputHub 的 shell 由 sessions
|
|
12
|
+
* provide materialization 触发创建,「从未 staged 的会话 scope」可能没有
|
|
13
|
+
* resident shell —— 任何一步失败都降级,绝不崩面板。
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
|
|
16
|
+
import type { Context, ConversationService, SessionFace, SessionInput } from '../../context-types.ts'
|
|
17
|
+
import { appendDraftText } from './model.ts'
|
|
18
|
+
|
|
19
|
+
const NOOP_UNSUBSCRIBE = (): void => {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 解析 childId 会话的 input 草稿机;任何一步缺失/抛错返回 null
|
|
23
|
+
* (调用方走降级路径)。
|
|
24
|
+
*/
|
|
25
|
+
export function resolveSessionInput(ctx: Context, childId: string): SessionInput | null {
|
|
26
|
+
try {
|
|
27
|
+
const actx = ctx.sessions.scope(childId)
|
|
28
|
+
if (actx === undefined) return null
|
|
29
|
+
const conversation = ctx.get('conversation') as ConversationService | undefined
|
|
30
|
+
if (conversation === undefined || conversation === null) return null
|
|
31
|
+
const input = conversation.input.for(actx)
|
|
32
|
+
if (input === null || input === undefined) return null
|
|
33
|
+
if (typeof input.setDraft !== 'function' || typeof input.submit !== 'function') return null
|
|
34
|
+
if (input.state === undefined || typeof input.state.getSnapshot !== 'function') return null
|
|
35
|
+
return input
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 读草稿机的当前草稿(容错)。 */
|
|
42
|
+
export function readInputDraft(input: SessionInput): string {
|
|
43
|
+
try {
|
|
44
|
+
const draft = input.state.getSnapshot().draft
|
|
45
|
+
return typeof draft === 'string' ? draft : ''
|
|
46
|
+
} catch {
|
|
47
|
+
return ''
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** composer 对外脸:machine 标记实际落点(true = input 机器,false = 自绘降级)。 */
|
|
52
|
+
export interface Composer {
|
|
53
|
+
readonly machine: boolean
|
|
54
|
+
readonly draft: string
|
|
55
|
+
readonly sendError: string | null
|
|
56
|
+
setDraft(text: string): void
|
|
57
|
+
/** 拼接一段外部文本(桥接注入的引文草稿)。 */
|
|
58
|
+
appendDraft(text: string): void
|
|
59
|
+
/** 发送当前草稿(空草稿 no-op)。 */
|
|
60
|
+
submit(): void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 面板 composer hook。机器路径:草稿住在 input 机器的 store 里(随会话持久、
|
|
65
|
+
* 与主输入框同语义),submit 走机器事务(序列化/裁决/默认 sink 全在机器内)。
|
|
66
|
+
* 降级路径:草稿住本地 state,发送直连 session.prompt([...], 'queue'),
|
|
67
|
+
* 失败时回填草稿并报错。
|
|
68
|
+
*/
|
|
69
|
+
export function useComposer(ctx: Context, session: SessionFace | undefined, childId: string | undefined): Composer {
|
|
70
|
+
// 每个 childId 解析一次:机器可用性在会话生命周期内不变。
|
|
71
|
+
const input = useMemo(
|
|
72
|
+
() => (childId === undefined ? null : resolveSessionInput(ctx, childId)),
|
|
73
|
+
[ctx, childId],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const machineDraft = useSyncExternalStore(
|
|
77
|
+
useCallback(
|
|
78
|
+
(notify: () => void) => (input === null ? NOOP_UNSUBSCRIBE : input.state.subscribe(notify)),
|
|
79
|
+
[input],
|
|
80
|
+
),
|
|
81
|
+
() => (input === null ? '' : readInputDraft(input)),
|
|
82
|
+
)
|
|
83
|
+
const [localDraft, setLocalDraft] = useState('')
|
|
84
|
+
const [sendError, setSendError] = useState<string | null>(null)
|
|
85
|
+
|
|
86
|
+
const draft = input === null ? localDraft : machineDraft
|
|
87
|
+
|
|
88
|
+
const setDraft = useCallback(
|
|
89
|
+
(text: string): void => {
|
|
90
|
+
if (input === null) setLocalDraft(text)
|
|
91
|
+
else input.setDraft(text)
|
|
92
|
+
},
|
|
93
|
+
[input],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const appendDraft = useCallback(
|
|
97
|
+
(text: string): void => {
|
|
98
|
+
if (input === null) setLocalDraft(d => appendDraftText(d, text))
|
|
99
|
+
else input.setDraft(appendDraftText(readInputDraft(input), text))
|
|
100
|
+
},
|
|
101
|
+
[input],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const submit = useCallback((): void => {
|
|
105
|
+
const text = draft.trim()
|
|
106
|
+
if (text === '') return
|
|
107
|
+
if (input !== null) {
|
|
108
|
+
// 机器路径:草稿清空、发送失败回填、通知条全由机器/sink 负责。
|
|
109
|
+
input.submit()
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (session === undefined) return
|
|
113
|
+
setLocalDraft('')
|
|
114
|
+
setSendError(null)
|
|
115
|
+
session.prompt([{ type: 'text', text }], 'queue').then((result) => {
|
|
116
|
+
const r = result as { ok?: boolean; error?: { message?: string } } | undefined
|
|
117
|
+
if (r !== undefined && r.ok === false) {
|
|
118
|
+
// 仅在用户未另行输入时回填,不盖掉新草稿。
|
|
119
|
+
setLocalDraft(d => (d === '' ? text : d))
|
|
120
|
+
setSendError(r.error?.message ?? '发送失败')
|
|
121
|
+
}
|
|
122
|
+
}).catch((error: unknown) => {
|
|
123
|
+
setLocalDraft(d => (d === '' ? text : d))
|
|
124
|
+
setSendError(error instanceof Error ? error.message : '发送失败')
|
|
125
|
+
})
|
|
126
|
+
}, [draft, input, session])
|
|
127
|
+
|
|
128
|
+
return { machine: input !== null, draft, sendError, setDraft, appendDraft, submit }
|
|
129
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workitem 01 — 侧边聊天面板。
|
|
3
|
+
*
|
|
4
|
+
* 注册「侧边聊天」Tab 类型(dsh-sidenote:side,多实例一等公民):
|
|
5
|
+
* - + 菜单可见(order 60,terminal=40 / browser=50 之后);菜单行标题
|
|
6
|
+
* 「侧边聊天」,铸造出的 Tab 标题为「侧边」/「侧边 N」(createTab 按
|
|
7
|
+
* 当前并存数编号 —— 带 createTab 的 descriptor 会被 openTab 忽略
|
|
8
|
+
* seed.title,编号必须在铸造处完成);
|
|
9
|
+
* - createTab 铸 `side:<uuid>`(crypto.randomUUID())多实例 id;
|
|
10
|
+
* - blank 着陆页会话没有已完成 turn、fork 必败 → available 禁用 +
|
|
11
|
+
* 面板内错误态双保险;
|
|
12
|
+
* - Tab × 关闭即从界面消失(better-sidebar 关 tab 即出布局,meta 随之
|
|
13
|
+
* 消失;jsonl 留盘是设计意图,不做删除)。
|
|
14
|
+
*
|
|
15
|
+
* 另安装:annotate 桥(sideChatBridge.current,WI-03 联动缝)与 /side
|
|
16
|
+
* 斜杠命令(spike,popupSelect 形态,不可行时降级为只有 Tab 入口)。
|
|
17
|
+
*/
|
|
18
|
+
import { IconNewChatOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
19
|
+
import type { Context } from '../../context-types.ts'
|
|
20
|
+
import { sideChatBridge } from '../bridge.ts'
|
|
21
|
+
import { SideChatPanel } from './SideChatPanel.tsx'
|
|
22
|
+
import { SIDE_TAB_TYPE, canForkFrom, collectSideTabs, mintSideTabId, sideTabTitle } from './model.ts'
|
|
23
|
+
import { openOrFocusSideChat } from './open.ts'
|
|
24
|
+
import { t } from '../locales.ts'
|
|
25
|
+
import { registerSideCommand } from './slash.ts'
|
|
26
|
+
|
|
27
|
+
export function registerSideChat(ctx: Context): void {
|
|
28
|
+
ctx.effect(
|
|
29
|
+
() => ctx.betterSidebar.registerTab({
|
|
30
|
+
id: SIDE_TAB_TYPE,
|
|
31
|
+
title: () => t('menuTitle'),
|
|
32
|
+
icon: (size: number) => <IconNewChatOutline16 size={size} />,
|
|
33
|
+
order: 60,
|
|
34
|
+
available: (availableCtx, scope) => canForkFrom(availableCtx, scope.sessionId),
|
|
35
|
+
createTab: (state) => ({
|
|
36
|
+
tab: { id: mintSideTabId(), type: SIDE_TAB_TYPE, title: sideTabTitle(collectSideTabs(state).map(tab => tab.title)) },
|
|
37
|
+
}),
|
|
38
|
+
component: (props) => <SideChatPanel {...props} />,
|
|
39
|
+
}),
|
|
40
|
+
'dsh-sidenote: side chat tab',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// annotate 桥:「在侧边聊天中提问」= 新建或聚焦一个侧边聊天 Tab 并写入草稿。
|
|
44
|
+
ctx.effect(() => {
|
|
45
|
+
const impl = {
|
|
46
|
+
askInSideChat: (sessionId: string, draftText: string): boolean =>
|
|
47
|
+
openOrFocusSideChat(ctx, sessionId, draftText),
|
|
48
|
+
}
|
|
49
|
+
sideChatBridge.current = impl
|
|
50
|
+
return () => {
|
|
51
|
+
if (sideChatBridge.current === impl) sideChatBridge.current = null
|
|
52
|
+
}
|
|
53
|
+
}, 'dsh-sidenote: annotate bridge')
|
|
54
|
+
|
|
55
|
+
registerSideCommand(ctx)
|
|
56
|
+
}
|