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,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧边聊天的纯逻辑层:Tab 编号标题、meta 序列化/解析容错、状态树遍历、
|
|
3
|
+
* 会话快照 → 消息流折叠、面板相位判定。全部无副作用,单测直接覆盖。
|
|
4
|
+
*
|
|
5
|
+
* 数据源事实(权威注释):
|
|
6
|
+
* - SidebarState 真实形态是 splits/bottomSplits 两棵 split/leaf 树
|
|
7
|
+
* (dsh-better-sidebar src/client/state.ts),leaf.tabs 持有 SidebarTab。
|
|
8
|
+
* - ConversationSnapshot.nodes 是 ConversationNode 联合(kind 判别),
|
|
9
|
+
* partial/runningCalls 承载在途流式输出(dsh-client-runtime
|
|
10
|
+
* lib/types/client/sessions/conversation.d.ts)。
|
|
11
|
+
*/
|
|
12
|
+
import type { ConversationSnapshot, Context, SidebarTab } from '../../context-types.ts'
|
|
13
|
+
import { t } from '../locales.ts'
|
|
14
|
+
|
|
15
|
+
/** Tab 类型 id(better-sidebar 注册表键;带包前缀防冲突)。 */
|
|
16
|
+
export const SIDE_TAB_TYPE = 'dsh-sidenote:side'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
// ── Tab id 与标题 ────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** 多实例 Tab id:`side:<uuid>`(terminal/browser 的 createTab 铸 id 先例)。 */
|
|
23
|
+
export function mintSideTabId(): string {
|
|
24
|
+
return `side:${crypto.randomUUID()}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 标题编号:首个「侧边」;并存时新 Tab「侧边 N」(N = 既有最大编号 + 1,
|
|
29
|
+
* 从标题解析)——关掉「侧边」再新建不会重名复用「侧边 2」。
|
|
30
|
+
* @param existingTitles - 创建时刻同会话侧边聊天 Tab 的标题集。
|
|
31
|
+
*/
|
|
32
|
+
export function sideTabTitle(existingTitles: readonly string[]): string {
|
|
33
|
+
const base = t('tabBaseTitle')
|
|
34
|
+
let max = 0
|
|
35
|
+
for (const title of existingTitles) {
|
|
36
|
+
// 双语兼容:语言切换后旧标题是另一种语言,两种都认(编号连续性不丢)。
|
|
37
|
+
const match = /^(?:Side|侧边)(?: (\d+))?$/.exec(title)
|
|
38
|
+
if (match !== null) max = Math.max(max, match[1] === undefined ? 1 : Number(match[1]))
|
|
39
|
+
}
|
|
40
|
+
return max <= 0 ? base : `${base} ${max + 1}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Tab meta(注册表首选寄存处;随布局持久化) ───────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** 侧边聊天寄存在 SidebarTab.meta 上的插件自有 JSON。 */
|
|
46
|
+
export interface SideChatMeta {
|
|
47
|
+
/** fork 出的子会话 id;缺省 = 尚未 fork(刚创建)。 */
|
|
48
|
+
childId?: string
|
|
49
|
+
/** fork 来源主会话 id(面板归属校验用;面板天然落在其 sidebar 状态里)。 */
|
|
50
|
+
parentSessionId?: string
|
|
51
|
+
/** 桥接(WI-03 划选提问)写入的待注入草稿;面板应用后清除。 */
|
|
52
|
+
pendingDraft?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 容错解析 tab.meta:布局持久化里的 meta 来自上一版本的自己,字段缺失或
|
|
57
|
+
* 类型漂移一律降级为缺省,绝不抛错(刷新恢复路径不许崩)。
|
|
58
|
+
*/
|
|
59
|
+
export function parseSideChatMeta(meta: unknown): SideChatMeta {
|
|
60
|
+
if (typeof meta !== 'object' || meta === null) return {}
|
|
61
|
+
const raw = meta as Record<string, unknown>
|
|
62
|
+
const out: SideChatMeta = {}
|
|
63
|
+
if (typeof raw.childId === 'string' && raw.childId !== '') out.childId = raw.childId
|
|
64
|
+
if (typeof raw.parentSessionId === 'string' && raw.parentSessionId !== '') out.parentSessionId = raw.parentSessionId
|
|
65
|
+
if (typeof raw.pendingDraft === 'string' && raw.pendingDraft !== '') out.pendingDraft = raw.pendingDraft
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 清除 meta.pendingDraft(其余字段原样保留)。 */
|
|
70
|
+
export function clearPendingDraft(meta: SideChatMeta): SideChatMeta {
|
|
71
|
+
const { pendingDraft: _dropped, ...rest } = meta
|
|
72
|
+
return rest
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 侧栏状态树遍历(容错:布局 JSON 漂移时不抛错) ───────────────────────────
|
|
76
|
+
|
|
77
|
+
function isTab(value: unknown): value is SidebarTab {
|
|
78
|
+
if (typeof value !== 'object' || value === null) return false
|
|
79
|
+
const tab = value as { id?: unknown; type?: unknown }
|
|
80
|
+
return typeof tab.id === 'string' && typeof tab.type === 'string'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function walkTree(node: unknown, into: SidebarTab[]): void {
|
|
84
|
+
if (typeof node !== 'object' || node === null) return
|
|
85
|
+
const n = node as { kind?: unknown; tabs?: unknown; children?: unknown }
|
|
86
|
+
if (n.kind === 'leaf') {
|
|
87
|
+
if (Array.isArray(n.tabs)) for (const tab of n.tabs) if (isTab(tab)) into.push(tab)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(n.children)) for (const child of n.children) walkTree(child, into)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 枚举侧栏状态(splits + bottomSplits 两棵树)里的全部 Tab。 */
|
|
94
|
+
export function collectTabs(state: unknown): SidebarTab[] {
|
|
95
|
+
if (typeof state !== 'object' || state === null) return []
|
|
96
|
+
const s = state as { splits?: unknown; bottomSplits?: unknown }
|
|
97
|
+
const into: SidebarTab[] = []
|
|
98
|
+
walkTree(s.splits, into)
|
|
99
|
+
walkTree(s.bottomSplits, into)
|
|
100
|
+
return into
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 枚举当前并存的侧边聊天 Tab(树顺序 = 打开顺序的近似)。 */
|
|
104
|
+
export function collectSideTabs(state: unknown): SidebarTab[] {
|
|
105
|
+
return collectTabs(state).filter(tab => tab.type === SIDE_TAB_TYPE)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 并存数(createTab 编号标题的输入)。 */
|
|
109
|
+
export function countSideTabs(state: unknown): number {
|
|
110
|
+
return collectSideTabs(state).length
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── fork 准入 ────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* blank 着陆页会话没有已完成 turn,fork 必败(host 返回 fork-unavailable)
|
|
117
|
+
* ——+ 菜单与 /side 命令的 available 用它禁用入口;面板内错误态兜底。
|
|
118
|
+
* 摘要求知(列表未就绪)时放行,交给 fork 错误态。
|
|
119
|
+
*/
|
|
120
|
+
export function canForkFrom(ctx: Context, sessionId: string): boolean {
|
|
121
|
+
try {
|
|
122
|
+
const summary = ctx.sessions.list.getSnapshot().byId?.[sessionId]
|
|
123
|
+
return summary?.blank !== true
|
|
124
|
+
} catch {
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 面板相位 ─────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 面板相位:
|
|
133
|
+
* - forking:无 childId,fork 编排进行中(或等待 effect 起跑);
|
|
134
|
+
* - fork-error:fork/归档失败(blank 会话无已完成 turn 等);
|
|
135
|
+
* - loading:有 childId,列表未就绪或 binding 尚未可解析;
|
|
136
|
+
* - missing:列表就绪后子会话不在列(会话已不存在);
|
|
137
|
+
* - chat:binding 解析成功,正常聊天。
|
|
138
|
+
*/
|
|
139
|
+
export type PanelPhase = 'forking' | 'fork-error' | 'loading' | 'missing' | 'chat'
|
|
140
|
+
|
|
141
|
+
export function phaseOf(input: {
|
|
142
|
+
childId: string | undefined
|
|
143
|
+
forkError: string | null
|
|
144
|
+
bound: boolean
|
|
145
|
+
listPhase: 'pending' | 'ready' | undefined
|
|
146
|
+
listed: boolean
|
|
147
|
+
}): PanelPhase {
|
|
148
|
+
if (input.childId === undefined) return input.forkError === null ? 'forking' : 'fork-error'
|
|
149
|
+
if (input.bound) return 'chat'
|
|
150
|
+
if (input.listPhase === 'ready' && !input.listed) return 'missing'
|
|
151
|
+
return 'loading'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── 草稿拼接 ─────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/** 注入草稿的拼接规则:空草稿直接落文本,否则换行追加(引文+问题的自然形态)。 */
|
|
157
|
+
export function appendDraftText(draft: string, text: string): string {
|
|
158
|
+
return draft.trim() === '' ? text : `${draft}\n${text}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 会话快照 → 消息流折叠 ────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/** 面板渲染用的消息视图(自绘;工具卡片等复杂节点降级为简洁块)。 */
|
|
164
|
+
export interface ChatMessage {
|
|
165
|
+
/** React key(节点 seq / 在途 callId 派生,稳定)。 */
|
|
166
|
+
key: string
|
|
167
|
+
role: 'user' | 'assistant' | 'tool' | 'notice' | 'error'
|
|
168
|
+
/** markdown 正文(assistant)或纯文本(其他)。 */
|
|
169
|
+
text: string
|
|
170
|
+
/** assistant 的思考内容(折叠渲染);缺省 = 无。 */
|
|
171
|
+
reasoning?: string
|
|
172
|
+
/** tool 角色的工具名。 */
|
|
173
|
+
toolName?: string
|
|
174
|
+
/** tool 角色的失败标记。 */
|
|
175
|
+
isError?: boolean
|
|
176
|
+
/** 流式中(partial / runningCalls)。 */
|
|
177
|
+
streaming?: boolean
|
|
178
|
+
/** 被打断冻结的 assistant 输出(渲染「已停止」标记)。 */
|
|
179
|
+
interrupted?: boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 工具结果正文截断上限(面板是窄栏,超长输出不撑爆 DOM)。 */
|
|
183
|
+
export const TOOL_TEXT_LIMIT = 4000
|
|
184
|
+
|
|
185
|
+
export function truncateText(text: string, max: number): string {
|
|
186
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface LooseBlock {
|
|
190
|
+
type?: unknown
|
|
191
|
+
kind?: unknown
|
|
192
|
+
text?: unknown
|
|
193
|
+
name?: unknown
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** ContentBlock[] → 纯文本:text 块拼接;image 块降级占位;其余忽略。 */
|
|
197
|
+
export function contentTextOf(content: unknown): string {
|
|
198
|
+
if (!Array.isArray(content)) return ''
|
|
199
|
+
const parts: string[] = []
|
|
200
|
+
for (const block of content) {
|
|
201
|
+
if (typeof block !== 'object' || block === null) continue
|
|
202
|
+
const b = block as LooseBlock
|
|
203
|
+
if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text)
|
|
204
|
+
else if (b.type === 'image') parts.push('[图片]')
|
|
205
|
+
}
|
|
206
|
+
return parts.join('\n')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function seqKey(prefix: string, node: Record<string, unknown>): string {
|
|
210
|
+
return `${prefix}:${typeof node.seq === 'number' ? node.seq : '?'}`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function assistantParts(blocks: unknown): { text: string; reasoning: string; hasToolCall: boolean } {
|
|
214
|
+
const texts: string[] = []
|
|
215
|
+
const reasonings: string[] = []
|
|
216
|
+
let hasToolCall = false
|
|
217
|
+
if (Array.isArray(blocks)) {
|
|
218
|
+
for (const block of blocks) {
|
|
219
|
+
if (typeof block !== 'object' || block === null) continue
|
|
220
|
+
const b = block as LooseBlock
|
|
221
|
+
if (b.kind === 'text' && typeof b.text === 'string') texts.push(b.text)
|
|
222
|
+
else if (b.kind === 'reasoning' && typeof b.text === 'string') reasonings.push(b.text)
|
|
223
|
+
else if (b.kind === 'tool-call') hasToolCall = true
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { text: texts.join('\n\n'), reasoning: reasonings.join('\n\n'), hasToolCall }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 单个 ConversationNode → ChatMessage;返回 null = 面板不渲染
|
|
231
|
+
* (context 注入、未知面事件、已取消的重试等对读者无信息的节点)。
|
|
232
|
+
*/
|
|
233
|
+
export function nodeToMessage(node: unknown): ChatMessage | null {
|
|
234
|
+
if (typeof node !== 'object' || node === null) return null
|
|
235
|
+
const n = node as Record<string, unknown>
|
|
236
|
+
switch (n.kind) {
|
|
237
|
+
case 'user':
|
|
238
|
+
return { key: seqKey('u', n), role: 'user', text: contentTextOf(n.content) }
|
|
239
|
+
case 'steering':
|
|
240
|
+
return { key: seqKey('s', n), role: 'user', text: contentTextOf(n.content) }
|
|
241
|
+
case 'assistant': {
|
|
242
|
+
const { text, reasoning, hasToolCall } = assistantParts(n.blocks)
|
|
243
|
+
// 纯工具调用头的 assistant 节点不渲染(tool-result 节点承载工具卡片)。
|
|
244
|
+
if (text === '' && reasoning === '' && hasToolCall) return null
|
|
245
|
+
return {
|
|
246
|
+
key: seqKey('a', n),
|
|
247
|
+
role: 'assistant',
|
|
248
|
+
text,
|
|
249
|
+
...(reasoning !== '' ? { reasoning } : {}),
|
|
250
|
+
...(n.interrupted === true ? { interrupted: true } : {}),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
case 'tool-result': {
|
|
254
|
+
const call = n.call as { name?: unknown } | null
|
|
255
|
+
const toolName = typeof call?.name === 'string'
|
|
256
|
+
? call.name
|
|
257
|
+
: typeof n.callId === 'string' ? n.callId : '工具'
|
|
258
|
+
return {
|
|
259
|
+
key: seqKey('t', n),
|
|
260
|
+
role: 'tool',
|
|
261
|
+
toolName,
|
|
262
|
+
text: truncateText(contentTextOf(n.content), TOOL_TEXT_LIMIT),
|
|
263
|
+
...(n.isError === true ? { isError: true } : {}),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
case 'turn-error':
|
|
267
|
+
return { key: seqKey('e', n), role: 'error', text: typeof n.message === 'string' ? n.message : '未知错误' }
|
|
268
|
+
case 'model-retry': {
|
|
269
|
+
if (n.retryState === 'cancelled') return null
|
|
270
|
+
return {
|
|
271
|
+
key: seqKey('r', n),
|
|
272
|
+
role: 'notice',
|
|
273
|
+
text: n.retryState === 'started' ? '模型请求失败,正在自动重试…' : '模型请求失败,等待自动重试…',
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
case 'turn-max-tokens':
|
|
277
|
+
return { key: seqKey('m', n), role: 'notice', text: '输出达到长度上限,本轮回复已截断。' }
|
|
278
|
+
case 'command': {
|
|
279
|
+
const name = typeof n.name === 'string' && n.name !== '' ? `/${n.name}` : '/命令'
|
|
280
|
+
const args = typeof n.args === 'string' ? n.args : ''
|
|
281
|
+
return { key: seqKey('c', n), role: 'notice', text: `执行命令 ${name}${args}` }
|
|
282
|
+
}
|
|
283
|
+
case 'compaction':
|
|
284
|
+
return { key: seqKey('k', n), role: 'notice', text: '已压缩更早的对话上下文。' }
|
|
285
|
+
default:
|
|
286
|
+
// context(注入)/ unknown(未识面事件):MVP 不渲染。
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* ConversationSnapshot → 渲染消息列表:终态节点 + 在途工具调用 + 流式部分。
|
|
293
|
+
* 快照缺省(未绑定)时为空列表。
|
|
294
|
+
*/
|
|
295
|
+
export function transcriptOf(snapshot: ConversationSnapshot | undefined | null): ChatMessage[] {
|
|
296
|
+
if (snapshot === undefined || snapshot === null) return []
|
|
297
|
+
const out: ChatMessage[] = []
|
|
298
|
+
for (const node of snapshot.nodes ?? []) {
|
|
299
|
+
const message = nodeToMessage(node)
|
|
300
|
+
if (message !== null) out.push(message)
|
|
301
|
+
}
|
|
302
|
+
// 在途工具调用(tool/call 已见、tool/result 未至)。
|
|
303
|
+
if (Array.isArray(snapshot.runningCalls)) {
|
|
304
|
+
for (const call of snapshot.runningCalls) {
|
|
305
|
+
if (typeof call !== 'object' || call === null) continue
|
|
306
|
+
const c = call as { callId?: unknown; name?: unknown }
|
|
307
|
+
out.push({
|
|
308
|
+
key: `rc:${typeof c.callId === 'string' ? c.callId : '?'}`,
|
|
309
|
+
role: 'tool',
|
|
310
|
+
toolName: typeof c.name === 'string' ? c.name : '工具',
|
|
311
|
+
text: '',
|
|
312
|
+
streaming: true,
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// 流式中的 assistant 部分输出。
|
|
317
|
+
const partial = snapshot.partial as { blocks?: unknown } | null | undefined
|
|
318
|
+
if (partial !== undefined && partial !== null) {
|
|
319
|
+
const { text, reasoning, hasToolCall } = assistantParts(partial.blocks)
|
|
320
|
+
if (text !== '' || reasoning !== '' || !hasToolCall) {
|
|
321
|
+
out.push({
|
|
322
|
+
key: 'partial',
|
|
323
|
+
role: 'assistant',
|
|
324
|
+
text,
|
|
325
|
+
...(reasoning !== '' ? { reasoning } : {}),
|
|
326
|
+
streaming: true,
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out
|
|
331
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「新建或聚焦一个侧边聊天 Tab」的共享编排:桥接(WI-03 划选提问
|
|
3
|
+
* askInSideChat)与 /side 斜杠命令共用。
|
|
4
|
+
*
|
|
5
|
+
* 聚焦策略:同一主会话并存多个时聚焦最后一个(最近打开的)。
|
|
6
|
+
* 草稿注入:目标 Tab 已 fork 且 input 机器可达 → 直接写机器草稿;
|
|
7
|
+
* 否则写 meta.pendingDraft 移交面板(面板在 composer 就绪后应用并清除)。
|
|
8
|
+
*/
|
|
9
|
+
import type { Context, SidebarTab } from '../../context-types.ts'
|
|
10
|
+
import {
|
|
11
|
+
SIDE_TAB_TYPE,
|
|
12
|
+
appendDraftText,
|
|
13
|
+
collectSideTabs,
|
|
14
|
+
collectTabs,
|
|
15
|
+
parseSideChatMeta,
|
|
16
|
+
} from './model.ts'
|
|
17
|
+
import { readInputDraft, resolveSessionInput } from './composer.ts'
|
|
18
|
+
|
|
19
|
+
/** 从最新快照读一个 Tab(meta 合并写入前的读取面;布局即注册表)。 */
|
|
20
|
+
export function readTab(ctx: Context, tabId: string): SidebarTab | undefined {
|
|
21
|
+
return collectTabs(ctx.betterSidebar.getSnapshot().state).find(tab => tab.id === tabId)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 为 sessionId 新建或聚焦一个侧边聊天 Tab;draftText 给出时写入其草稿。
|
|
26
|
+
* @returns false = 未能打开(调用方保持自己的流程)。
|
|
27
|
+
*/
|
|
28
|
+
export function openOrFocusSideChat(ctx: Context, sessionId: string, draftText?: string): boolean {
|
|
29
|
+
try {
|
|
30
|
+
const snapshot = ctx.betterSidebar.getSnapshot()
|
|
31
|
+
// 侧边聊天活在主会话自己的侧栏状态里;目标会话不在屏上时不越权开 Tab。
|
|
32
|
+
if (snapshot.sessionId !== sessionId || snapshot.state === undefined) return false
|
|
33
|
+
const existing = collectSideTabs(snapshot.state)
|
|
34
|
+
|
|
35
|
+
if (existing.length > 0) {
|
|
36
|
+
const target = existing[existing.length - 1]!
|
|
37
|
+
if (draftText !== undefined && draftText !== '') {
|
|
38
|
+
const meta = parseSideChatMeta(target.meta)
|
|
39
|
+
const input = meta.childId === undefined ? null : resolveSessionInput(ctx, meta.childId)
|
|
40
|
+
if (input !== null) {
|
|
41
|
+
input.setDraft(appendDraftText(readInputDraft(input), draftText))
|
|
42
|
+
} else {
|
|
43
|
+
ctx.betterSidebar.updateTab(target.id, {
|
|
44
|
+
meta: { ...meta, pendingDraft: appendDraftText(meta.pendingDraft ?? '', draftText) },
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
ctx.betterSidebar.activateTab(target.id, { sessionId })
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 新建:openTab 走 createTab 铸造(seed.meta 会被忽略),所以先记下既有
|
|
53
|
+
// id 集,openTab 同步落状态后找出新 Tab,再把 pendingDraft 写进它的 meta。
|
|
54
|
+
const before = new Set(collectTabs(snapshot.state).map(tab => tab.id))
|
|
55
|
+
ctx.betterSidebar.openTab({ type: SIDE_TAB_TYPE }, { sessionId })
|
|
56
|
+
const created = collectSideTabs(ctx.betterSidebar.getSnapshot().state).find(tab => !before.has(tab.id))
|
|
57
|
+
if (created === undefined) return false
|
|
58
|
+
if (draftText !== undefined && draftText !== '') {
|
|
59
|
+
ctx.betterSidebar.updateTab(created.id, { meta: { pendingDraft: draftText } })
|
|
60
|
+
}
|
|
61
|
+
return true
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.warn('[dsh-sidenote] 打开侧边聊天失败:', error)
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 侧边聊天面板样式。颜色全部消费 DSH 设计平台的 --dsw-alias-* 令牌
|
|
3
|
+
* (声明在 <body> 与 body[data-ds-dark-theme] 上),跟随皮肤系统,
|
|
4
|
+
* 不做任何每皮肤适配、不写死颜色。
|
|
5
|
+
*/
|
|
6
|
+
.root {
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
height: 100%;
|
|
10
|
+
color: var(--dsw-alias-label-primary);
|
|
11
|
+
font-size: 13px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* 消息流(可滚动区)。 */
|
|
15
|
+
.body {
|
|
16
|
+
flex: 1;
|
|
17
|
+
overflow-y: auto;
|
|
18
|
+
padding: 12px 10px;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.transcript {
|
|
24
|
+
display: flex;
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
gap: 12px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ── 空状态 / 整屏状态 ── */
|
|
30
|
+
.empty,
|
|
31
|
+
.stateScreen {
|
|
32
|
+
flex: 1;
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: center;
|
|
37
|
+
gap: 8px;
|
|
38
|
+
text-align: center;
|
|
39
|
+
padding: 24px 16px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.emptyIcon {
|
|
43
|
+
color: var(--dsw-alias-label-tertiary);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.emptyTitle {
|
|
47
|
+
font-size: 14px;
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
color: var(--dsw-alias-label-primary);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.emptyText {
|
|
53
|
+
font-size: 12px;
|
|
54
|
+
line-height: 1.6;
|
|
55
|
+
color: var(--dsw-alias-label-tertiary);
|
|
56
|
+
max-width: 240px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.stateDetail {
|
|
60
|
+
font-size: 12px;
|
|
61
|
+
line-height: 1.6;
|
|
62
|
+
color: var(--dsw-alias-state-error-primary);
|
|
63
|
+
max-width: 260px;
|
|
64
|
+
word-break: break-word;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ── 消息行 ── */
|
|
68
|
+
.userRow {
|
|
69
|
+
display: flex;
|
|
70
|
+
justify-content: flex-end;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.userBubble {
|
|
74
|
+
max-width: 88%;
|
|
75
|
+
background: var(--dsw-alias-button-info-fill);
|
|
76
|
+
color: #fff;
|
|
77
|
+
border-radius: 12px 12px 2px 12px;
|
|
78
|
+
padding: 8px 12px;
|
|
79
|
+
white-space: pre-wrap;
|
|
80
|
+
word-break: break-word;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.assistantRow {
|
|
84
|
+
display: flex;
|
|
85
|
+
justify-content: flex-start;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.assistantBody {
|
|
89
|
+
max-width: 100%;
|
|
90
|
+
font-size: 13px;
|
|
91
|
+
line-height: 1.6;
|
|
92
|
+
word-break: break-word;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.reasoning {
|
|
96
|
+
margin-bottom: 6px;
|
|
97
|
+
border-left: 3px solid var(--dsw-alias-border-l2);
|
|
98
|
+
padding-left: 8px;
|
|
99
|
+
color: var(--dsw-alias-label-tertiary);
|
|
100
|
+
font-size: 12px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.reasoning > summary {
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
user-select: none;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.reasoningBody {
|
|
109
|
+
margin-top: 4px;
|
|
110
|
+
white-space: pre-wrap;
|
|
111
|
+
word-break: break-word;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.streamingHint {
|
|
115
|
+
color: var(--dsw-alias-label-dimmed);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* 工具卡片(简洁自绘块:头部 = 工具名 + 状态徽标,正文 = 截断文本)。 */
|
|
119
|
+
.toolCard {
|
|
120
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
121
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
122
|
+
border-radius: 8px;
|
|
123
|
+
overflow: hidden;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.toolHead {
|
|
127
|
+
display: flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
gap: 6px;
|
|
130
|
+
padding: 5px 10px;
|
|
131
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
132
|
+
font-size: 12px;
|
|
133
|
+
color: var(--dsw-alias-label-secondary);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.toolError {
|
|
137
|
+
color: var(--dsw-alias-state-error-primary);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.toolRunning {
|
|
141
|
+
color: var(--dsw-alias-label-tertiary);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.toolBody {
|
|
145
|
+
padding: 8px 10px;
|
|
146
|
+
font-size: 12px;
|
|
147
|
+
color: var(--dsw-alias-label-tertiary);
|
|
148
|
+
white-space: pre-wrap;
|
|
149
|
+
word-break: break-word;
|
|
150
|
+
max-height: 160px;
|
|
151
|
+
overflow-y: auto;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.errorRow {
|
|
155
|
+
color: var(--dsw-alias-state-error-primary);
|
|
156
|
+
font-size: 12px;
|
|
157
|
+
padding: 4px 2px;
|
|
158
|
+
white-space: pre-wrap;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.noticeRow {
|
|
162
|
+
color: var(--dsw-alias-label-dimmed);
|
|
163
|
+
font-size: 12px;
|
|
164
|
+
text-align: center;
|
|
165
|
+
padding: 2px 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ── composer ── */
|
|
169
|
+
.composer {
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-direction: column;
|
|
172
|
+
gap: 6px;
|
|
173
|
+
padding: 8px 10px;
|
|
174
|
+
border-top: 1px solid var(--dsw-alias-border-l2);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.input {
|
|
178
|
+
width: 100%;
|
|
179
|
+
min-height: 40px;
|
|
180
|
+
max-height: 160px;
|
|
181
|
+
resize: vertical;
|
|
182
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
183
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
184
|
+
border-radius: 8px;
|
|
185
|
+
color: var(--dsw-alias-label-primary);
|
|
186
|
+
padding: 8px 10px;
|
|
187
|
+
font: inherit;
|
|
188
|
+
box-sizing: border-box;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.input:focus {
|
|
192
|
+
outline: none;
|
|
193
|
+
border-color: var(--dsw-alias-state-business-primary);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.composerFoot {
|
|
197
|
+
display: flex;
|
|
198
|
+
align-items: center;
|
|
199
|
+
justify-content: space-between;
|
|
200
|
+
gap: 8px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.modelLabel {
|
|
204
|
+
font-size: 12px;
|
|
205
|
+
color: var(--dsw-alias-label-dimmed);
|
|
206
|
+
overflow: hidden;
|
|
207
|
+
text-overflow: ellipsis;
|
|
208
|
+
white-space: nowrap;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.sendButton,
|
|
212
|
+
.stopButton {
|
|
213
|
+
appearance: none;
|
|
214
|
+
display: inline-flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 4px;
|
|
217
|
+
border: 1px solid var(--dsw-alias-state-business-primary);
|
|
218
|
+
background: var(--dsw-alias-button-info-fill);
|
|
219
|
+
color: #fff;
|
|
220
|
+
font-size: 13px;
|
|
221
|
+
padding: 6px 12px;
|
|
222
|
+
border-radius: 8px;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
flex: none;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.sendButton:disabled {
|
|
228
|
+
opacity: 0.55;
|
|
229
|
+
cursor: default;
|
|
230
|
+
}
|