@heixui/dsh-session-suite 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +381 -2
- package/README.zh.md +178 -0
- package/THIRD-PARTY.md +64 -0
- package/client.js +2974 -0
- package/cordis.patch.yml +77 -0
- package/icon.svg +19 -0
- package/index.js +211 -0
- package/lib/api.js +387 -0
- package/lib/checkpoints.js +774 -0
- package/lib/data/jieba-dict.txt +109750 -0
- package/lib/factkeys.js +212 -0
- package/lib/markdown.js +252 -0
- package/lib/memory-engine.js +1323 -0
- package/lib/memory-host.js +683 -0
- package/lib/memory.js +224 -0
- package/lib/retrieval.js +217 -0
- package/lib/service.js +1273 -0
- package/lib/share.js +641 -0
- package/lib/step-tag-filter.js +199 -0
- package/lib/tags.js +140 -0
- package/lib/util.js +261 -0
- package/lib/wordlist.js +183 -0
- package/locale/en.json +87 -0
- package/locale/zh.json +87 -0
- package/package.json +76 -4
package/client.js
ADDED
|
@@ -0,0 +1,2974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-suite — Client half.
|
|
3
|
+
*
|
|
4
|
+
* Adds, on top of the shipped Web UI:
|
|
5
|
+
*
|
|
6
|
+
* sidebar.workspaces.session.menu.item share rows + a destructive delete row
|
|
7
|
+
* conversation.chat.assistant-actions rewind / delete-message actions
|
|
8
|
+
* conversation.input.dock the inline "rewind here?" confirm bar
|
|
9
|
+
* shell.overlay the delete-session dialog and toasts
|
|
10
|
+
* settings.section the snapshot / memory manager page
|
|
11
|
+
*
|
|
12
|
+
* Only `react` is required: Harness Client packages are not importable from a
|
|
13
|
+
* plugin, so the controls below are hand-written against the host's theme
|
|
14
|
+
* tokens and interaction patterns.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: 'dsh-session-suite',
|
|
19
|
+
factory(require) {
|
|
20
|
+
const React = require('react')
|
|
21
|
+
const h = React.createElement
|
|
22
|
+
const { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } = React
|
|
23
|
+
|
|
24
|
+
const NS = 'session-suite'
|
|
25
|
+
const ROUTE = 'api/session-suite'
|
|
26
|
+
const API_TIMEOUT_MS = 20000
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------- locale
|
|
29
|
+
|
|
30
|
+
const DICTS = {
|
|
31
|
+
en: {
|
|
32
|
+
'menu.delete': 'Delete session',
|
|
33
|
+
'menu.shareMarkdown': 'Share as Markdown',
|
|
34
|
+
'menu.shareText': 'Copy conversation as text',
|
|
35
|
+
'menu.shareJson': 'Share as JSON',
|
|
36
|
+
'menu.snapshots': 'Session snapshots',
|
|
37
|
+
'menu.share': 'Share session',
|
|
38
|
+
'menu.segment': 'Delete part of the conversation',
|
|
39
|
+
'share.title': 'Share this conversation',
|
|
40
|
+
'share.hint': 'Choose the turns to include.',
|
|
41
|
+
'share.all': 'Select all',
|
|
42
|
+
'share.none': 'Clear',
|
|
43
|
+
'share.selected': '{count} of {total} turns selected',
|
|
44
|
+
'share.image': 'Image',
|
|
45
|
+
'share.html': 'Web page',
|
|
46
|
+
'share.markdown': 'Markdown',
|
|
47
|
+
'share.json': 'JSON',
|
|
48
|
+
'share.copy': 'Copy text',
|
|
49
|
+
'share.metaTurns': '{turns} turns · {messages} messages',
|
|
50
|
+
'share.exportedAt': 'exported {time}',
|
|
51
|
+
'share.imageFooter': 'DeepSeek Harness · dsh-session-suite',
|
|
52
|
+
'share.rendering': 'Rendering…',
|
|
53
|
+
'share.needSelection': 'Select at least one turn',
|
|
54
|
+
'share.done': 'Saved',
|
|
55
|
+
'share.copied': 'Copied to the clipboard',
|
|
56
|
+
'share.linkNote': 'A public link would need a server hosting the conversation. The web page file is portable: anyone can open it, and it prints to PDF.',
|
|
57
|
+
'share.curate': 'Final answers only',
|
|
58
|
+
'share.curateHint': 'Keeps your prompt and the closing reply of each turn, and drops the narration between tool calls.',
|
|
59
|
+
'share.failed': 'Could not export',
|
|
60
|
+
'segment.title': 'Delete part of the conversation',
|
|
61
|
+
'segment.hint': 'Everything from the first selected turn onwards leaves the conversation. Workspace files are never touched; memory recorded in that range is removed.',
|
|
62
|
+
'segment.warning': 'Turns after your selection are removed too: a session log can only be cut, not spliced.',
|
|
63
|
+
'segment.confirm': 'Delete',
|
|
64
|
+
'segment.done': 'Conversation trimmed',
|
|
65
|
+
'segment.failed': 'Could not delete that part',
|
|
66
|
+
'segment.needSelection': 'Select at least one turn',
|
|
67
|
+
'segment.whole': 'This removes every turn in the session; the session and its files stay.',
|
|
68
|
+
'menu.shareCopied': 'Conversation copied to the clipboard',
|
|
69
|
+
'menu.shareFailed': 'Could not export this session',
|
|
70
|
+
'menu.shareEmpty': 'This session has no messages to share yet',
|
|
71
|
+
'message.rewind': 'Rewind to this step',
|
|
72
|
+
'message.rewindTitle': 'Rewind to this step?',
|
|
73
|
+
'message.rewindBody':
|
|
74
|
+
'Restores the workspace files and session memory saved for this step, forks the conversation up to this message, and archives the current session. An undo snapshot is kept for 10 minutes.',
|
|
75
|
+
'message.rewindConfirm': 'Rewind',
|
|
76
|
+
'message.rewinding': 'Rewinding…',
|
|
77
|
+
'message.rewindDone': 'Rewound to this step',
|
|
78
|
+
'message.rewindFailed': 'Could not rewind this session',
|
|
79
|
+
'message.resend': 'Resend this turn',
|
|
80
|
+
'message.resendTitle': 'Resend this turn?',
|
|
81
|
+
'message.resendBody':
|
|
82
|
+
'The question will be asked again in a new branch of this conversation, so the model answers from scratch instead of building on the reply you are rejecting. The current conversation stays as a branch, and workspace files are not touched.',
|
|
83
|
+
'message.resendConfirm': 'Resend',
|
|
84
|
+
'message.resending': 'Resending…',
|
|
85
|
+
'message.resendDone': 'Resent as a new turn',
|
|
86
|
+
'message.resendStaged': 'Prompt staged for editing',
|
|
87
|
+
'message.resendFailed': 'Could not resend this turn',
|
|
88
|
+
'message.edit': 'Edit and resend',
|
|
89
|
+
'message.editTitle': 'Edit and resend',
|
|
90
|
+
'message.editBody':
|
|
91
|
+
'Change what you asked; it is sent as a new turn in this same conversation. Memories recorded after that turn are cleared, and workspace files are not touched. Note: the DSH log is append-only, so the original question and its reply stay in the history above rather than being deleted.',
|
|
92
|
+
'message.editConfirm': 'Resend edited',
|
|
93
|
+
'message.undo': 'Undo this rewind',
|
|
94
|
+
'message.undoDone': 'Rewind undone',
|
|
95
|
+
'message.delete': 'Delete this turn',
|
|
96
|
+
'message.deleteTitle': 'Delete this turn?',
|
|
97
|
+
'message.deleteBody':
|
|
98
|
+
'The prompt, the reply, and the steps in between all leave the conversation. Only the log and session memory are trimmed — workspace files stay exactly as they are.',
|
|
99
|
+
'message.deleteConfirm': 'Delete',
|
|
100
|
+
'message.deleteDone': 'Deleted this turn',
|
|
101
|
+
'message.deleteFailed': 'Could not delete this message',
|
|
102
|
+
'message.deleteOnly': 'This is the only message in the session. Deleting it also deletes the session.',
|
|
103
|
+
'dialog.cancel': 'Cancel',
|
|
104
|
+
'dialog.delete': 'Delete',
|
|
105
|
+
'dialog.deleteTitle': 'Delete session',
|
|
106
|
+
'dialog.deleteBody':
|
|
107
|
+
'The session and everything inside it will be removed from local storage, including its conversation log, snapshots, and memory. This cannot be undone.',
|
|
108
|
+
'dialog.deleting': 'Deleting…',
|
|
109
|
+
'dialog.deleteDone': 'Session deleted',
|
|
110
|
+
'dialog.deleteStale': 'Deleted — press Ctrl+R if the row is still listed',
|
|
111
|
+
'dialog.deleteFailed': 'Could not delete the session',
|
|
112
|
+
'dialog.deleteSubagents': 'This will also delete {n} subagent session(s) started by it.',
|
|
113
|
+
'dialog.deleteWouldRefuse': 'Something here is still running. Stop it first — nothing has been deleted.',
|
|
114
|
+
'dialog.sessionRunning': 'This session is running. Stop it before deleting.',
|
|
115
|
+
'settings.title': 'Session snapshots',
|
|
116
|
+
'settings.description': 'Workspace checkpoints captured for a session, with disk usage, locking, and restore controls.',
|
|
117
|
+
'settings.empty': 'No snapshots yet. A checkpoint is captured at the start of each turn.',
|
|
118
|
+
'settings.capture': 'Capture now',
|
|
119
|
+
'settings.capturing': 'Capturing…',
|
|
120
|
+
'settings.restore': 'Restore files',
|
|
121
|
+
'settings.restoreConfirm': 'Restore the files recorded in this snapshot? The conversation is not changed.',
|
|
122
|
+
'settings.restoreDone': 'Workspace restored',
|
|
123
|
+
'settings.restoreFailed': 'Could not restore this snapshot',
|
|
124
|
+
'settings.remove': 'Delete snapshot',
|
|
125
|
+
'settings.lock': 'Lock',
|
|
126
|
+
'settings.unlock': 'Unlock',
|
|
127
|
+
'settings.locked': 'Locked',
|
|
128
|
+
'settings.budget': 'Keeps the newest {keep} automatic snapshots',
|
|
129
|
+
'settings.usage': 'Snapshots use {size}',
|
|
130
|
+
'settings.files': '{count} files',
|
|
131
|
+
'settings.turn': 'Turn {turn}',
|
|
132
|
+
'settings.temporary': 'Temporary',
|
|
133
|
+
'settings.undoBundles': 'Undo bundles',
|
|
134
|
+
'settings.undoBundle': 'Rollback at turn {turn}',
|
|
135
|
+
'settings.undoRestore': 'Undo rollback',
|
|
136
|
+
'settings.undoHint': 'Available for 10 minutes after a rollback.',
|
|
137
|
+
'settings.memory': 'Session memory',
|
|
138
|
+
'settings.memoryEmpty': 'Nothing remembered in this session yet.',
|
|
139
|
+
'settings.memoryAdd': 'Add memory',
|
|
140
|
+
'settings.memoryPlaceholder': 'A durable fact, rule, or preference',
|
|
141
|
+
'settings.memorySave': 'Save',
|
|
142
|
+
'settings.memoryRemove': 'Remove',
|
|
143
|
+
'settings.loading': 'Loading snapshots…',
|
|
144
|
+
'settings.session': 'Session',
|
|
145
|
+
'settings.noSessions': 'No sessions are available yet.',
|
|
146
|
+
'toast.captured': 'Snapshot captured',
|
|
147
|
+
'toast.removed': 'Snapshot deleted',
|
|
148
|
+
'toast.locked': 'Snapshot locked',
|
|
149
|
+
'toast.unlocked': 'Snapshot unlocked',
|
|
150
|
+
'toast.memoryAdded': 'Memory saved',
|
|
151
|
+
'toast.memoryRemoved': 'Memory removed',
|
|
152
|
+
'toast.failed': 'Something went wrong',
|
|
153
|
+
'settings.details': 'Details',
|
|
154
|
+
'memory.title': 'Long-term memory',
|
|
155
|
+
'memory.description': 'Durable facts and conventions shared across sessions, kept in two stores: this workspace, and global.',
|
|
156
|
+
'memory.tab.all': 'All',
|
|
157
|
+
'memory.tab.workspace': 'This workspace',
|
|
158
|
+
'memory.tab.global': 'Global',
|
|
159
|
+
'memory.tab.archived': 'Cold archive',
|
|
160
|
+
'memory.global': 'Global',
|
|
161
|
+
'memory.workspace': 'Workspace',
|
|
162
|
+
'memory.workspacePicker': 'Workspace',
|
|
163
|
+
'memory.workspaceNone': 'No workspace detected',
|
|
164
|
+
'memory.rule': 'Rule',
|
|
165
|
+
'memory.summary': 'Summary',
|
|
166
|
+
'memory.frozen': 'Frozen',
|
|
167
|
+
'memory.archived': 'Archived',
|
|
168
|
+
'memory.score': 'score {score}',
|
|
169
|
+
'memory.access': '{count} recalls',
|
|
170
|
+
'memory.empty': 'Nothing remembered yet. Say remember: … in brackets, or add one below.',
|
|
171
|
+
'memory.explainTitle': 'What would this question recall?',
|
|
172
|
+
'memory.explainHint':
|
|
173
|
+
'Type a question to see which memories it pulls in, how many the evidence justifies, and why each of the rest was left out. Nothing is recorded — this is a dry run.',
|
|
174
|
+
'memory.explainPlaceholder': 'a question, e.g. why did the cache hit rate drop',
|
|
175
|
+
'memory.explainRun': 'Test',
|
|
176
|
+
'memory.explainInjected': 'Injecting {n} of a possible {limit}',
|
|
177
|
+
'memory.explainNone': 'Nothing matched, so nothing would be injected. That is the intended answer, not a failure.',
|
|
178
|
+
'memory.explainOmitted': '{n} left out',
|
|
179
|
+
'memory.whyNoMatch': 'no match',
|
|
180
|
+
'memory.whyCapacity': 'over budget',
|
|
181
|
+
'memory.filterAllTags': 'All tags',
|
|
182
|
+
'memory.filterAnyTime': 'Any time',
|
|
183
|
+
'memory.filterToday': 'Last 24h',
|
|
184
|
+
'memory.filterWeek': 'Last 7 days',
|
|
185
|
+
'memory.filterMonth': 'Last 30 days',
|
|
186
|
+
'memory.filterClear': 'Clear filters',
|
|
187
|
+
'memory.filterCount': '{n} in library',
|
|
188
|
+
'memory.placeholder': 'A durable fact, rule, or preference',
|
|
189
|
+
'memory.save': 'Save',
|
|
190
|
+
'memory.added': 'Memory saved',
|
|
191
|
+
'memory.removed': 'Memory deleted',
|
|
192
|
+
'memory.freeze': 'Freeze',
|
|
193
|
+
'memory.unfreeze': 'Unfreeze',
|
|
194
|
+
'memory.remove': 'Delete',
|
|
195
|
+
'memory.pendingTitle': 'Suggested memories',
|
|
196
|
+
'memory.pendingHint': 'Extracted from the last turns. Nothing is stored until you accept it.',
|
|
197
|
+
'memory.accept': 'Keep',
|
|
198
|
+
'memory.reject': 'Discard',
|
|
199
|
+
'memory.accepted': 'Kept',
|
|
200
|
+
'memory.rejected': 'Discarded',
|
|
201
|
+
'memory.maintain': 'Re-score and compress now',
|
|
202
|
+
'memory.maintained': 'Memory rescored',
|
|
203
|
+
'memory.maintainedAt': 'Last maintained',
|
|
204
|
+
'memory.maintainedNever': 'Not maintained yet — runs automatically once memories change',
|
|
205
|
+
'memory.maintainedScored': 'scored',
|
|
206
|
+
'memory.maintainedCompressed': 'compressed',
|
|
207
|
+
'memory.maintainedRules': 'rules',
|
|
208
|
+
'memory.implicit': 'Suggest memories automatically',
|
|
209
|
+
'memory.clearCold': 'Clear cold archive',
|
|
210
|
+
'memory.coldCleared': 'Cold archive cleared',
|
|
211
|
+
},
|
|
212
|
+
zh: {
|
|
213
|
+
'menu.delete': '删除任务',
|
|
214
|
+
'menu.shareMarkdown': '分享为 Markdown',
|
|
215
|
+
'menu.shareText': '复制对话文本',
|
|
216
|
+
'menu.shareJson': '分享为 JSON',
|
|
217
|
+
'menu.snapshots': '会话快照',
|
|
218
|
+
'menu.share': '分享会话',
|
|
219
|
+
'menu.segment': '删除对话片段',
|
|
220
|
+
'share.title': '分享这段对话',
|
|
221
|
+
'share.hint': '选择要包含的对话轮次。',
|
|
222
|
+
'share.all': '全选',
|
|
223
|
+
'share.none': '清空',
|
|
224
|
+
'share.selected': '已选 {count} / {total} 轮',
|
|
225
|
+
'share.image': '生成图片',
|
|
226
|
+
'share.html': '网页文件',
|
|
227
|
+
'share.markdown': 'Markdown',
|
|
228
|
+
'share.json': 'JSON',
|
|
229
|
+
'share.copy': '复制文本',
|
|
230
|
+
'share.metaTurns': '{turns} 轮 · {messages} 条消息',
|
|
231
|
+
'share.exportedAt': '导出于 {time}',
|
|
232
|
+
'share.imageFooter': 'DeepSeek Harness · dsh-session-suite',
|
|
233
|
+
'share.rendering': '正在生成…',
|
|
234
|
+
'share.needSelection': '请至少选择一轮对话',
|
|
235
|
+
'share.done': '已保存',
|
|
236
|
+
'share.copied': '已复制到剪贴板',
|
|
237
|
+
'share.linkNote': '在线链接需要一台服务器来托管内容;这里生成的是可离线打开的网页文件,发给谁都能打开,也能直接打印成 PDF。',
|
|
238
|
+
'share.curate': '只导出最终回答',
|
|
239
|
+
'share.curateHint': '每轮只保留你的提问和最终答复,丢掉工具调用之间的过程性说明。',
|
|
240
|
+
'share.failed': '导出失败',
|
|
241
|
+
'segment.title': '删除对话片段',
|
|
242
|
+
'segment.hint': '从选中的第一轮开始,之后的内容都会从会话里移除。工作区文件不会改动,这一段里记录的记忆会一并清理。',
|
|
243
|
+
'segment.warning': '选中轮次之后的内容也会一起移除:会话日志只能从中间截断,无法挖空再拼接。',
|
|
244
|
+
'segment.confirm': '删除',
|
|
245
|
+
'segment.done': '对话已裁剪',
|
|
246
|
+
'segment.failed': '删除失败',
|
|
247
|
+
'segment.needSelection': '请至少选择一轮对话',
|
|
248
|
+
'segment.whole': '这会清空会话里全部对话,但会话本身和工作区文件都保留。',
|
|
249
|
+
'menu.shareCopied': '对话已复制到剪贴板',
|
|
250
|
+
'menu.shareFailed': '会话导出失败',
|
|
251
|
+
'menu.shareEmpty': '这个会话还没有可分享的消息',
|
|
252
|
+
'message.rewind': '回滚到此步骤',
|
|
253
|
+
'message.rewindTitle': '确定要回滚到此步骤并重新开始吗?',
|
|
254
|
+
'message.rewindBody':
|
|
255
|
+
'会把工作区文件和会话记忆恢复到这一步,并把对话分叉到这条消息,同时归档当前会话。撤销快照保留 10 分钟。',
|
|
256
|
+
'message.rewindConfirm': '确认回滚',
|
|
257
|
+
'message.rewinding': '正在回滚…',
|
|
258
|
+
'message.rewindDone': '已回滚到此步骤',
|
|
259
|
+
'message.rewindFailed': '回滚失败',
|
|
260
|
+
'message.resend': '重发这一轮',
|
|
261
|
+
'message.resendTitle': '确定要重发这一轮吗?',
|
|
262
|
+
'message.resendBody':
|
|
263
|
+
'会在当前对话的一个新分支里重新提问,让模型从头回答,而不是在你已否定的回答上继续。当前对话会保留为分支,工作区文件不会有任何改动。',
|
|
264
|
+
'message.resendConfirm': '重发',
|
|
265
|
+
'message.resending': '正在重发…',
|
|
266
|
+
'message.resendDone': '已作为新一轮重发',
|
|
267
|
+
'message.resendStaged': '已填入输入框,可先修改再发送',
|
|
268
|
+
'message.resendFailed': '重发失败',
|
|
269
|
+
'message.edit': '编辑并重发',
|
|
270
|
+
'message.editTitle': '编辑并重发',
|
|
271
|
+
'message.editBody':
|
|
272
|
+
'修改你原本的提问,改完后会作为新的一轮直接发在当前会话里。那一轮之后记录的会话记忆会被清掉,工作区文件不会有任何改动。注意:DSH 的对话记录是只追加的,所以原来的提问和回答仍会留在上面的历史里,不会被删除。',
|
|
273
|
+
'message.editConfirm': '重发修改后的提问',
|
|
274
|
+
'message.undo': '撤销本次回滚',
|
|
275
|
+
'message.undoDone': '已撤销回滚',
|
|
276
|
+
'message.delete': '删除这一轮',
|
|
277
|
+
'message.deleteTitle': '确定要删除这一轮吗?',
|
|
278
|
+
'message.deleteBody': '这一轮的提问、回答,以及中间的过程说明都会从会话中移除。只清理对话记录与会话记忆,工作区文件不会有任何改动。',
|
|
279
|
+
'message.deleteConfirm': '删除',
|
|
280
|
+
'message.deleteDone': '已删除这一轮',
|
|
281
|
+
'message.deleteFailed': '删除消息失败',
|
|
282
|
+
'message.deleteOnly': '这是当前会话中唯一的消息,删除后会同时删除该会话。',
|
|
283
|
+
'dialog.cancel': '取消',
|
|
284
|
+
'dialog.delete': '删除',
|
|
285
|
+
'dialog.deleteTitle': '删除任务',
|
|
286
|
+
'dialog.deleteBody': '删除后,这个会话及其所有内容将从本地移除,包括聊天记录、快照和会话记忆,且无法恢复。',
|
|
287
|
+
'dialog.deleting': '正在删除…',
|
|
288
|
+
'dialog.deleteDone': '会话已删除',
|
|
289
|
+
'dialog.deleteStale': '已删除;列表若还在,按 Ctrl+R 刷新即可',
|
|
290
|
+
'dialog.deleteFailed': '删除会话失败',
|
|
291
|
+
'dialog.deleteSubagents': '它启动的 {n} 个子代理会话也会一并删除。',
|
|
292
|
+
'dialog.deleteWouldRefuse': '这里有会话仍在运行,请先停止;目前没有删除任何内容。',
|
|
293
|
+
'dialog.sessionRunning': '这个会话正在运行,请先停止再删除。',
|
|
294
|
+
'settings.title': '会话快照',
|
|
295
|
+
'settings.description': '查看会话的工作区检查点、占用空间,并锁定、删除或恢复它们。',
|
|
296
|
+
'settings.empty': '还没有快照。每一轮对话开始时都会自动记录一次。',
|
|
297
|
+
'settings.capture': '立即创建快照',
|
|
298
|
+
'settings.capturing': '正在创建…',
|
|
299
|
+
'settings.restore': '恢复文件',
|
|
300
|
+
'settings.restoreConfirm': '恢复这份快照记录的文件?对话内容不会改变。',
|
|
301
|
+
'settings.restoreDone': '工作区已恢复',
|
|
302
|
+
'settings.restoreFailed': '恢复快照失败',
|
|
303
|
+
'settings.remove': '删除快照',
|
|
304
|
+
'settings.lock': '锁定',
|
|
305
|
+
'settings.unlock': '解锁',
|
|
306
|
+
'settings.locked': '已锁定',
|
|
307
|
+
'settings.budget': '每个会话保留最近 {keep} 个自动快照',
|
|
308
|
+
'settings.usage': '快照占用 {size}',
|
|
309
|
+
'settings.files': '{count} 个文件',
|
|
310
|
+
'settings.turn': '第 {turn} 轮',
|
|
311
|
+
'settings.temporary': '临时',
|
|
312
|
+
'settings.undoBundles': '撤销包',
|
|
313
|
+
'settings.undoBundle': '第 {turn} 轮的回滚',
|
|
314
|
+
'settings.undoRestore': '撤销回滚',
|
|
315
|
+
'settings.undoHint': '回滚后 10 分钟内可撤销。',
|
|
316
|
+
'settings.memory': '会话记忆',
|
|
317
|
+
'settings.memoryEmpty': '这个会话还没有记录记忆。',
|
|
318
|
+
'settings.memoryAdd': '新增记忆',
|
|
319
|
+
'settings.memoryPlaceholder': '写入一条长期有效的事实、规则或偏好',
|
|
320
|
+
'settings.memorySave': '保存',
|
|
321
|
+
'settings.memoryRemove': '删除',
|
|
322
|
+
'settings.loading': '正在读取快照…',
|
|
323
|
+
'settings.session': '会话',
|
|
324
|
+
'settings.noSessions': '还没有可用的会话。',
|
|
325
|
+
'toast.captured': '已创建快照',
|
|
326
|
+
'toast.removed': '已删除快照',
|
|
327
|
+
'toast.locked': '已锁定快照',
|
|
328
|
+
'toast.unlocked': '已解锁快照',
|
|
329
|
+
'toast.memoryAdded': '记忆已保存',
|
|
330
|
+
'toast.memoryRemoved': '记忆已删除',
|
|
331
|
+
'toast.failed': '操作失败',
|
|
332
|
+
'settings.details': '详情',
|
|
333
|
+
'memory.title': '长期记忆',
|
|
334
|
+
'memory.description': '跨会话保留的稳定事实与约定,分两个库:本工作区私有,以及全局。',
|
|
335
|
+
'memory.tab.all': '全部',
|
|
336
|
+
'memory.tab.workspace': '本工作区',
|
|
337
|
+
'memory.tab.global': '全局',
|
|
338
|
+
'memory.tab.archived': '冷归档',
|
|
339
|
+
'memory.global': '全局',
|
|
340
|
+
'memory.workspace': '本工作区',
|
|
341
|
+
'memory.workspacePicker': '工作区',
|
|
342
|
+
'memory.workspaceNone': '未检测到工作区',
|
|
343
|
+
'memory.rule': '规则',
|
|
344
|
+
'memory.summary': '摘要',
|
|
345
|
+
'memory.frozen': '已冻结',
|
|
346
|
+
'memory.archived': '已归档',
|
|
347
|
+
'memory.score': '评分 {score}',
|
|
348
|
+
'memory.access': '召回 {count} 次',
|
|
349
|
+
'memory.empty': '还没有记忆。可以说【记住:…】,或在下面手动添加。',
|
|
350
|
+
'memory.explainTitle': '这句话会召回什么?',
|
|
351
|
+
'memory.explainHint':
|
|
352
|
+
'输入一个问题,看它会召回哪几条记忆、证据够支持几条、其余的各自因为什么被排除。不会记录任何东西,这是一次试算。',
|
|
353
|
+
'memory.explainPlaceholder': '一个问题,例如:缓存命中率为什么掉了',
|
|
354
|
+
'memory.explainRun': '试算',
|
|
355
|
+
'memory.explainInjected': '注入 {n} 条(上限 {limit})',
|
|
356
|
+
'memory.explainNone': '没有任何匹配,因此不会注入任何记忆。这是预期结果,不是故障。',
|
|
357
|
+
'memory.explainOmitted': '排除了 {n} 条',
|
|
358
|
+
'memory.whyNoMatch': '未命中',
|
|
359
|
+
'memory.whyCapacity': '超出预算',
|
|
360
|
+
'memory.filterAllTags': '全部标签',
|
|
361
|
+
'memory.filterAnyTime': '不限时间',
|
|
362
|
+
'memory.filterToday': '最近 24 小时',
|
|
363
|
+
'memory.filterWeek': '最近 7 天',
|
|
364
|
+
'memory.filterMonth': '最近 30 天',
|
|
365
|
+
'memory.filterClear': '清除筛选',
|
|
366
|
+
'memory.filterCount': '共 {n} 条',
|
|
367
|
+
'memory.placeholder': '写入一条长期有效的事实、规则或偏好',
|
|
368
|
+
'memory.save': '保存',
|
|
369
|
+
'memory.added': '记忆已保存',
|
|
370
|
+
'memory.removed': '记忆已删除',
|
|
371
|
+
'memory.freeze': '冻结',
|
|
372
|
+
'memory.unfreeze': '解冻',
|
|
373
|
+
'memory.remove': '删除',
|
|
374
|
+
'memory.pendingTitle': '待确认的记忆',
|
|
375
|
+
'memory.pendingHint': '从最近几轮里识别出来的,确认前不会入库。',
|
|
376
|
+
'memory.accept': '保留',
|
|
377
|
+
'memory.reject': '丢弃',
|
|
378
|
+
'memory.accepted': '已保留',
|
|
379
|
+
'memory.rejected': '已丢弃',
|
|
380
|
+
'memory.maintain': '重新评分并压缩',
|
|
381
|
+
'memory.maintained': '记忆已重新评分',
|
|
382
|
+
'memory.maintainedAt': '上次维护',
|
|
383
|
+
'memory.maintainedNever': '尚未维护过 — 记忆有变化时会自动运行',
|
|
384
|
+
'memory.maintainedScored': '已评分',
|
|
385
|
+
'memory.maintainedCompressed': '已压缩',
|
|
386
|
+
'memory.maintainedRules': '条规则',
|
|
387
|
+
'memory.implicit': '自动识别并建议记忆',
|
|
388
|
+
'memory.clearCold': '清空冷归档',
|
|
389
|
+
'memory.coldCleared': '冷归档已清空',
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ----------------------------------------------------------------- store
|
|
394
|
+
|
|
395
|
+
/** Minimal external store: one snapshot, many subscribers. */
|
|
396
|
+
function createStore(initial) {
|
|
397
|
+
let value = initial
|
|
398
|
+
const listeners = new Set()
|
|
399
|
+
return {
|
|
400
|
+
get: () => value,
|
|
401
|
+
set(next) {
|
|
402
|
+
value = next
|
|
403
|
+
for (const listener of listeners) listener()
|
|
404
|
+
},
|
|
405
|
+
patch(partial) {
|
|
406
|
+
value = { ...(value ?? {}), ...partial }
|
|
407
|
+
for (const listener of listeners) listener()
|
|
408
|
+
},
|
|
409
|
+
subscribe(listener) {
|
|
410
|
+
listeners.add(listener)
|
|
411
|
+
return () => listeners.delete(listener)
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const pending = createStore(null)
|
|
417
|
+
const dialog = createStore(null)
|
|
418
|
+
const toast = createStore(null)
|
|
419
|
+
const revision = createStore(0)
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Host-facing capabilities captured from the plugin context in `apply`, so
|
|
423
|
+
* deep components can reach them without prop drilling.
|
|
424
|
+
*/
|
|
425
|
+
const runtime = {
|
|
426
|
+
openSession: undefined,
|
|
427
|
+
archiveSession: undefined,
|
|
428
|
+
cwd: undefined,
|
|
429
|
+
sessionId: undefined,
|
|
430
|
+
resolveCwd: undefined,
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function useStore(store) {
|
|
434
|
+
return useSyncExternalStore(store.subscribe, store.get, store.get)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function bump() {
|
|
438
|
+
revision.set(revision.get() + 1)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let toastTimer
|
|
442
|
+
function notify(message, tone) {
|
|
443
|
+
toast.set({ id: Date.now(), message, tone: tone ?? 'default' })
|
|
444
|
+
clearTimeout(toastTimer)
|
|
445
|
+
toastTimer = setTimeout(() => toast.set(null), 3200)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ------------------------------------------------------------------- api
|
|
449
|
+
|
|
450
|
+
async function call(payload, init) {
|
|
451
|
+
const controller = new AbortController()
|
|
452
|
+
const timer = setTimeout(() => controller.abort(), init?.timeoutMs ?? API_TIMEOUT_MS)
|
|
453
|
+
try {
|
|
454
|
+
const response = await fetch(ROUTE, {
|
|
455
|
+
method: 'POST',
|
|
456
|
+
headers: { 'content-type': 'application/json' },
|
|
457
|
+
body: JSON.stringify(payload),
|
|
458
|
+
signal: controller.signal,
|
|
459
|
+
})
|
|
460
|
+
const text = await response.text()
|
|
461
|
+
let parsed
|
|
462
|
+
try {
|
|
463
|
+
parsed = JSON.parse(text)
|
|
464
|
+
} catch {
|
|
465
|
+
throw new Error(text.slice(0, 300) || `HTTP ${response.status}`)
|
|
466
|
+
}
|
|
467
|
+
if (!parsed.ok) {
|
|
468
|
+
const error = new Error(parsed.error || `HTTP ${response.status}`)
|
|
469
|
+
error.code = parsed.code
|
|
470
|
+
throw error
|
|
471
|
+
}
|
|
472
|
+
return parsed.data
|
|
473
|
+
} finally {
|
|
474
|
+
clearTimeout(timer)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Save a Blob through the browser's download manager. */
|
|
479
|
+
function saveBlob(blob, fileName) {
|
|
480
|
+
const url = URL.createObjectURL(blob)
|
|
481
|
+
const anchor = document.createElement('a')
|
|
482
|
+
anchor.href = url
|
|
483
|
+
anchor.download = fileName
|
|
484
|
+
document.body.appendChild(anchor)
|
|
485
|
+
anchor.click()
|
|
486
|
+
anchor.remove()
|
|
487
|
+
setTimeout(() => URL.revokeObjectURL(url), 4000)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ------------------------------------------------------------------ icons
|
|
491
|
+
|
|
492
|
+
function icon(children, size) {
|
|
493
|
+
return h(
|
|
494
|
+
'svg',
|
|
495
|
+
{
|
|
496
|
+
viewBox: '0 0 16 16',
|
|
497
|
+
width: size ?? 16,
|
|
498
|
+
height: size ?? 16,
|
|
499
|
+
fill: 'none',
|
|
500
|
+
stroke: 'currentColor',
|
|
501
|
+
strokeWidth: 1.4,
|
|
502
|
+
strokeLinecap: 'round',
|
|
503
|
+
strokeLinejoin: 'round',
|
|
504
|
+
'aria-hidden': true,
|
|
505
|
+
className: 'dss-icon',
|
|
506
|
+
},
|
|
507
|
+
...children,
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const Icons = {
|
|
512
|
+
rewind: () => icon([h('path', { key: 'a', d: 'M3.2 8a4.8 4.8 0 1 0 1.5-3.5' }), h('path', { key: 'b', d: 'M2.6 2.4v3h3' })]),
|
|
513
|
+
// A pencil, not a circular arrow: this edits the user's question, it does
|
|
514
|
+
// not repeat or restore anything. The circle was confusing in the action
|
|
515
|
+
// row, where rewind already uses a circular arrow.
|
|
516
|
+
edit: () =>
|
|
517
|
+
icon([
|
|
518
|
+
h('path', { key: 'a', d: 'M3 13.2 11.7 4.5l1.8 1.8L4.8 15H3z' }),
|
|
519
|
+
h('path', { key: 'b', d: 'M10.4 5.8l1.8 1.8 1.3-1.3a.9.9 0 0 0 0-1.3l-.5-.5a.9.9 0 0 0-1.3 0z' }),
|
|
520
|
+
]),
|
|
521
|
+
trash: () =>
|
|
522
|
+
icon([
|
|
523
|
+
h('path', { key: 'a', d: 'M2.6 4.3h10.8' }),
|
|
524
|
+
h('path', { key: 'b', d: 'M6.3 4.3V2.9h3.4v1.4' }),
|
|
525
|
+
h('path', { key: 'c', d: 'M4.2 4.3l.6 8.2a1 1 0 0 0 1 .9h4.4a1 1 0 0 0 1-.9l.6-8.2' }),
|
|
526
|
+
]),
|
|
527
|
+
download: () =>
|
|
528
|
+
icon([h('path', { key: 'a', d: 'M8 2.4v7.2' }), h('path', { key: 'b', d: 'M5 6.8 8 9.8l3-3' }), h('path', { key: 'c', d: 'M3 12.6h10' })]),
|
|
529
|
+
clipboard: () =>
|
|
530
|
+
icon([
|
|
531
|
+
h('rect', { key: 'a', x: 3.4, y: 3.2, width: 9.2, height: 10.4, rx: 1.6 }),
|
|
532
|
+
h('path', { key: 'b', d: 'M6 2.4h4v1.8H6z' }),
|
|
533
|
+
]),
|
|
534
|
+
code: () => icon([h('path', { key: 'a', d: 'M5.6 4.6 2.6 8l3 3.4' }), h('path', { key: 'b', d: 'M10.4 4.6 13.4 8l-3 3.4' })]),
|
|
535
|
+
cut: () =>
|
|
536
|
+
icon([
|
|
537
|
+
h('circle', { key: 'a', cx: 4.4, cy: 11.6, r: 1.9 }),
|
|
538
|
+
h('circle', { key: 'b', cx: 11.6, cy: 11.6, r: 1.9 }),
|
|
539
|
+
h('path', { key: 'c', d: 'M5.8 10.2 11.2 2.6' }),
|
|
540
|
+
h('path', { key: 'd', d: 'M10.2 10.2 4.8 2.6' }),
|
|
541
|
+
]),
|
|
542
|
+
lock: () => icon([h('rect', { key: 'a', x: 3.4, y: 7, width: 9.2, height: 6.4, rx: 1.4 }), h('path', { key: 'b', d: 'M5.6 7V5.4a2.4 2.4 0 0 1 4.8 0V7' })]),
|
|
543
|
+
unlock: () => icon([h('rect', { key: 'a', x: 3.4, y: 7, width: 9.2, height: 6.4, rx: 1.4 }), h('path', { key: 'b', d: 'M5.6 7V5.4a2.4 2.4 0 0 1 4.6-.8' })]),
|
|
544
|
+
restore: () => icon([h('path', { key: 'a', d: 'M3 8a5 5 0 1 1 1.6 3.7' }), h('path', { key: 'b', d: 'M2.6 2.8v3h3' })]),
|
|
545
|
+
close: () => icon([h('path', { key: 'a', d: 'M4 4l8 8' }), h('path', { key: 'b', d: 'M12 4l-8 8' })]),
|
|
546
|
+
info: () =>
|
|
547
|
+
icon([
|
|
548
|
+
h('circle', { key: 'a', cx: 8, cy: 8, r: 5.6 }),
|
|
549
|
+
h('path', { key: 'b', d: 'M8 7.2v4' }),
|
|
550
|
+
h('path', { key: 'c', d: 'M8 5.1v.1' }),
|
|
551
|
+
]),
|
|
552
|
+
warning: () =>
|
|
553
|
+
icon([
|
|
554
|
+
h('circle', { key: 'a', cx: 8, cy: 8, r: 5.6 }),
|
|
555
|
+
h('path', { key: 'b', d: 'M8 4.9v3.7' }),
|
|
556
|
+
h('path', { key: 'c', d: 'M8 10.9v.1' }),
|
|
557
|
+
]),
|
|
558
|
+
plus: () => icon([h('path', { key: 'a', d: 'M8 3.4v9.2' }), h('path', { key: 'b', d: 'M3.4 8h9.2' })]),
|
|
559
|
+
arrowRight: () => icon([h('path', { key: 'a', d: 'M3.4 8h9.2' }), h('path', { key: 'b', d: 'M9 4.4 12.6 8 9 11.6' })]),
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ----------------------------------------------------------------- styles
|
|
563
|
+
|
|
564
|
+
const CSS = `
|
|
565
|
+
.dss-icon { display: block; flex: none; }
|
|
566
|
+
.dss-menu-item-wrap { display: block; }
|
|
567
|
+
.dss-menu-item {
|
|
568
|
+
display: flex; align-items: center; gap: 6px; width: 100%;
|
|
569
|
+
min-height: 34px; padding: 6px 8px; border: 0;
|
|
570
|
+
border-radius: var(--dsw-radius-md, 8px); background: transparent;
|
|
571
|
+
font-size: 13px; line-height: 20px; text-align: left; cursor: pointer;
|
|
572
|
+
color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
573
|
+
}
|
|
574
|
+
.dss-menu-item:hover, .dss-menu-item:focus-visible {
|
|
575
|
+
background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 8%, transparent);
|
|
576
|
+
outline: none;
|
|
577
|
+
}
|
|
578
|
+
.dss-menu-item:disabled { opacity: .4; cursor: not-allowed; }
|
|
579
|
+
.dss-menu-item.dss-danger { color: var(--dsw-alias-state-error-primary, #d93025); }
|
|
580
|
+
.dss-menu-item.dss-danger:hover {
|
|
581
|
+
background: var(--dsw-alias-interactive-bg-hover-danger, color-mix(in srgb, var(--dsw-alias-state-error-primary, #d93025) 12%, transparent));
|
|
582
|
+
}
|
|
583
|
+
.dss-menu-separator {
|
|
584
|
+
height: .5px; margin: 4px 2px;
|
|
585
|
+
background: var(--dsw-alias-border-l2, rgba(0,0,0,.1));
|
|
586
|
+
}
|
|
587
|
+
.dss-menu-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; flex: none; }
|
|
588
|
+
.dss-menu-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
589
|
+
|
|
590
|
+
.dss-action {
|
|
591
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
592
|
+
width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--dsw-radius-sm, 6px);
|
|
593
|
+
background: transparent; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, #6b6b6b));
|
|
594
|
+
cursor: pointer; transition: background .15s ease, color .15s ease;
|
|
595
|
+
}
|
|
596
|
+
.dss-action:hover {
|
|
597
|
+
background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, var(--dsw-alias-label-primary, #000) 8%, transparent));
|
|
598
|
+
color: var(--dsw-alias-label-secondary, #6b6b6b);
|
|
599
|
+
}
|
|
600
|
+
.dss-action.dss-danger:hover {
|
|
601
|
+
background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d93025) 14%, transparent);
|
|
602
|
+
color: var(--dsw-alias-state-error-primary, #d93025);
|
|
603
|
+
}
|
|
604
|
+
.dss-action:disabled { opacity: .4; cursor: progress; }
|
|
605
|
+
|
|
606
|
+
.dss-banner {
|
|
607
|
+
display: flex; gap: 10px; align-items: flex-start;
|
|
608
|
+
margin: 0 0 8px; padding: 12px 14px; border-radius: 10px;
|
|
609
|
+
background: var(--dsw-alias-bg-layer-1, #f4f4f4);
|
|
610
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
|
|
611
|
+
color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
612
|
+
font-size: 13px; line-height: 20px;
|
|
613
|
+
}
|
|
614
|
+
.dss-banner-icon { color: var(--dsw-alias-brand-primary, #2f6feb); margin-top: 1px; }
|
|
615
|
+
.dss-banner-main { flex: 1 1 auto; min-width: 0; display: flex; gap: 10px; }
|
|
616
|
+
.dss-banner-text { display: flex; flex-direction: column; gap: 2px; }
|
|
617
|
+
.dss-banner-title { font-weight: 600; }
|
|
618
|
+
.dss-banner-body { color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
619
|
+
.dss-banner-actions { display: flex; gap: 8px; align-items: center; flex: none; }
|
|
620
|
+
|
|
621
|
+
.dss-btn {
|
|
622
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
623
|
+
height: 30px; padding: 0 14px; border-radius: 8px; font-size: 13px;
|
|
624
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12));
|
|
625
|
+
background: var(--dsw-alias-bg-layer-1, #fff);
|
|
626
|
+
color: var(--dsw-alias-label-primary, #1f1f1f); cursor: pointer;
|
|
627
|
+
transition: background .15s ease, border-color .15s ease;
|
|
628
|
+
}
|
|
629
|
+
.dss-btn:hover { background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 6%, var(--dsw-alias-bg-layer-1, #fff)); }
|
|
630
|
+
.dss-btn:disabled { opacity: .55; cursor: progress; }
|
|
631
|
+
.dss-btn.dss-primary {
|
|
632
|
+
background: var(--dsw-alias-label-primary, #1f1f1f);
|
|
633
|
+
border-color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
634
|
+
color: var(--dsw-alias-bg-base, #fff);
|
|
635
|
+
}
|
|
636
|
+
.dss-btn.dss-primary:hover { opacity: .9; }
|
|
637
|
+
.dss-btn.dss-critical {
|
|
638
|
+
background: var(--dsw-alias-state-error-primary, #d93025);
|
|
639
|
+
border-color: var(--dsw-alias-state-error-primary, #d93025);
|
|
640
|
+
color: #fff;
|
|
641
|
+
}
|
|
642
|
+
.dss-btn.dss-critical:hover { opacity: .9; }
|
|
643
|
+
.dss-btn.dss-ghost { border-color: transparent; background: transparent; }
|
|
644
|
+
.dss-btn.dss-ghost:hover { background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 6%, transparent); }
|
|
645
|
+
.dss-btn.dss-small { height: 26px; padding: 0 10px; font-size: 12px; border-radius: 7px; }
|
|
646
|
+
|
|
647
|
+
.dss-scrim {
|
|
648
|
+
position: fixed; inset: 0; z-index: 60; display: flex;
|
|
649
|
+
align-items: center; justify-content: center; padding: 24px;
|
|
650
|
+
background: rgba(15, 18, 24, .32); pointer-events: auto;
|
|
651
|
+
}
|
|
652
|
+
.dss-dialog {
|
|
653
|
+
width: min(440px, 100%); border-radius: 12px; padding: 18px 18px 16px;
|
|
654
|
+
background: var(--dsw-alias-bg-overlay, #fff);
|
|
655
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
|
|
656
|
+
box-shadow: 0 18px 48px rgba(15, 18, 24, .22);
|
|
657
|
+
color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
658
|
+
pointer-events: auto;
|
|
659
|
+
}
|
|
660
|
+
.dss-dialog-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
661
|
+
.dss-dialog-icon { display: inline-flex; flex: none; color: var(--dsw-alias-state-error-primary, #d93025); }
|
|
662
|
+
.dss-dialog-title { flex: 1 1 auto; font-size: 15px; font-weight: 600; }
|
|
663
|
+
.dss-dialog-title.dss-critical { color: var(--dsw-alias-state-error-primary, #d93025); }
|
|
664
|
+
.dss-dialog-body { font-size: 13px; line-height: 21px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
665
|
+
.dss-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
|
666
|
+
.dss-dialog-error { margin-top: 10px; font-size: 12px; color: var(--dsw-alias-state-error-primary, #d93025); }
|
|
667
|
+
.dss-dialog.dss-wide { width: min(720px, 100%); }
|
|
668
|
+
.dss-dialog-scroll { max-height: min(62vh, 560px); overflow-y: auto; margin-top: 12px; padding-right: 4px; }
|
|
669
|
+
.dss-dialog-actions.dss-wrap { flex-wrap: wrap; }
|
|
670
|
+
|
|
671
|
+
.dss-picker-scroll { margin-top: 10px; }
|
|
672
|
+
.dss-picker { display: flex; flex-direction: column; gap: 2px; }
|
|
673
|
+
.dss-pick {
|
|
674
|
+
display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px;
|
|
675
|
+
border-radius: 8px; cursor: pointer; user-select: none;
|
|
676
|
+
}
|
|
677
|
+
.dss-pick:hover { background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 5%, transparent); }
|
|
678
|
+
.dss-pick input { margin-top: 3px; accent-color: var(--dsw-alias-brand-primary, #2563eb); }
|
|
679
|
+
.dss-pick-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
680
|
+
.dss-pick-title {
|
|
681
|
+
font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
682
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
683
|
+
}
|
|
684
|
+
.dss-pick-sub {
|
|
685
|
+
font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b);
|
|
686
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
687
|
+
}
|
|
688
|
+
.dss-note { margin-top: 10px; font-size: 12.5px; color: var(--dsw-alias-state-success-primary, #1a7f37); }
|
|
689
|
+
.dss-curate {
|
|
690
|
+
display: flex; align-items: flex-start; gap: 10px; margin-top: 12px;
|
|
691
|
+
padding: 10px 12px; border-radius: 8px; cursor: pointer;
|
|
692
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
|
|
693
|
+
background: var(--dsw-alias-bg-layer-1, #fff);
|
|
694
|
+
}
|
|
695
|
+
.dss-curate input { margin-top: 3px; accent-color: var(--dsw-alias-brand-primary, #2563eb); }
|
|
696
|
+
.dss-curate-text { display: flex; flex-direction: column; gap: 2px; }
|
|
697
|
+
.dss-curate-title { font-size: 13px; color: var(--dsw-alias-label-primary, #1f1f1f); }
|
|
698
|
+
.dss-curate-hint { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
699
|
+
.dss-curate-inline { margin-top: 0; padding: 6px 10px; border-color: transparent; background: transparent; }
|
|
700
|
+
|
|
701
|
+
.dss-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
702
|
+
.dss-tab {
|
|
703
|
+
height: 28px; padding: 0 12px; border-radius: 999px; font-size: 12.5px; cursor: pointer;
|
|
704
|
+
border: 1px solid transparent; background: transparent;
|
|
705
|
+
color: var(--dsw-alias-label-secondary, #6b6b6b);
|
|
706
|
+
}
|
|
707
|
+
.dss-tab:hover { background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 6%, transparent); }
|
|
708
|
+
.dss-tab.dss-tab-on {
|
|
709
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 12%, transparent);
|
|
710
|
+
color: var(--dsw-alias-brand-primary, #2563eb); font-weight: 600;
|
|
711
|
+
}
|
|
712
|
+
.dss-mem-pending {
|
|
713
|
+
display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 10px;
|
|
714
|
+
border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 30%, transparent);
|
|
715
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #2563eb) 6%, transparent);
|
|
716
|
+
}
|
|
717
|
+
.dss-mem-pending-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
718
|
+
.dss-warn {
|
|
719
|
+
margin-top: 10px; padding: 8px 10px; border-radius: 8px; font-size: 12.5px; line-height: 19px;
|
|
720
|
+
color: var(--dsw-alias-state-warn-primary, #9a6700);
|
|
721
|
+
background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #9a6700) 12%, transparent);
|
|
722
|
+
}
|
|
723
|
+
.dss-hint { margin-top: 12px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
724
|
+
|
|
725
|
+
.dss-toast {
|
|
726
|
+
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
|
|
727
|
+
z-index: 70; padding: 9px 14px; border-radius: 999px; pointer-events: none;
|
|
728
|
+
font-size: 13px; color: var(--dsw-alias-bg-base, #fff);
|
|
729
|
+
background: var(--dsw-alias-label-primary, #1f1f1f);
|
|
730
|
+
box-shadow: 0 8px 24px rgba(15, 18, 24, .24);
|
|
731
|
+
}
|
|
732
|
+
.dss-toast.dss-error { background: var(--dsw-alias-state-error-primary, #d93025); }
|
|
733
|
+
|
|
734
|
+
.dss-section { display: flex; flex-direction: column; gap: 12px; max-width: 720px; }
|
|
735
|
+
.dss-section.dss-embedded { max-width: none; gap: 10px; }
|
|
736
|
+
.dss-section h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
|
|
737
|
+
.dss-section-head { display: flex; flex-direction: column; gap: 4px; }
|
|
738
|
+
.dss-section-title { font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f1f1f); }
|
|
739
|
+
.dss-section-desc { font-size: 12.5px; line-height: 19px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
740
|
+
.dss-card {
|
|
741
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
|
|
742
|
+
border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff);
|
|
743
|
+
}
|
|
744
|
+
.dss-row {
|
|
745
|
+
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
|
746
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
|
|
747
|
+
}
|
|
748
|
+
.dss-row:last-child { border-bottom: 0; }
|
|
749
|
+
.dss-row-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
750
|
+
.dss-row-title { font-size: 13px; color: var(--dsw-alias-label-primary, #1f1f1f); display: flex; align-items: center; gap: 6px; }
|
|
751
|
+
.dss-row-meta { font-size: 12px; color: var(--dsw-alias-label-secondary, #6b6b6b); display: flex; gap: 10px; flex-wrap: wrap; }
|
|
752
|
+
.dss-badge {
|
|
753
|
+
display: inline-flex; align-items: center; height: 18px; padding: 0 6px;
|
|
754
|
+
border-radius: 999px; font-size: 11px; line-height: 18px;
|
|
755
|
+
background: color-mix(in srgb, var(--dsw-alias-label-primary, #000) 7%, transparent);
|
|
756
|
+
color: var(--dsw-alias-label-secondary, #6b6b6b);
|
|
757
|
+
}
|
|
758
|
+
.dss-badge.dss-locked { background: color-mix(in srgb, var(--dsw-alias-brand-primary, #2f6feb) 14%, transparent); color: var(--dsw-alias-brand-primary, #2f6feb); }
|
|
759
|
+
.dss-badge.dss-temp { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b07b00) 16%, transparent); color: var(--dsw-alias-state-warn-primary, #b07b00); }
|
|
760
|
+
.dss-empty { padding: 18px 12px; font-size: 13px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
761
|
+
.dss-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
762
|
+
.dss-select {
|
|
763
|
+
height: 30px; border-radius: 8px; padding: 0 8px; font-size: 13px;
|
|
764
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12));
|
|
765
|
+
background: var(--dsw-alias-bg-layer-1, #fff);
|
|
766
|
+
color: var(--dsw-alias-label-primary, #1f1f1f); max-width: 100%;
|
|
767
|
+
}
|
|
768
|
+
.dss-ws-picker { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 2px 0 6px; }
|
|
769
|
+
.dss-ws-label { font-size: 12px; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
770
|
+
.dss-ws-picker .dss-select { flex: 0 1 260px; min-width: 0; }
|
|
771
|
+
.dss-maint {
|
|
772
|
+
display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline;
|
|
773
|
+
padding-top: 8px; font-size: 11px; line-height: 1.5;
|
|
774
|
+
color: var(--dsw-alias-label-tertiary, #8a8a8a);
|
|
775
|
+
}
|
|
776
|
+
.dss-maint-when { flex: 0 0 auto; }
|
|
777
|
+
.dss-maint-report { flex: 0 0 auto; font-variant-numeric: tabular-nums; }
|
|
778
|
+
.dss-input {
|
|
779
|
+
flex: 1 1 auto; min-width: 0; height: 30px; padding: 0 9px; font-size: 13px;
|
|
780
|
+
border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12));
|
|
781
|
+
background: var(--dsw-alias-bg-base, #fff); color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
782
|
+
}
|
|
783
|
+
.dss-group-title { font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--dsw-alias-label-secondary, #6b6b6b); }
|
|
784
|
+
.dss-memory-text { font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary, #1f1f1f); word-break: break-word; }
|
|
785
|
+
.dss-row-actions { display: flex; align-items: center; gap: 4px; flex: none; }
|
|
786
|
+
.dss-spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-top-color: transparent; animation: dss-spin .8s linear infinite; }
|
|
787
|
+
@keyframes dss-spin { to { transform: rotate(360deg); } }
|
|
788
|
+
`
|
|
789
|
+
|
|
790
|
+
function installStyles() {
|
|
791
|
+
const element = document.createElement('style')
|
|
792
|
+
element.setAttribute('data-dsh-session-suite', '')
|
|
793
|
+
element.textContent = CSS
|
|
794
|
+
document.head.appendChild(element)
|
|
795
|
+
return () => element.remove()
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ------------------------------------------------------------- primitives
|
|
799
|
+
|
|
800
|
+
function MenuRow({ label, iconNode, danger, separator, disabled, onSelect }) {
|
|
801
|
+
const className = ['dss-menu-item', danger ? 'dss-danger' : ''].filter(Boolean).join(' ')
|
|
802
|
+
const button = h(
|
|
803
|
+
'button',
|
|
804
|
+
{ type: 'button', role: 'menuitem', className, disabled, title: label, onClick: onSelect },
|
|
805
|
+
iconNode ? h('span', { className: 'dss-menu-icon' }, iconNode) : null,
|
|
806
|
+
h('span', { className: 'dss-menu-label' }, label),
|
|
807
|
+
)
|
|
808
|
+
return h(
|
|
809
|
+
'div',
|
|
810
|
+
{ className: 'dss-menu-item-wrap' },
|
|
811
|
+
separator ? h('div', { className: 'dss-menu-separator', role: 'separator' }) : null,
|
|
812
|
+
button,
|
|
813
|
+
)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function DialogFrame({ title, critical, wide, onClose, children }) {
|
|
817
|
+
const ref = useRef(null)
|
|
818
|
+
useEffect(() => {
|
|
819
|
+
const onKey = event => {
|
|
820
|
+
if (event.key === 'Escape') {
|
|
821
|
+
event.stopPropagation()
|
|
822
|
+
onClose()
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
document.addEventListener('keydown', onKey, true)
|
|
826
|
+
ref.current?.focus()
|
|
827
|
+
return () => document.removeEventListener('keydown', onKey, true)
|
|
828
|
+
}, [onClose])
|
|
829
|
+
return h(
|
|
830
|
+
'div',
|
|
831
|
+
{
|
|
832
|
+
className: 'dss-scrim',
|
|
833
|
+
onMouseDown: event => {
|
|
834
|
+
if (event.target === event.currentTarget) onClose()
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
h(
|
|
838
|
+
'div',
|
|
839
|
+
{
|
|
840
|
+
className: 'dss-dialog' + (wide ? ' dss-wide' : ''),
|
|
841
|
+
role: 'dialog',
|
|
842
|
+
'aria-modal': 'true',
|
|
843
|
+
'aria-label': title,
|
|
844
|
+
tabIndex: -1,
|
|
845
|
+
ref,
|
|
846
|
+
},
|
|
847
|
+
h(
|
|
848
|
+
'div',
|
|
849
|
+
{ className: 'dss-dialog-head' },
|
|
850
|
+
critical
|
|
851
|
+
? h('span', { className: 'dss-dialog-icon', 'aria-hidden': true }, h(Icons.warning, null))
|
|
852
|
+
: null,
|
|
853
|
+
h('div', { className: 'dss-dialog-title' }, title),
|
|
854
|
+
h('button', { type: 'button', className: 'dss-action', onClick: onClose, 'aria-label': 'Close' }, h(Icons.close, null)),
|
|
855
|
+
),
|
|
856
|
+
children,
|
|
857
|
+
),
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// --------------------------------------------------------------- overlay
|
|
862
|
+
|
|
863
|
+
function SelectSessionDialog({ state, onClose }) {
|
|
864
|
+
const [busy, setBusy] = useState(false)
|
|
865
|
+
const [error, setError] = useState(null)
|
|
866
|
+
const [preview, setPreview] = useState(null)
|
|
867
|
+
const t = state.t ?? (key => key)
|
|
868
|
+
|
|
869
|
+
// Deleting a session also deletes the subagent sessions it started, so the
|
|
870
|
+
// dialog says how many before the user commits rather than after. A failed
|
|
871
|
+
// preview never blocks the dialog: the confirm button still works and the
|
|
872
|
+
// Host reports the real reason on submit.
|
|
873
|
+
useEffect(() => {
|
|
874
|
+
if (state.mode !== 'delete-session' || !state.sessionId) return undefined
|
|
875
|
+
let alive = true
|
|
876
|
+
call({ action: 'previewDelete', sessionId: state.sessionId })
|
|
877
|
+
.then(data => {
|
|
878
|
+
if (alive) setPreview(data)
|
|
879
|
+
})
|
|
880
|
+
.catch(() => {})
|
|
881
|
+
return () => {
|
|
882
|
+
alive = false
|
|
883
|
+
}
|
|
884
|
+
}, [state.mode, state.sessionId])
|
|
885
|
+
|
|
886
|
+
const confirm = async () => {
|
|
887
|
+
setBusy(true)
|
|
888
|
+
setError(null)
|
|
889
|
+
try {
|
|
890
|
+
if (state.mode === 'delete-session') {
|
|
891
|
+
// Close the open view first: the Host then finds the session idle
|
|
892
|
+
// and the sidebar drops it without leaving a dead panel behind.
|
|
893
|
+
try {
|
|
894
|
+
await runtime.archiveSession?.(state.sessionId)
|
|
895
|
+
} catch {
|
|
896
|
+
// The Host route below reports the precise reason (e.g. still running).
|
|
897
|
+
}
|
|
898
|
+
const removed = await call({ action: 'deleteSession', sessionId: state.sessionId })
|
|
899
|
+
// Repeat the archive: the Host now knows the session is gone, so this
|
|
900
|
+
// pass always takes the durable path and drops the sidebar row.
|
|
901
|
+
try {
|
|
902
|
+
await runtime.archiveSession?.(state.sessionId)
|
|
903
|
+
} catch {
|
|
904
|
+
// Already archived is the common, fine outcome.
|
|
905
|
+
}
|
|
906
|
+
notify(removed?.stillListed ? t('dialog.deleteStale') : t('dialog.deleteDone'))
|
|
907
|
+
} else if (state.onConfirm) {
|
|
908
|
+
await state.onConfirm()
|
|
909
|
+
if (state.doneMessage) notify(state.doneMessage)
|
|
910
|
+
}
|
|
911
|
+
onClose()
|
|
912
|
+
bump()
|
|
913
|
+
} catch (failure) {
|
|
914
|
+
const message = failure?.code === 'session-running' ? t('dialog.sessionRunning') : failure?.message ?? t('toast.failed')
|
|
915
|
+
setError(message)
|
|
916
|
+
} finally {
|
|
917
|
+
setBusy(false)
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const linked = preview && Array.isArray(preview.subagents) ? preview.subagents.length : 0
|
|
922
|
+
const terminating = preview?.willRefuse === true
|
|
923
|
+
const body = state.mode === 'delete-session'
|
|
924
|
+
? h(
|
|
925
|
+
'div',
|
|
926
|
+
{ className: 'dss-dialog-body' },
|
|
927
|
+
t('dialog.deleteBody'),
|
|
928
|
+
state.onlyMessage ? h('div', { style: { marginTop: 8 } }, t('message.deleteOnly')) : null,
|
|
929
|
+
linked > 0
|
|
930
|
+
? h(
|
|
931
|
+
'div',
|
|
932
|
+
{ style: { marginTop: 8, fontWeight: 600 } },
|
|
933
|
+
t('dialog.deleteSubagents', { n: linked }),
|
|
934
|
+
)
|
|
935
|
+
: null,
|
|
936
|
+
terminating
|
|
937
|
+
? h('div', { style: { marginTop: 8 } }, t('dialog.deleteWouldRefuse'))
|
|
938
|
+
: null,
|
|
939
|
+
)
|
|
940
|
+
: h('div', { className: 'dss-dialog-body' }, state.body)
|
|
941
|
+
|
|
942
|
+
return h(
|
|
943
|
+
DialogFrame,
|
|
944
|
+
{ title: state.mode === 'delete-session' ? t('dialog.deleteTitle') : state.title, critical: true, onClose: busy ? () => {} : onClose },
|
|
945
|
+
body,
|
|
946
|
+
error ? h('div', { className: 'dss-dialog-error' }, error) : null,
|
|
947
|
+
h(
|
|
948
|
+
'div',
|
|
949
|
+
{ className: 'dss-dialog-actions' },
|
|
950
|
+
h('button', { type: 'button', className: 'dss-btn', onClick: onClose, disabled: busy }, t('dialog.cancel')),
|
|
951
|
+
h(
|
|
952
|
+
'button',
|
|
953
|
+
{ type: 'button', className: 'dss-btn dss-critical', onClick: confirm, disabled: busy },
|
|
954
|
+
busy ? h('span', { className: 'dss-spin' }) : null,
|
|
955
|
+
busy ? t('dialog.deleting') : (state.confirmLabel ?? t('dialog.delete')),
|
|
956
|
+
),
|
|
957
|
+
),
|
|
958
|
+
)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// ------------------------------------------------------ turn selection
|
|
962
|
+
|
|
963
|
+
/** Load the turn outline a share or segment dialog works on. */
|
|
964
|
+
function useOutline(sessionId) {
|
|
965
|
+
const [state, setState] = useState({ loading: true, outline: null, error: null })
|
|
966
|
+
useEffect(() => {
|
|
967
|
+
let alive = true
|
|
968
|
+
call({ action: 'outline', sessionId })
|
|
969
|
+
.then(outline => alive && setState({ loading: false, outline, error: null }))
|
|
970
|
+
.catch(error => alive && setState({ loading: false, outline: null, error: error.message }))
|
|
971
|
+
return () => {
|
|
972
|
+
alive = false
|
|
973
|
+
}
|
|
974
|
+
}, [sessionId])
|
|
975
|
+
return state
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Every turn starts selected: sharing or deleting everything is the default. */
|
|
979
|
+
function useTurnSelection(outline) {
|
|
980
|
+
const [selected, setSelected] = useState(() => new Set())
|
|
981
|
+
useEffect(() => {
|
|
982
|
+
setSelected(new Set((outline?.turns ?? []).map(row => row.turn)))
|
|
983
|
+
}, [outline])
|
|
984
|
+
const toggle = useCallback(turn => {
|
|
985
|
+
setSelected(current => {
|
|
986
|
+
const next = new Set(current)
|
|
987
|
+
if (next.has(turn)) next.delete(turn)
|
|
988
|
+
else next.add(turn)
|
|
989
|
+
return next
|
|
990
|
+
})
|
|
991
|
+
}, [])
|
|
992
|
+
const all = useCallback(() => setSelected(new Set((outline?.turns ?? []).map(row => row.turn))), [outline])
|
|
993
|
+
const none = useCallback(() => setSelected(new Set()), [])
|
|
994
|
+
return { selected, toggle, all, none }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function rangesOf(outline, selected) {
|
|
998
|
+
return (outline?.turns ?? [])
|
|
999
|
+
.filter(row => selected.has(row.turn))
|
|
1000
|
+
.map(row => ({ startSeq: row.startSeq, endSeq: row.endSeq }))
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function TurnPicker({ outline, selected, onToggle, t }) {
|
|
1004
|
+
const turns = outline?.turns ?? []
|
|
1005
|
+
if (turns.length === 0) return h('div', { className: 'dss-empty' }, t('settings.empty'))
|
|
1006
|
+
return h(
|
|
1007
|
+
'div',
|
|
1008
|
+
{ className: 'dss-picker' },
|
|
1009
|
+
...turns.map(row => {
|
|
1010
|
+
const checked = selected.has(row.turn)
|
|
1011
|
+
return h(
|
|
1012
|
+
'label',
|
|
1013
|
+
{ key: row.turn, className: 'dss-pick' + (checked ? ' dss-pick-on' : '') },
|
|
1014
|
+
h('input', { type: 'checkbox', checked, onChange: () => onToggle(row.turn) }),
|
|
1015
|
+
h(
|
|
1016
|
+
'span',
|
|
1017
|
+
{ className: 'dss-pick-main' },
|
|
1018
|
+
h('span', { className: 'dss-pick-title' }, `${t('settings.turn', { turn: row.turn })} · ${row.preview || '—'}`),
|
|
1019
|
+
row.answerPreview ? h('span', { className: 'dss-pick-sub' }, row.answerPreview) : null,
|
|
1020
|
+
),
|
|
1021
|
+
)
|
|
1022
|
+
}),
|
|
1023
|
+
)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function SelectionToolbar({ t, outline, selected, onAll, onNone }) {
|
|
1027
|
+
const total = outline?.turns?.length ?? 0
|
|
1028
|
+
return h(
|
|
1029
|
+
'div',
|
|
1030
|
+
{ className: 'dss-toolbar' },
|
|
1031
|
+
h('span', { className: 'dss-group-title' }, t('share.selected', { count: selected.size, total })),
|
|
1032
|
+
h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', onClick: onAll }, t('share.all')),
|
|
1033
|
+
h('button', { type: 'button', className: 'dss-btn dss-small dss-ghost', onClick: onNone }, t('share.none')),
|
|
1034
|
+
)
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Draw the selected conversation into a PNG with the 2D canvas API.
|
|
1039
|
+
*
|
|
1040
|
+
* The Host already parsed the Markdown (export format `blocks`), so this is
|
|
1041
|
+
* purely layout: inline runs become wrapped lines, blocks become one card per
|
|
1042
|
+
* reply, and the user's prompts sit in blue bubbles on the right.
|
|
1043
|
+
*/
|
|
1044
|
+
async function renderConversationImage({ title, meta, footer, messages }) {
|
|
1045
|
+
const WIDTH = 880
|
|
1046
|
+
const PAGE_PAD = 40
|
|
1047
|
+
const CARD_PAD = 22
|
|
1048
|
+
const SCALE = 2
|
|
1049
|
+
const SANS = '-apple-system, "Segoe UI", "Microsoft YaHei", Roboto, sans-serif'
|
|
1050
|
+
const MONO = 'ui-monospace, SFMono-Regular, Consolas, monospace'
|
|
1051
|
+
const font = (size, weight) => `${weight ? `${weight} ` : ''}${size}px ${SANS}`
|
|
1052
|
+
const mono = size => `${size}px ${MONO}`
|
|
1053
|
+
const palette = {
|
|
1054
|
+
page: '#f4f5f7',
|
|
1055
|
+
card: '#ffffff',
|
|
1056
|
+
fg: '#1b1d21',
|
|
1057
|
+
muted: '#8a8f98',
|
|
1058
|
+
user: '#2563eb',
|
|
1059
|
+
userFg: '#ffffff',
|
|
1060
|
+
quote: '#f5f7fa',
|
|
1061
|
+
accent: '#2563eb',
|
|
1062
|
+
code: '#f6f7f9',
|
|
1063
|
+
codeFg: '#3a3f46',
|
|
1064
|
+
chipFg: '#3a3f46',
|
|
1065
|
+
line: '#eceef1',
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const measure = document.createElement('canvas').getContext('2d')
|
|
1069
|
+
const cardWidth = WIDTH - PAGE_PAD * 2
|
|
1070
|
+
const contentWidth = cardWidth - CARD_PAD * 2
|
|
1071
|
+
const bubbleMax = Math.round(cardWidth * 0.8)
|
|
1072
|
+
|
|
1073
|
+
/** Decode a data URL into an image the canvas can draw. */
|
|
1074
|
+
const loadImage = source =>
|
|
1075
|
+
new Promise(resolve => {
|
|
1076
|
+
if (!source) {
|
|
1077
|
+
resolve(null)
|
|
1078
|
+
return
|
|
1079
|
+
}
|
|
1080
|
+
const image = new Image()
|
|
1081
|
+
image.onload = () => resolve(image)
|
|
1082
|
+
image.onerror = () => resolve(null)
|
|
1083
|
+
image.src = source
|
|
1084
|
+
})
|
|
1085
|
+
|
|
1086
|
+
// Screenshots arrive inline (the Host already resolved the attachments), so
|
|
1087
|
+
// they are decoded before layout to know their aspect ratio.
|
|
1088
|
+
const prepared = []
|
|
1089
|
+
for (const message of messages) {
|
|
1090
|
+
const images = []
|
|
1091
|
+
for (const image of message.images ?? []) {
|
|
1092
|
+
const element = await loadImage(image.dataUrl)
|
|
1093
|
+
if (element) images.push({ ...image, element })
|
|
1094
|
+
}
|
|
1095
|
+
prepared.push({ ...message, images })
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const roundRect = (ctx, x, y, w, h, r) => {
|
|
1099
|
+
ctx.beginPath()
|
|
1100
|
+
if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, w, h, r)
|
|
1101
|
+
else {
|
|
1102
|
+
const radius = Math.min(r, w / 2, h / 2)
|
|
1103
|
+
ctx.moveTo(x + radius, y)
|
|
1104
|
+
ctx.arcTo(x + w, y, x + w, y + h, radius)
|
|
1105
|
+
ctx.arcTo(x + w, y + h, x, y + h, radius)
|
|
1106
|
+
ctx.arcTo(x, y + h, x, y, radius)
|
|
1107
|
+
ctx.arcTo(x, y, x + w, y, radius)
|
|
1108
|
+
ctx.closePath()
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** Wrap inline runs into lines of styled segments. */
|
|
1113
|
+
const wrapRuns = (runs, maxWidth, baseFont, baseFill) => {
|
|
1114
|
+
const lines = []
|
|
1115
|
+
let segments = []
|
|
1116
|
+
let width = 0
|
|
1117
|
+
const append = segment => {
|
|
1118
|
+
const last = segments[segments.length - 1]
|
|
1119
|
+
if (last && last.font === segment.font && last.fill === segment.fill) last.text += segment.text
|
|
1120
|
+
else segments.push({ ...segment })
|
|
1121
|
+
}
|
|
1122
|
+
for (const run of runs ?? []) {
|
|
1123
|
+
const style = baseFont
|
|
1124
|
+
? { font: baseFont, fill: baseFill ?? palette.fg }
|
|
1125
|
+
: run.code
|
|
1126
|
+
? { font: mono(13), fill: palette.chipFg }
|
|
1127
|
+
: { font: font(15, run.bold ? '600' : undefined), fill: palette.fg }
|
|
1128
|
+
measure.font = style.font
|
|
1129
|
+
for (const char of String(run.text ?? '')) {
|
|
1130
|
+
const advance = measure.measureText(char).width
|
|
1131
|
+
if (width + advance > maxWidth && segments.length > 0) {
|
|
1132
|
+
lines.push({ segments, width })
|
|
1133
|
+
segments = []
|
|
1134
|
+
width = 0
|
|
1135
|
+
}
|
|
1136
|
+
append({ text: char, font: style.font, fill: style.fill })
|
|
1137
|
+
width += advance
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
lines.push({ segments, width })
|
|
1141
|
+
return lines
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** Wrap one plain string with a fixed style. */
|
|
1145
|
+
const wrapPlain = (text, textFont, fill, maxWidth) => {
|
|
1146
|
+
const lines = []
|
|
1147
|
+
let segments = []
|
|
1148
|
+
let width = 0
|
|
1149
|
+
measure.font = textFont
|
|
1150
|
+
for (const char of String(text)) {
|
|
1151
|
+
const advance = measure.measureText(char).width
|
|
1152
|
+
if (width + advance > maxWidth && segments.length > 0) {
|
|
1153
|
+
lines.push({ segments, width })
|
|
1154
|
+
segments = []
|
|
1155
|
+
width = 0
|
|
1156
|
+
}
|
|
1157
|
+
const last = segments[segments.length - 1]
|
|
1158
|
+
if (last) last.text += char
|
|
1159
|
+
else segments.push({ text: char, font: textFont, fill })
|
|
1160
|
+
width += advance
|
|
1161
|
+
}
|
|
1162
|
+
lines.push({ segments, width })
|
|
1163
|
+
return lines
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const groupSections = () => {
|
|
1167
|
+
const sections = []
|
|
1168
|
+
for (const message of prepared) {
|
|
1169
|
+
if (message.role === 'user') {
|
|
1170
|
+
sections.push({ role: 'user', blocks: message.blocks ?? [], images: message.images ?? [] })
|
|
1171
|
+
continue
|
|
1172
|
+
}
|
|
1173
|
+
const last = sections[sections.length - 1]
|
|
1174
|
+
if (last && last.role === 'assistant' && last.turn === message.turn) last.blocks.push(...(message.blocks ?? []))
|
|
1175
|
+
else sections.push({ role: 'assistant', turn: message.turn, blocks: [...(message.blocks ?? [])] })
|
|
1176
|
+
}
|
|
1177
|
+
return sections
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** Lay out one assistant card's Markdown blocks. */
|
|
1181
|
+
const layoutBlocks = blocks => {
|
|
1182
|
+
const ops = []
|
|
1183
|
+
let y = 0
|
|
1184
|
+
for (const block of blocks) {
|
|
1185
|
+
if (block.type === 'heading') {
|
|
1186
|
+
const size = block.level <= 2 ? 19 : block.level === 3 ? 17 : 15
|
|
1187
|
+
const lines = wrapRuns(block.runs, contentWidth, font(size, '600'), palette.fg)
|
|
1188
|
+
if (y > 0) y += 12
|
|
1189
|
+
for (const line of lines) {
|
|
1190
|
+
ops.push({ kind: 'line', x: 0, y: y + Math.round(size * 0.85) + 4, segments: line.segments })
|
|
1191
|
+
y += Math.round(size * 1.55)
|
|
1192
|
+
}
|
|
1193
|
+
y += 4
|
|
1194
|
+
continue
|
|
1195
|
+
}
|
|
1196
|
+
if (block.type === 'paragraph') {
|
|
1197
|
+
for (const line of wrapRuns(block.runs, contentWidth, font(15), palette.fg)) {
|
|
1198
|
+
ops.push({ kind: 'line', x: 0, y: y + 17, segments: line.segments })
|
|
1199
|
+
y += 24
|
|
1200
|
+
}
|
|
1201
|
+
y += 9
|
|
1202
|
+
continue
|
|
1203
|
+
}
|
|
1204
|
+
if (block.type === 'list') {
|
|
1205
|
+
for (const [index, item] of (block.items ?? []).entries()) {
|
|
1206
|
+
const indent = Math.min(3, item.indent ?? 0) * 18
|
|
1207
|
+
const lines = wrapRuns(item.runs, contentWidth - indent - 22, font(15), palette.fg)
|
|
1208
|
+
for (const [lineIndex, line] of lines.entries()) {
|
|
1209
|
+
if (lineIndex === 0) {
|
|
1210
|
+
ops.push({
|
|
1211
|
+
kind: 'text',
|
|
1212
|
+
x: indent,
|
|
1213
|
+
y: y + 17,
|
|
1214
|
+
text: block.ordered ? `${(block.start ?? 1) + index}.` : '•',
|
|
1215
|
+
font: font(15),
|
|
1216
|
+
fill: palette.muted,
|
|
1217
|
+
})
|
|
1218
|
+
}
|
|
1219
|
+
ops.push({ kind: 'line', x: indent + 22, y: y + 17, segments: line.segments })
|
|
1220
|
+
y += 24
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
y += 9
|
|
1224
|
+
continue
|
|
1225
|
+
}
|
|
1226
|
+
if (block.type === 'quote') {
|
|
1227
|
+
const lines = []
|
|
1228
|
+
for (const runs of block.lines ?? []) lines.push(...wrapRuns(runs, contentWidth - 34, font(15), palette.fg))
|
|
1229
|
+
const height = lines.length * 24 + 16
|
|
1230
|
+
ops.push({ kind: 'quote', y, height })
|
|
1231
|
+
lines.forEach((line, index) => {
|
|
1232
|
+
ops.push({ kind: 'line', x: 16, y: y + 8 + index * 24 + 17, segments: line.segments })
|
|
1233
|
+
})
|
|
1234
|
+
y += height + 10
|
|
1235
|
+
continue
|
|
1236
|
+
}
|
|
1237
|
+
if (block.type === 'code') {
|
|
1238
|
+
const lines = String(block.text ?? '')
|
|
1239
|
+
.split('\n')
|
|
1240
|
+
.flatMap(raw => wrapPlain(raw === '' ? ' ' : raw, mono(13), palette.codeFg, contentWidth - 26))
|
|
1241
|
+
const height = lines.length * 21 + 18
|
|
1242
|
+
ops.push({ kind: 'code', y, height })
|
|
1243
|
+
lines.forEach((line, index) => {
|
|
1244
|
+
ops.push({ kind: 'line', x: 13, y: y + 9 + index * 21 + 15, segments: line.segments })
|
|
1245
|
+
})
|
|
1246
|
+
y += height + 10
|
|
1247
|
+
continue
|
|
1248
|
+
}
|
|
1249
|
+
if (block.type === 'table') {
|
|
1250
|
+
const columnCount = Math.max(1, block.headers?.length ?? 0, ...(block.rows ?? []).map(row => row.length))
|
|
1251
|
+
const plainOf = runs => (runs ?? []).map(run => run.text ?? '').join('')
|
|
1252
|
+
const natural = new Array(columnCount).fill(52)
|
|
1253
|
+
const bump = (runs, column, bold) => {
|
|
1254
|
+
measure.font = font(13, bold ? '600' : undefined)
|
|
1255
|
+
natural[column] = Math.max(natural[column], measure.measureText(plainOf(runs)).width + 20)
|
|
1256
|
+
}
|
|
1257
|
+
;(block.headers ?? []).forEach((runs, column) => bump(runs, column, true))
|
|
1258
|
+
for (const row of block.rows ?? []) row.forEach((runs, column) => bump(runs, column, false))
|
|
1259
|
+
const naturalTotal = natural.reduce((sum, value) => sum + value, 0)
|
|
1260
|
+
const factor = naturalTotal > contentWidth ? contentWidth / naturalTotal : 1
|
|
1261
|
+
const widths = natural.map(value => Math.round(value * factor))
|
|
1262
|
+
const rows = [
|
|
1263
|
+
{ header: true, cells: block.headers ?? [] },
|
|
1264
|
+
...(block.rows ?? []).map(row => ({ header: false, cells: row })),
|
|
1265
|
+
]
|
|
1266
|
+
const laid = rows.map(row => ({
|
|
1267
|
+
header: row.header,
|
|
1268
|
+
cells: row.cells.map((runs, column) =>
|
|
1269
|
+
wrapRuns(runs, Math.max(24, widths[column] - 16), font(13, row.header ? '600' : undefined), palette.fg),
|
|
1270
|
+
),
|
|
1271
|
+
}))
|
|
1272
|
+
const rowHeights = laid.map(row => Math.max(...row.cells.map(lines => lines.length)) * 21 + 14)
|
|
1273
|
+
const height = rowHeights.reduce((sum, value) => sum + value, 0)
|
|
1274
|
+
ops.push({ kind: 'table', y, height, widths, rows: laid, rowHeights })
|
|
1275
|
+
y += height + 10
|
|
1276
|
+
continue
|
|
1277
|
+
}
|
|
1278
|
+
if (block.type === 'hr') {
|
|
1279
|
+
ops.push({ kind: 'hr', y })
|
|
1280
|
+
y += 16
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return { ops, height: Math.max(0, y - 9) }
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const sections = groupSections().map(section => {
|
|
1287
|
+
if (section.role === 'user') {
|
|
1288
|
+
const lines = []
|
|
1289
|
+
for (const block of section.blocks) {
|
|
1290
|
+
if (block.type === 'code') {
|
|
1291
|
+
lines.push(...wrapPlain(block.text ?? '', mono(13), palette.userFg, bubbleMax - 30))
|
|
1292
|
+
continue
|
|
1293
|
+
}
|
|
1294
|
+
if (block.type === 'list') {
|
|
1295
|
+
for (const item of block.items ?? []) {
|
|
1296
|
+
const prefix = block.ordered ? '· ' : '· '
|
|
1297
|
+
lines.push(...wrapRuns([{ text: prefix }, ...(item.runs ?? [])], bubbleMax - 30, font(15), palette.userFg))
|
|
1298
|
+
}
|
|
1299
|
+
continue
|
|
1300
|
+
}
|
|
1301
|
+
lines.push(...wrapRuns(block.runs ?? [], bubbleMax - 30, font(15), palette.userFg))
|
|
1302
|
+
}
|
|
1303
|
+
const width = Math.min(bubbleMax, Math.max(80, ...lines.map(line => line.width)) + 30)
|
|
1304
|
+
const height = lines.length * 24 + 20
|
|
1305
|
+
// Screenshots sit above the prompt, right-aligned like the bubble. They
|
|
1306
|
+
// take the full content width — a screenshot is usually the point of the
|
|
1307
|
+
// message, and a narrow slot is what made them look downscaled. Aspect
|
|
1308
|
+
// ratio is preserved; a tall one is capped by height instead.
|
|
1309
|
+
const images = []
|
|
1310
|
+
for (const image of section.images ?? []) {
|
|
1311
|
+
if (!image.element) continue
|
|
1312
|
+
const ratio = image.element.height / image.element.width || 1
|
|
1313
|
+
let width = Math.min(contentWidth, image.element.width)
|
|
1314
|
+
let height = width * ratio
|
|
1315
|
+
const maxHeight = 900
|
|
1316
|
+
if (height > maxHeight) {
|
|
1317
|
+
height = maxHeight
|
|
1318
|
+
width = Math.round(height / ratio)
|
|
1319
|
+
}
|
|
1320
|
+
images.push({ element: image.element, width: Math.round(width), height: Math.round(height) })
|
|
1321
|
+
}
|
|
1322
|
+
return { kind: 'user', lines, width, height, images }
|
|
1323
|
+
}
|
|
1324
|
+
const layout = layoutBlocks(section.blocks)
|
|
1325
|
+
return { kind: 'assistant', turn: section.turn, ops: layout.ops, height: layout.height + CARD_PAD * 2 }
|
|
1326
|
+
})
|
|
1327
|
+
|
|
1328
|
+
for (const section of sections) {
|
|
1329
|
+
if (section.kind === 'user' && section.images.length > 0) {
|
|
1330
|
+
section.imagesHeight = section.images.reduce((sum, image) => sum + image.height + 10, 0)
|
|
1331
|
+
section.height += section.imagesHeight
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const headerHeight = 118
|
|
1336
|
+
const footerHeight = 52
|
|
1337
|
+
const gap = 18
|
|
1338
|
+
const bodyHeight = sections.reduce((sum, section) => sum + section.height + gap, 0)
|
|
1339
|
+
const height = headerHeight + bodyHeight + footerHeight
|
|
1340
|
+
|
|
1341
|
+
const canvas = document.createElement('canvas')
|
|
1342
|
+
canvas.width = WIDTH * SCALE
|
|
1343
|
+
canvas.height = Math.ceil(height * SCALE)
|
|
1344
|
+
const ctx = canvas.getContext('2d')
|
|
1345
|
+
ctx.scale(SCALE, SCALE)
|
|
1346
|
+
ctx.textBaseline = 'alphabetic'
|
|
1347
|
+
|
|
1348
|
+
ctx.fillStyle = palette.page
|
|
1349
|
+
ctx.fillRect(0, 0, WIDTH, height)
|
|
1350
|
+
|
|
1351
|
+
ctx.fillStyle = palette.fg
|
|
1352
|
+
const titleLines = wrapPlain(title || 'Session', font(26, '600'), palette.fg, cardWidth).slice(0, 2)
|
|
1353
|
+
let headerY = PAGE_PAD + 24
|
|
1354
|
+
for (const line of titleLines) {
|
|
1355
|
+
ctx.font = font(26, '600')
|
|
1356
|
+
ctx.fillText(line.segments.map(segment => segment.text).join(''), PAGE_PAD, headerY)
|
|
1357
|
+
headerY += 34
|
|
1358
|
+
}
|
|
1359
|
+
ctx.fillStyle = palette.muted
|
|
1360
|
+
ctx.font = font(12)
|
|
1361
|
+
ctx.fillText(meta, PAGE_PAD, headerY + 4)
|
|
1362
|
+
|
|
1363
|
+
let y = headerHeight
|
|
1364
|
+
for (const section of sections) {
|
|
1365
|
+
if (section.kind === 'user') {
|
|
1366
|
+
let cursorY = y
|
|
1367
|
+
for (const image of section.images ?? []) {
|
|
1368
|
+
const imageX = WIDTH - PAGE_PAD - image.width
|
|
1369
|
+
ctx.save()
|
|
1370
|
+
roundRect(ctx, imageX, cursorY, image.width, image.height, 12)
|
|
1371
|
+
ctx.clip()
|
|
1372
|
+
ctx.drawImage(image.element, imageX, cursorY, image.width, image.height)
|
|
1373
|
+
ctx.restore()
|
|
1374
|
+
ctx.strokeStyle = palette.line
|
|
1375
|
+
roundRect(ctx, imageX, cursorY, image.width, image.height, 12)
|
|
1376
|
+
ctx.stroke()
|
|
1377
|
+
cursorY += image.height + 10
|
|
1378
|
+
}
|
|
1379
|
+
const bubbleTop = y + (section.imagesHeight ?? 0)
|
|
1380
|
+
const bubbleHeight = section.height - (section.imagesHeight ?? 0)
|
|
1381
|
+
const x = WIDTH - PAGE_PAD - section.width
|
|
1382
|
+
ctx.fillStyle = palette.user
|
|
1383
|
+
roundRect(ctx, x, bubbleTop, section.width, bubbleHeight, 14)
|
|
1384
|
+
ctx.fill()
|
|
1385
|
+
section.lines.forEach((line, index) => {
|
|
1386
|
+
let cursor = x + 15
|
|
1387
|
+
const baseline = bubbleTop + 10 + index * 24 + 17
|
|
1388
|
+
for (const segment of line.segments) {
|
|
1389
|
+
ctx.font = segment.font
|
|
1390
|
+
ctx.fillStyle = segment.fill
|
|
1391
|
+
ctx.fillText(segment.text, cursor, baseline)
|
|
1392
|
+
measure.font = segment.font
|
|
1393
|
+
cursor += measure.measureText(segment.text).width
|
|
1394
|
+
}
|
|
1395
|
+
})
|
|
1396
|
+
} else {
|
|
1397
|
+
ctx.save()
|
|
1398
|
+
ctx.shadowColor = 'rgba(16, 18, 20, 0.06)'
|
|
1399
|
+
ctx.shadowBlur = 10
|
|
1400
|
+
ctx.shadowOffsetY = 2
|
|
1401
|
+
ctx.fillStyle = palette.card
|
|
1402
|
+
roundRect(ctx, PAGE_PAD, y, cardWidth, section.height, 14)
|
|
1403
|
+
ctx.fill()
|
|
1404
|
+
ctx.restore()
|
|
1405
|
+
|
|
1406
|
+
const originX = PAGE_PAD + CARD_PAD
|
|
1407
|
+
const originY = y + CARD_PAD
|
|
1408
|
+
for (const op of section.ops) {
|
|
1409
|
+
if (op.kind === 'quote') {
|
|
1410
|
+
ctx.fillStyle = palette.quote
|
|
1411
|
+
roundRect(ctx, originX, originY + op.y, contentWidth, op.height, 10)
|
|
1412
|
+
ctx.fill()
|
|
1413
|
+
ctx.fillStyle = palette.accent
|
|
1414
|
+
ctx.fillRect(originX, originY + op.y, 3, op.height)
|
|
1415
|
+
} else if (op.kind === 'code') {
|
|
1416
|
+
ctx.fillStyle = palette.code
|
|
1417
|
+
roundRect(ctx, originX, originY + op.y, contentWidth, op.height, 10)
|
|
1418
|
+
ctx.fill()
|
|
1419
|
+
} else if (op.kind === 'table') {
|
|
1420
|
+
const tableWidth = op.widths.reduce((sum, value) => sum + value, 0)
|
|
1421
|
+
let rowTop = originY + op.y
|
|
1422
|
+
op.rows.forEach((row, rowIndex) => {
|
|
1423
|
+
const rowHeight = op.rowHeights[rowIndex]
|
|
1424
|
+
if (row.header) {
|
|
1425
|
+
ctx.fillStyle = palette.quote
|
|
1426
|
+
ctx.fillRect(originX, rowTop, tableWidth, rowHeight)
|
|
1427
|
+
}
|
|
1428
|
+
let cellX = originX
|
|
1429
|
+
row.cells.forEach((lines, column) => {
|
|
1430
|
+
lines.forEach((line, lineIndex) => {
|
|
1431
|
+
let cursor = cellX + 8
|
|
1432
|
+
const baseline = rowTop + 7 + lineIndex * 21 + 15
|
|
1433
|
+
for (const segment of line.segments) {
|
|
1434
|
+
ctx.font = segment.font
|
|
1435
|
+
ctx.fillStyle = segment.fill
|
|
1436
|
+
ctx.fillText(segment.text, cursor, baseline)
|
|
1437
|
+
measure.font = segment.font
|
|
1438
|
+
cursor += measure.measureText(segment.text).width
|
|
1439
|
+
}
|
|
1440
|
+
})
|
|
1441
|
+
cellX += op.widths[column]
|
|
1442
|
+
})
|
|
1443
|
+
ctx.strokeStyle = palette.line
|
|
1444
|
+
ctx.beginPath()
|
|
1445
|
+
ctx.moveTo(originX, rowTop + rowHeight)
|
|
1446
|
+
ctx.lineTo(originX + tableWidth, rowTop + rowHeight)
|
|
1447
|
+
ctx.stroke()
|
|
1448
|
+
rowTop += rowHeight
|
|
1449
|
+
})
|
|
1450
|
+
} else if (op.kind === 'hr') {
|
|
1451
|
+
ctx.strokeStyle = palette.line
|
|
1452
|
+
ctx.beginPath()
|
|
1453
|
+
ctx.moveTo(originX, originY + op.y)
|
|
1454
|
+
ctx.lineTo(originX + contentWidth, originY + op.y)
|
|
1455
|
+
ctx.stroke()
|
|
1456
|
+
} else if (op.kind === 'text') {
|
|
1457
|
+
ctx.font = op.font
|
|
1458
|
+
ctx.fillStyle = op.fill
|
|
1459
|
+
ctx.fillText(op.text, originX + op.x, originY + op.y)
|
|
1460
|
+
} else {
|
|
1461
|
+
let cursor = originX + op.x
|
|
1462
|
+
for (const segment of op.segments) {
|
|
1463
|
+
ctx.font = segment.font
|
|
1464
|
+
ctx.fillStyle = segment.fill
|
|
1465
|
+
ctx.fillText(segment.text, cursor, originY + op.y)
|
|
1466
|
+
measure.font = segment.font
|
|
1467
|
+
cursor += measure.measureText(segment.text).width
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
y += section.height + gap
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
ctx.fillStyle = palette.muted
|
|
1476
|
+
ctx.font = font(12)
|
|
1477
|
+
ctx.textAlign = 'center'
|
|
1478
|
+
ctx.fillText(footer, WIDTH / 2, height - 22)
|
|
1479
|
+
ctx.textAlign = 'left'
|
|
1480
|
+
|
|
1481
|
+
return await new Promise((resolve, reject) => {
|
|
1482
|
+
canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('canvas could not produce a PNG'))), 'image/png')
|
|
1483
|
+
})
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function ShareDialog({ state, onClose }) {
|
|
1487
|
+
const t = state.t ?? (key => key)
|
|
1488
|
+
const { loading, outline, error } = useOutline(state.sessionId)
|
|
1489
|
+
const { selected, toggle, all, none } = useTurnSelection(outline)
|
|
1490
|
+
const [busy, setBusy] = useState(null)
|
|
1491
|
+
const [note, setNote] = useState(null)
|
|
1492
|
+
const [finalOnly, setFinalOnly] = useState(true)
|
|
1493
|
+
|
|
1494
|
+
const ranges = rangesOf(outline, selected)
|
|
1495
|
+
const ready = ranges.length > 0
|
|
1496
|
+
const mode = finalOnly ? 'final' : 'all'
|
|
1497
|
+
|
|
1498
|
+
const run = async (kind, operation) => {
|
|
1499
|
+
if (!ready) {
|
|
1500
|
+
setNote(t('share.needSelection'))
|
|
1501
|
+
return
|
|
1502
|
+
}
|
|
1503
|
+
setBusy(kind)
|
|
1504
|
+
setNote(null)
|
|
1505
|
+
try {
|
|
1506
|
+
await operation()
|
|
1507
|
+
} catch (failure) {
|
|
1508
|
+
setNote(failure?.message ?? t('share.failed'))
|
|
1509
|
+
} finally {
|
|
1510
|
+
setBusy(null)
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const exportFile = format =>
|
|
1515
|
+
run(format, async () => {
|
|
1516
|
+
const result = await call({ action: 'export', sessionId: state.sessionId, format, ranges, mode })
|
|
1517
|
+
saveBlob(new Blob([result.body], { type: result.contentType }), result.fileName)
|
|
1518
|
+
setNote(t('share.done'))
|
|
1519
|
+
})
|
|
1520
|
+
|
|
1521
|
+
const copyText = () =>
|
|
1522
|
+
run('txt', async () => {
|
|
1523
|
+
const result = await call({ action: 'export', sessionId: state.sessionId, format: 'txt', ranges, mode })
|
|
1524
|
+
await navigator.clipboard.writeText(result.body)
|
|
1525
|
+
setNote(t('share.copied'))
|
|
1526
|
+
})
|
|
1527
|
+
|
|
1528
|
+
const exportImage = () =>
|
|
1529
|
+
run('image', async () => {
|
|
1530
|
+
const result = await call({ action: 'export', sessionId: state.sessionId, format: 'blocks', ranges, mode })
|
|
1531
|
+
const payload = JSON.parse(result.body)
|
|
1532
|
+
const turns = new Set(payload.messages.map(message => message.turn).filter(turn => typeof turn === 'number')).size
|
|
1533
|
+
const exported = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
|
1534
|
+
const blob = await renderConversationImage({
|
|
1535
|
+
title: payload.title || outline?.title || 'Session',
|
|
1536
|
+
meta: `${t('share.metaTurns', { turns, messages: payload.messages.length })} · ${t('share.exportedAt', { time: exported })}`,
|
|
1537
|
+
footer: t('share.imageFooter'),
|
|
1538
|
+
messages: payload.messages,
|
|
1539
|
+
})
|
|
1540
|
+
saveBlob(blob, `${(payload.title || 'session').replace(/[\\/:*?"<>|\s]+/g, '-').slice(0, 40)}.png`)
|
|
1541
|
+
setNote(t('share.done'))
|
|
1542
|
+
})
|
|
1543
|
+
|
|
1544
|
+
return h(
|
|
1545
|
+
DialogFrame,
|
|
1546
|
+
{ title: t('share.title'), wide: true, onClose: busy ? () => {} : onClose },
|
|
1547
|
+
h('div', { className: 'dss-dialog-body' }, t('share.hint')),
|
|
1548
|
+
loading
|
|
1549
|
+
? h('div', { className: 'dss-empty' }, t('settings.loading'))
|
|
1550
|
+
: error
|
|
1551
|
+
? h('div', { className: 'dss-dialog-error' }, error)
|
|
1552
|
+
: h(
|
|
1553
|
+
React.Fragment,
|
|
1554
|
+
null,
|
|
1555
|
+
h(SelectionToolbar, { t, outline, selected, onAll: all, onNone: none }),
|
|
1556
|
+
h('div', { className: 'dss-dialog-scroll dss-picker-scroll' }, h(TurnPicker, { outline, selected, onToggle: toggle, t })),
|
|
1557
|
+
h(
|
|
1558
|
+
'label',
|
|
1559
|
+
{ className: 'dss-curate' },
|
|
1560
|
+
h('input', {
|
|
1561
|
+
type: 'checkbox',
|
|
1562
|
+
checked: finalOnly,
|
|
1563
|
+
onChange: event => setFinalOnly(event.target.checked),
|
|
1564
|
+
}),
|
|
1565
|
+
h(
|
|
1566
|
+
'span',
|
|
1567
|
+
{ className: 'dss-curate-text' },
|
|
1568
|
+
h('span', { className: 'dss-curate-title' }, t('share.curate')),
|
|
1569
|
+
h('span', { className: 'dss-curate-hint' }, t('share.curateHint')),
|
|
1570
|
+
),
|
|
1571
|
+
),
|
|
1572
|
+
note ? h('div', { className: 'dss-note' }, note) : null,
|
|
1573
|
+
h(
|
|
1574
|
+
'div',
|
|
1575
|
+
{ className: 'dss-dialog-actions dss-wrap' },
|
|
1576
|
+
h(
|
|
1577
|
+
'button',
|
|
1578
|
+
{ type: 'button', className: 'dss-btn dss-primary', disabled: !ready || busy !== null, onClick: exportImage },
|
|
1579
|
+
busy === 'image' ? h('span', { className: 'dss-spin' }) : null,
|
|
1580
|
+
t('share.image'),
|
|
1581
|
+
),
|
|
1582
|
+
h(
|
|
1583
|
+
'button',
|
|
1584
|
+
{ type: 'button', className: 'dss-btn', disabled: !ready || busy !== null, onClick: () => exportFile('html') },
|
|
1585
|
+
t('share.html'),
|
|
1586
|
+
),
|
|
1587
|
+
h(
|
|
1588
|
+
'button',
|
|
1589
|
+
{ type: 'button', className: 'dss-btn', disabled: !ready || busy !== null, onClick: () => exportFile('md') },
|
|
1590
|
+
t('share.markdown'),
|
|
1591
|
+
),
|
|
1592
|
+
h(
|
|
1593
|
+
'button',
|
|
1594
|
+
{ type: 'button', className: 'dss-btn', disabled: !ready || busy !== null, onClick: () => exportFile('json') },
|
|
1595
|
+
t('share.json'),
|
|
1596
|
+
),
|
|
1597
|
+
h(
|
|
1598
|
+
'button',
|
|
1599
|
+
{ type: 'button', className: 'dss-btn dss-ghost', disabled: !ready || busy !== null, onClick: copyText },
|
|
1600
|
+
t('share.copy'),
|
|
1601
|
+
),
|
|
1602
|
+
),
|
|
1603
|
+
h('div', { className: 'dss-hint' }, t('share.linkNote')),
|
|
1604
|
+
),
|
|
1605
|
+
)
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function SegmentDialog({ state, onClose }) {
|
|
1609
|
+
const t = state.t ?? (key => key)
|
|
1610
|
+
const { loading, outline, error } = useOutline(state.sessionId)
|
|
1611
|
+
const { selected, toggle, all, none } = useTurnSelection(outline)
|
|
1612
|
+
const [busy, setBusy] = useState(false)
|
|
1613
|
+
const [problem, setProblem] = useState(null)
|
|
1614
|
+
|
|
1615
|
+
const turns = outline?.turns ?? []
|
|
1616
|
+
const picked = turns.filter(row => selected.has(row.turn))
|
|
1617
|
+
const first = picked[0]
|
|
1618
|
+
const lastTurn = turns.length > 0 ? turns[turns.length - 1].turn : undefined
|
|
1619
|
+
const partial = picked.length > 0 && picked.length < turns.length
|
|
1620
|
+
|
|
1621
|
+
const confirm = async () => {
|
|
1622
|
+
if (!first) {
|
|
1623
|
+
setProblem(t('segment.needSelection'))
|
|
1624
|
+
return
|
|
1625
|
+
}
|
|
1626
|
+
setBusy(true)
|
|
1627
|
+
setProblem(null)
|
|
1628
|
+
try {
|
|
1629
|
+
const result = await call({ action: 'deleteSegment', sessionId: state.sessionId, startSeq: first.startSeq })
|
|
1630
|
+
if (result?.childId) runtime.openSession?.(result.childId)
|
|
1631
|
+
notify(t('segment.done'))
|
|
1632
|
+
onClose()
|
|
1633
|
+
bump()
|
|
1634
|
+
} catch (failure) {
|
|
1635
|
+
setProblem(failure?.message ?? t('segment.failed'))
|
|
1636
|
+
} finally {
|
|
1637
|
+
setBusy(false)
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
return h(
|
|
1642
|
+
DialogFrame,
|
|
1643
|
+
{ title: t('segment.title'), critical: true, wide: true, onClose: busy ? () => {} : onClose },
|
|
1644
|
+
h('div', { className: 'dss-dialog-body' }, t('segment.hint')),
|
|
1645
|
+
loading
|
|
1646
|
+
? h('div', { className: 'dss-empty' }, t('settings.loading'))
|
|
1647
|
+
: error
|
|
1648
|
+
? h('div', { className: 'dss-dialog-error' }, error)
|
|
1649
|
+
: h(
|
|
1650
|
+
React.Fragment,
|
|
1651
|
+
null,
|
|
1652
|
+
h(SelectionToolbar, { t, outline, selected, onAll: all, onNone: none }),
|
|
1653
|
+
h('div', { className: 'dss-dialog-scroll dss-picker-scroll' }, h(TurnPicker, { outline, selected, onToggle: toggle, t })),
|
|
1654
|
+
partial ? h('div', { className: 'dss-warn' }, t('segment.warning')) : null,
|
|
1655
|
+
picked.length === turns.length && turns.length > 0 ? h('div', { className: 'dss-warn' }, t('segment.whole')) : null,
|
|
1656
|
+
problem ? h('div', { className: 'dss-dialog-error' }, problem) : null,
|
|
1657
|
+
h(
|
|
1658
|
+
'div',
|
|
1659
|
+
{ className: 'dss-dialog-actions' },
|
|
1660
|
+
h('button', { type: 'button', className: 'dss-btn', onClick: onClose, disabled: busy }, t('dialog.cancel')),
|
|
1661
|
+
h(
|
|
1662
|
+
'button',
|
|
1663
|
+
{ type: 'button', className: 'dss-btn dss-critical', onClick: confirm, disabled: busy || !first },
|
|
1664
|
+
busy ? h('span', { className: 'dss-spin' }) : null,
|
|
1665
|
+
busy ? t('dialog.deleting') : t('segment.confirm'),
|
|
1666
|
+
),
|
|
1667
|
+
),
|
|
1668
|
+
),
|
|
1669
|
+
)
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* The snapshot manager as a dialog, so the same panel is reachable straight
|
|
1674
|
+
* from a session's menu without opening Settings first.
|
|
1675
|
+
*/
|
|
1676
|
+
function SnapshotDialog({ state, onClose }) {
|
|
1677
|
+
const t = state.t ?? (key => key)
|
|
1678
|
+
return h(
|
|
1679
|
+
DialogFrame,
|
|
1680
|
+
{ title: t('settings.title'), wide: true, onClose },
|
|
1681
|
+
h('div', { className: 'dss-dialog-body' }, t('settings.description')),
|
|
1682
|
+
h(
|
|
1683
|
+
'div',
|
|
1684
|
+
{ className: 'dss-dialog-scroll' },
|
|
1685
|
+
h(SnapshotSection, { t, embedded: true, initialSessionId: state.sessionId }),
|
|
1686
|
+
),
|
|
1687
|
+
)
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function SuiteOverlay() {
|
|
1691
|
+
const state = useStore(dialog)
|
|
1692
|
+
const message = useStore(toast)
|
|
1693
|
+
return h(
|
|
1694
|
+
React.Fragment,
|
|
1695
|
+
null,
|
|
1696
|
+
state
|
|
1697
|
+
? state.mode === 'snapshots'
|
|
1698
|
+
? h(SnapshotDialog, { key: state.key ?? 'snapshots', state, onClose: () => dialog.set(null) })
|
|
1699
|
+
: state.mode === 'share'
|
|
1700
|
+
? h(ShareDialog, { key: state.key ?? 'share', state, onClose: () => dialog.set(null) })
|
|
1701
|
+
: state.mode === 'segment'
|
|
1702
|
+
? h(SegmentDialog, { key: state.key ?? 'segment', state, onClose: () => dialog.set(null) })
|
|
1703
|
+
: state.mode === 'edit-resend'
|
|
1704
|
+
? h(EditResendDialog, { key: state.key ?? 'edit-resend', state, onClose: () => dialog.set(null) })
|
|
1705
|
+
: h(SelectSessionDialog, { key: state.key ?? 'dialog', state, onClose: () => dialog.set(null) })
|
|
1706
|
+
: null,
|
|
1707
|
+
message
|
|
1708
|
+
? h(
|
|
1709
|
+
'div',
|
|
1710
|
+
{ key: message.id, className: 'dss-toast' + (message.tone === 'error' ? ' dss-error' : ''), role: 'status' },
|
|
1711
|
+
message.message,
|
|
1712
|
+
)
|
|
1713
|
+
: null,
|
|
1714
|
+
)
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// ---------------------------------------------------------- session menu
|
|
1718
|
+
|
|
1719
|
+
function useSessionMenuRows(props, t) {
|
|
1720
|
+
const { sessionId, useMenuOpenState } = props
|
|
1721
|
+
const [, setMenuOpen] = useMenuOpenState()
|
|
1722
|
+
return { sessionId, setMenuOpen, t }
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function ShareRow(props) {
|
|
1726
|
+
const { setMenuOpen, sessionId, t } = useSessionMenuRows(props, props.t)
|
|
1727
|
+
return h(MenuRow, {
|
|
1728
|
+
label: t('menu.share'),
|
|
1729
|
+
iconNode: h(Icons.download, null),
|
|
1730
|
+
separator: true,
|
|
1731
|
+
onSelect: () => {
|
|
1732
|
+
setMenuOpen(false)
|
|
1733
|
+
dialog.set({ key: `${sessionId}:share`, mode: 'share', sessionId, t })
|
|
1734
|
+
},
|
|
1735
|
+
})
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function SnapshotsRow(props) {
|
|
1739
|
+
const { setMenuOpen, sessionId, t } = useSessionMenuRows(props, props.t)
|
|
1740
|
+
return h(MenuRow, {
|
|
1741
|
+
label: t('menu.snapshots'),
|
|
1742
|
+
iconNode: h(Icons.rewind, null),
|
|
1743
|
+
separator: true,
|
|
1744
|
+
onSelect: () => {
|
|
1745
|
+
setMenuOpen(false)
|
|
1746
|
+
dialog.set({ key: `${sessionId}:snapshots`, mode: 'snapshots', sessionId, t })
|
|
1747
|
+
},
|
|
1748
|
+
})
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function SegmentRow(props) {
|
|
1752
|
+
const { setMenuOpen, sessionId, t } = useSessionMenuRows(props, props.t)
|
|
1753
|
+
return h(MenuRow, {
|
|
1754
|
+
label: t('menu.segment'),
|
|
1755
|
+
iconNode: h(Icons.cut, null),
|
|
1756
|
+
danger: true,
|
|
1757
|
+
separator: true,
|
|
1758
|
+
onSelect: () => {
|
|
1759
|
+
setMenuOpen(false)
|
|
1760
|
+
dialog.set({ key: `${sessionId}:segment`, mode: 'segment', sessionId, t })
|
|
1761
|
+
},
|
|
1762
|
+
})
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function DeleteSessionRow(props) {
|
|
1766
|
+
const { setMenuOpen, sessionId, t } = useSessionMenuRows(props, props.t)
|
|
1767
|
+
return h(MenuRow, {
|
|
1768
|
+
label: t('menu.delete'),
|
|
1769
|
+
iconNode: h(Icons.trash, null),
|
|
1770
|
+
danger: true,
|
|
1771
|
+
separator: true,
|
|
1772
|
+
onSelect: () => {
|
|
1773
|
+
setMenuOpen(false)
|
|
1774
|
+
dialog.set({ key: `${sessionId}:delete`, mode: 'delete-session', sessionId, displayTitle: props.displayTitle, t })
|
|
1775
|
+
},
|
|
1776
|
+
})
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// ------------------------------------------------------- message actions
|
|
1780
|
+
|
|
1781
|
+
function RewindAction(props) {
|
|
1782
|
+
const { messageId, sessionId } = props
|
|
1783
|
+
const t = props.t
|
|
1784
|
+
const start = useCallback(
|
|
1785
|
+
mode => {
|
|
1786
|
+
pending.set({ key: `${sessionId}:${messageId}:${mode}`, mode, sessionId, messageId, t })
|
|
1787
|
+
},
|
|
1788
|
+
[messageId, sessionId, t],
|
|
1789
|
+
)
|
|
1790
|
+
return h(
|
|
1791
|
+
React.Fragment,
|
|
1792
|
+
null,
|
|
1793
|
+
h(
|
|
1794
|
+
'button',
|
|
1795
|
+
{
|
|
1796
|
+
type: 'button',
|
|
1797
|
+
className: 'dss-action',
|
|
1798
|
+
title: t('message.rewind'),
|
|
1799
|
+
'aria-label': t('message.rewind'),
|
|
1800
|
+
onClick: () => start('rewind'),
|
|
1801
|
+
},
|
|
1802
|
+
h(Icons.rewind, null),
|
|
1803
|
+
),
|
|
1804
|
+
// Edit-and-resend sits in the same action row as rewind and delete. A
|
|
1805
|
+
// separate turnTail row put it on its own line; the user asked for it
|
|
1806
|
+
// alongside the other per-message icons.
|
|
1807
|
+
h(
|
|
1808
|
+
'button',
|
|
1809
|
+
{
|
|
1810
|
+
type: 'button',
|
|
1811
|
+
className: 'dss-action',
|
|
1812
|
+
title: t('message.edit'),
|
|
1813
|
+
'aria-label': t('message.edit'),
|
|
1814
|
+
onClick: () => {
|
|
1815
|
+
dialog.set({
|
|
1816
|
+
key: `${sessionId}:edit:${messageId}`,
|
|
1817
|
+
mode: 'edit-resend',
|
|
1818
|
+
sessionId,
|
|
1819
|
+
messageId,
|
|
1820
|
+
t,
|
|
1821
|
+
})
|
|
1822
|
+
},
|
|
1823
|
+
},
|
|
1824
|
+
h(Icons.edit, null),
|
|
1825
|
+
),
|
|
1826
|
+
h(
|
|
1827
|
+
'button',
|
|
1828
|
+
{
|
|
1829
|
+
type: 'button',
|
|
1830
|
+
className: 'dss-action dss-danger',
|
|
1831
|
+
title: t('message.delete'),
|
|
1832
|
+
'aria-label': t('message.delete'),
|
|
1833
|
+
onClick: () => start('delete'),
|
|
1834
|
+
},
|
|
1835
|
+
h(Icons.trash, null),
|
|
1836
|
+
),
|
|
1837
|
+
)
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// ------------------------------------------------------- user message edit
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* Edit-and-resend, opened from the per-message action row.
|
|
1844
|
+
*
|
|
1845
|
+
* The pencil sits beside rewind and delete in `RewindAction`, which is
|
|
1846
|
+
* registered on `conversation.chat.assistant-actions` — the same row the
|
|
1847
|
+
* user already sees on each answer. We deliberately do NOT replace the
|
|
1848
|
+
* `user` key of `conversation.chat.node`: that slot is occupied by the
|
|
1849
|
+
* shipped bubble renderer (replaceRisk: shadows-shipped-ui), so a plugin bug
|
|
1850
|
+
* there would break every user message in the transcript.
|
|
1851
|
+
*
|
|
1852
|
+
* The dialog edits the text, then the Host trims the memories that turn
|
|
1853
|
+
* recorded and sends the edited text in the same session. Workspace files
|
|
1854
|
+
* are never touched: this is a conversation operation, and restoring files
|
|
1855
|
+
* would discard work done since.
|
|
1856
|
+
* this is a conversation operation, and rolling files back would discard work
|
|
1857
|
+
* done since.
|
|
1858
|
+
*/
|
|
1859
|
+
function EditResendDialog({ state, onClose }) {
|
|
1860
|
+
const t = state.t ?? (key => key)
|
|
1861
|
+
const [busy, setBusy] = useState(false)
|
|
1862
|
+
const [error, setError] = useState(null)
|
|
1863
|
+
const [text, setText] = useState(state.originalPrompt ?? '')
|
|
1864
|
+
|
|
1865
|
+
useEffect(() => {
|
|
1866
|
+
if (!state.sessionId) return undefined
|
|
1867
|
+
let alive = true
|
|
1868
|
+
// Pure read: opening the dialog must not fork a session. The old
|
|
1869
|
+
// `mode: 'stage'` call did fork, so merely looking at the dialog created
|
|
1870
|
+
// a branch and could fail before the user had changed anything.
|
|
1871
|
+
call({
|
|
1872
|
+
action: 'resendTurn.preview',
|
|
1873
|
+
sessionId: state.sessionId,
|
|
1874
|
+
...(state.messageId ? { messageId: state.messageId } : {}),
|
|
1875
|
+
...(typeof state.turn === 'number' ? { turn: state.turn } : {}),
|
|
1876
|
+
})
|
|
1877
|
+
.then(data => {
|
|
1878
|
+
if (!alive) return
|
|
1879
|
+
if (typeof data?.prompt === 'string' && data.prompt) setText(data.prompt)
|
|
1880
|
+
})
|
|
1881
|
+
.catch(failure => alive && setError(failure?.message ?? t('message.resendFailed')))
|
|
1882
|
+
return () => {
|
|
1883
|
+
alive = false
|
|
1884
|
+
}
|
|
1885
|
+
}, [state.sessionId, state.messageId, state.turn])
|
|
1886
|
+
|
|
1887
|
+
const confirm = async () => {
|
|
1888
|
+
setBusy(true)
|
|
1889
|
+
setError(null)
|
|
1890
|
+
try {
|
|
1891
|
+
const result = await call({
|
|
1892
|
+
action: 'resendTurn',
|
|
1893
|
+
sessionId: state.sessionId,
|
|
1894
|
+
messageId: state.messageId,
|
|
1895
|
+
mode: 'retry',
|
|
1896
|
+
text,
|
|
1897
|
+
})
|
|
1898
|
+
// Resend stays in the current session; there is no child to open.
|
|
1899
|
+
notify(t('message.resendDone'))
|
|
1900
|
+
onClose()
|
|
1901
|
+
bump()
|
|
1902
|
+
} catch (failure) {
|
|
1903
|
+
setError(failure?.message ?? t('message.resendFailed'))
|
|
1904
|
+
} finally {
|
|
1905
|
+
setBusy(false)
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
return h(
|
|
1910
|
+
DialogFrame,
|
|
1911
|
+
{ title: t('message.editTitle'), wide: true, onClose: busy ? () => {} : onClose },
|
|
1912
|
+
h('div', { className: 'dss-dialog-body' }, t('message.editBody')),
|
|
1913
|
+
h('textarea', {
|
|
1914
|
+
className: 'dss-input',
|
|
1915
|
+
style: { minHeight: 120, width: '100%', marginTop: 10 },
|
|
1916
|
+
value: text,
|
|
1917
|
+
onChange: event => setText(event.target.value),
|
|
1918
|
+
}),
|
|
1919
|
+
error ? h('div', { className: 'dss-dialog-error' }, error) : null,
|
|
1920
|
+
h(
|
|
1921
|
+
'div',
|
|
1922
|
+
{ className: 'dss-dialog-actions' },
|
|
1923
|
+
h('button', { type: 'button', className: 'dss-btn dss-small', onClick: onClose, disabled: busy }, t('dialog.cancel')),
|
|
1924
|
+
h(
|
|
1925
|
+
'button',
|
|
1926
|
+
{
|
|
1927
|
+
type: 'button',
|
|
1928
|
+
className: 'dss-btn dss-small dss-primary',
|
|
1929
|
+
onClick: confirm,
|
|
1930
|
+
disabled: busy || !String(text).trim(),
|
|
1931
|
+
},
|
|
1932
|
+
busy ? h('span', { className: 'dss-spin' }) : null,
|
|
1933
|
+
busy ? t('message.resending') : t('message.editConfirm'),
|
|
1934
|
+
),
|
|
1935
|
+
),
|
|
1936
|
+
)
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// -------------------------------------------------------- rewind banner
|
|
1940
|
+
|
|
1941
|
+
function RewindBanner(props) {
|
|
1942
|
+
const state = useStore(pending)
|
|
1943
|
+
const session = props.session
|
|
1944
|
+
const sessionId = session?.sessionId ?? session?.id
|
|
1945
|
+
const [busy, setBusy] = useState(false)
|
|
1946
|
+
const [error, setError] = useState(null)
|
|
1947
|
+
const t = props.t
|
|
1948
|
+
|
|
1949
|
+
if (!state || !sessionId || state.sessionId !== sessionId) return null
|
|
1950
|
+
|
|
1951
|
+
const close = () => {
|
|
1952
|
+
if (busy) return
|
|
1953
|
+
setError(null)
|
|
1954
|
+
pending.set(null)
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const confirm = async () => {
|
|
1958
|
+
setBusy(true)
|
|
1959
|
+
setError(null)
|
|
1960
|
+
try {
|
|
1961
|
+
if (state.mode === 'resend') {
|
|
1962
|
+
// The action slot hands us a message id; the Host resolves which
|
|
1963
|
+
// turn it belongs to, forks before that turn's prompt, and re-admits
|
|
1964
|
+
// the prompt in the fork.
|
|
1965
|
+
const result = await call({
|
|
1966
|
+
action: 'resendTurn',
|
|
1967
|
+
sessionId,
|
|
1968
|
+
messageId: state.messageId,
|
|
1969
|
+
})
|
|
1970
|
+
if (result?.childId && props.openSession) props.openSession(result.childId)
|
|
1971
|
+
notify(result?.sent ? t('message.resendDone') : t('message.resendStaged'))
|
|
1972
|
+
pending.set(null)
|
|
1973
|
+
bump()
|
|
1974
|
+
return
|
|
1975
|
+
}
|
|
1976
|
+
const result = await call({
|
|
1977
|
+
action: state.mode === 'delete' ? 'deleteMessage' : 'rollback',
|
|
1978
|
+
sessionId,
|
|
1979
|
+
messageId: state.messageId,
|
|
1980
|
+
})
|
|
1981
|
+
if (result?.childId && props.openSession) {
|
|
1982
|
+
props.openSession(result.childId)
|
|
1983
|
+
}
|
|
1984
|
+
// Report what the user actually asked for. Keying this off
|
|
1985
|
+
// `deletedSession` was wrong: deleteMessage always returns false
|
|
1986
|
+
// (it only trims the conversation), so every delete announced
|
|
1987
|
+
// "rewound to this step" and read as the wrong operation.
|
|
1988
|
+
const done = state.mode === 'delete'
|
|
1989
|
+
? t('message.deleteDone')
|
|
1990
|
+
: t('message.rewindDone')
|
|
1991
|
+
notify(done)
|
|
1992
|
+
pending.set(null)
|
|
1993
|
+
bump()
|
|
1994
|
+
} catch (failure) {
|
|
1995
|
+
setError(failure?.message ?? t('message.rewindFailed'))
|
|
1996
|
+
} finally {
|
|
1997
|
+
setBusy(false)
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const isDelete = state.mode === 'delete'
|
|
2002
|
+
const isResend = state.mode === 'resend'
|
|
2003
|
+
const titleKey = isDelete ? 'message.deleteTitle' : isResend ? 'message.resendTitle' : 'message.rewindTitle'
|
|
2004
|
+
const bodyKey = isDelete ? 'message.deleteBody' : isResend ? 'message.resendBody' : 'message.rewindBody'
|
|
2005
|
+
return h(
|
|
2006
|
+
'div',
|
|
2007
|
+
{ className: 'dss-banner', role: 'group' },
|
|
2008
|
+
h(
|
|
2009
|
+
'div',
|
|
2010
|
+
{ className: 'dss-banner-main' },
|
|
2011
|
+
h('span', { className: 'dss-banner-icon' }, h(Icons.info, null)),
|
|
2012
|
+
h(
|
|
2013
|
+
'div',
|
|
2014
|
+
{ className: 'dss-banner-text' },
|
|
2015
|
+
h('span', { className: 'dss-banner-title' }, t(titleKey)),
|
|
2016
|
+
h('span', { className: 'dss-banner-body' }, t(bodyKey)),
|
|
2017
|
+
error ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, error) : null,
|
|
2018
|
+
),
|
|
2019
|
+
),
|
|
2020
|
+
h(
|
|
2021
|
+
'div',
|
|
2022
|
+
{ className: 'dss-banner-actions' },
|
|
2023
|
+
h('button', { type: 'button', className: 'dss-btn dss-small', onClick: close, disabled: busy }, t('dialog.cancel')),
|
|
2024
|
+
h(
|
|
2025
|
+
'button',
|
|
2026
|
+
{ type: 'button', className: 'dss-btn dss-small dss-primary', onClick: confirm, disabled: busy },
|
|
2027
|
+
busy ? h('span', { className: 'dss-spin' }) : null,
|
|
2028
|
+
busy
|
|
2029
|
+
? t(isResend ? 'message.resending' : 'message.rewinding')
|
|
2030
|
+
: t(isDelete ? 'message.deleteConfirm' : isResend ? 'message.resendConfirm' : 'message.rewindConfirm'),
|
|
2031
|
+
),
|
|
2032
|
+
),
|
|
2033
|
+
)
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// ------------------------------------------------------- settings section
|
|
2037
|
+
|
|
2038
|
+
function SnapshotSection(props) {
|
|
2039
|
+
const t = props.t
|
|
2040
|
+
const rev = useStore(revision)
|
|
2041
|
+
const [sessions, setSessions] = useState(null)
|
|
2042
|
+
const [selected, setSelected] = useState(props.initialSessionId ?? null)
|
|
2043
|
+
const [detail, setDetail] = useState(null)
|
|
2044
|
+
const [error, setError] = useState(null)
|
|
2045
|
+
const [busy, setBusy] = useState(null)
|
|
2046
|
+
const [memoryDraft, setMemoryDraft] = useState('')
|
|
2047
|
+
|
|
2048
|
+
useEffect(() => {
|
|
2049
|
+
let alive = true
|
|
2050
|
+
call({ action: 'list' })
|
|
2051
|
+
.then(rows => {
|
|
2052
|
+
if (!alive) return
|
|
2053
|
+
setSessions(rows)
|
|
2054
|
+
setSelected(current => {
|
|
2055
|
+
if (current && rows.some(row => row.sessionId === current)) return current
|
|
2056
|
+
if (props.initialSessionId && rows.some(row => row.sessionId === props.initialSessionId)) {
|
|
2057
|
+
return props.initialSessionId
|
|
2058
|
+
}
|
|
2059
|
+
// No session was named (the settings page opens this section with
|
|
2060
|
+
// no conversation context), so pick the one the user is most
|
|
2061
|
+
// likely looking for: the most recently active session that has
|
|
2062
|
+
// snapshots, not merely the first row that has any. The previous
|
|
2063
|
+
// fallback depended on list order and, once a session was missing
|
|
2064
|
+
// from the list entirely, showed an unrelated conversation.
|
|
2065
|
+
const ranked = [...rows].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
2066
|
+
const withSnapshots = ranked.find(row => row.checkpoints > 0)
|
|
2067
|
+
return (withSnapshots ?? ranked[0])?.sessionId ?? null
|
|
2068
|
+
})
|
|
2069
|
+
})
|
|
2070
|
+
.catch(failure => alive && setError(failure.message))
|
|
2071
|
+
return () => {
|
|
2072
|
+
alive = false
|
|
2073
|
+
}
|
|
2074
|
+
}, [rev, props.initialSessionId])
|
|
2075
|
+
|
|
2076
|
+
useEffect(() => {
|
|
2077
|
+
if (!selected) {
|
|
2078
|
+
setDetail(null)
|
|
2079
|
+
return undefined
|
|
2080
|
+
}
|
|
2081
|
+
let alive = true
|
|
2082
|
+
setDetail(null)
|
|
2083
|
+
call({ action: 'detail', sessionId: selected })
|
|
2084
|
+
.then(value => alive && setDetail(value))
|
|
2085
|
+
.catch(failure => alive && setError(failure.message))
|
|
2086
|
+
return () => {
|
|
2087
|
+
alive = false
|
|
2088
|
+
}
|
|
2089
|
+
}, [selected, rev])
|
|
2090
|
+
|
|
2091
|
+
const run = useCallback(
|
|
2092
|
+
async (key, operation, doneMessage) => {
|
|
2093
|
+
setBusy(key)
|
|
2094
|
+
setError(null)
|
|
2095
|
+
try {
|
|
2096
|
+
await operation()
|
|
2097
|
+
if (doneMessage) notify(doneMessage)
|
|
2098
|
+
bump()
|
|
2099
|
+
} catch (failure) {
|
|
2100
|
+
setError(failure.message)
|
|
2101
|
+
notify(failure.message, 'error')
|
|
2102
|
+
} finally {
|
|
2103
|
+
setBusy(null)
|
|
2104
|
+
}
|
|
2105
|
+
},
|
|
2106
|
+
[],
|
|
2107
|
+
)
|
|
2108
|
+
|
|
2109
|
+
const sectionClass = props.embedded ? 'dss-section dss-embedded' : 'dss-section'
|
|
2110
|
+
const head = props.embedded ? null : h(
|
|
2111
|
+
'div',
|
|
2112
|
+
{ className: 'dss-section-head' },
|
|
2113
|
+
h('h2', null, t('settings.title')),
|
|
2114
|
+
h('div', { className: 'dss-section-desc' }, t('settings.description')),
|
|
2115
|
+
)
|
|
2116
|
+
|
|
2117
|
+
if (error && !detail) {
|
|
2118
|
+
return h('div', { className: sectionClass }, head, h('div', { className: 'dss-dialog-error' }, error))
|
|
2119
|
+
}
|
|
2120
|
+
if (!sessions) return h('div', { className: sectionClass }, head, h('div', { className: 'dss-empty' }, t('settings.loading')))
|
|
2121
|
+
if (sessions.length === 0) return h('div', { className: sectionClass }, head, h('div', { className: 'dss-empty' }, t('settings.noSessions')))
|
|
2122
|
+
|
|
2123
|
+
const picker = h(
|
|
2124
|
+
'div',
|
|
2125
|
+
{ className: 'dss-toolbar' },
|
|
2126
|
+
h('span', { className: 'dss-group-title' }, t('settings.session')),
|
|
2127
|
+
h(
|
|
2128
|
+
'select',
|
|
2129
|
+
{
|
|
2130
|
+
className: 'dss-select',
|
|
2131
|
+
value: selected ?? '',
|
|
2132
|
+
onChange: event => setSelected(event.target.value),
|
|
2133
|
+
},
|
|
2134
|
+
...sessions.map(row =>
|
|
2135
|
+
h('option', { key: row.sessionId, value: row.sessionId }, `${row.title || row.sessionId} · ${row.checkpoints} · ${row.checkpointLabel}`),
|
|
2136
|
+
),
|
|
2137
|
+
),
|
|
2138
|
+
h(
|
|
2139
|
+
'button',
|
|
2140
|
+
{
|
|
2141
|
+
type: 'button',
|
|
2142
|
+
className: 'dss-btn dss-small',
|
|
2143
|
+
disabled: !selected || busy === 'capture',
|
|
2144
|
+
onClick: () => run('capture', () => call({ action: 'capture', sessionId: selected }), t('toast.captured')),
|
|
2145
|
+
},
|
|
2146
|
+
busy === 'capture' ? h('span', { className: 'dss-spin' }) : h(Icons.plus, { size: 14 }),
|
|
2147
|
+
t(busy === 'capture' ? 'settings.capturing' : 'settings.capture'),
|
|
2148
|
+
),
|
|
2149
|
+
)
|
|
2150
|
+
|
|
2151
|
+
const stats = detail?.stats
|
|
2152
|
+
const statsLine = stats
|
|
2153
|
+
? h(
|
|
2154
|
+
'div',
|
|
2155
|
+
{ className: 'dss-row-meta' },
|
|
2156
|
+
h('span', null, t('settings.usage', { size: stats.label })),
|
|
2157
|
+
h('span', null, t('settings.budget', { keep: stats.budget })),
|
|
2158
|
+
detail.cwd ? h('span', { title: detail.cwd }, detail.cwd) : null,
|
|
2159
|
+
)
|
|
2160
|
+
: null
|
|
2161
|
+
|
|
2162
|
+
const checkpoints = detail?.checkpoints ?? []
|
|
2163
|
+
const checkpointList =
|
|
2164
|
+
checkpoints.length === 0
|
|
2165
|
+
? h('div', { className: 'dss-empty' }, t('settings.empty'))
|
|
2166
|
+
: h(
|
|
2167
|
+
'div',
|
|
2168
|
+
{ className: 'dss-card' },
|
|
2169
|
+
...checkpoints.map(cp =>
|
|
2170
|
+
h(
|
|
2171
|
+
'div',
|
|
2172
|
+
{ className: 'dss-row', key: cp.id },
|
|
2173
|
+
h(
|
|
2174
|
+
'div',
|
|
2175
|
+
{ className: 'dss-row-main' },
|
|
2176
|
+
h(
|
|
2177
|
+
'div',
|
|
2178
|
+
{ className: 'dss-row-title' },
|
|
2179
|
+
h('span', null, cp.label || cp.id),
|
|
2180
|
+
cp.locked ? h('span', { className: 'dss-badge dss-locked' }, t('settings.locked')) : null,
|
|
2181
|
+
cp.temporary ? h('span', { className: 'dss-badge dss-temp' }, t('settings.temporary')) : null,
|
|
2182
|
+
),
|
|
2183
|
+
h(
|
|
2184
|
+
'div',
|
|
2185
|
+
{ className: 'dss-row-meta' },
|
|
2186
|
+
typeof cp.turn === 'number' ? h('span', null, t('settings.turn', { turn: cp.turn })) : null,
|
|
2187
|
+
h('span', null, t('settings.files', { count: cp.files })),
|
|
2188
|
+
h('span', null, cp.size),
|
|
2189
|
+
h('span', null, new Date(cp.createdAt).toLocaleString()),
|
|
2190
|
+
),
|
|
2191
|
+
),
|
|
2192
|
+
h(
|
|
2193
|
+
'div',
|
|
2194
|
+
{ className: 'dss-row-actions' },
|
|
2195
|
+
h(
|
|
2196
|
+
'button',
|
|
2197
|
+
{
|
|
2198
|
+
type: 'button',
|
|
2199
|
+
className: 'dss-btn dss-small dss-ghost',
|
|
2200
|
+
title: cp.locked ? t('settings.unlock') : t('settings.lock'),
|
|
2201
|
+
disabled: busy === `lock:${cp.id}`,
|
|
2202
|
+
onClick: () =>
|
|
2203
|
+
run(`lock:${cp.id}`, () => call({ action: 'setLocked', sessionId: selected, checkpointId: cp.id, locked: !cp.locked }), cp.locked ? t('toast.unlocked') : t('toast.locked')),
|
|
2204
|
+
},
|
|
2205
|
+
h(cp.locked ? Icons.unlock : Icons.lock, { size: 14 }),
|
|
2206
|
+
),
|
|
2207
|
+
h(
|
|
2208
|
+
'button',
|
|
2209
|
+
{
|
|
2210
|
+
type: 'button',
|
|
2211
|
+
className: 'dss-btn dss-small dss-ghost',
|
|
2212
|
+
disabled: busy === `restore:${cp.id}`,
|
|
2213
|
+
onClick: () => {
|
|
2214
|
+
dialog.set({
|
|
2215
|
+
key: `restore:${cp.id}`,
|
|
2216
|
+
mode: 'restore',
|
|
2217
|
+
t,
|
|
2218
|
+
title: t('settings.restore'),
|
|
2219
|
+
body: t('settings.restoreConfirm'),
|
|
2220
|
+
confirmLabel: t('settings.restore'),
|
|
2221
|
+
doneMessage: t('settings.restoreDone'),
|
|
2222
|
+
onConfirm: () => call({ action: 'restore', sessionId: selected, checkpointId: cp.id }),
|
|
2223
|
+
})
|
|
2224
|
+
},
|
|
2225
|
+
},
|
|
2226
|
+
h(Icons.restore, { size: 14 }),
|
|
2227
|
+
t('settings.restore'),
|
|
2228
|
+
),
|
|
2229
|
+
h(
|
|
2230
|
+
'button',
|
|
2231
|
+
{
|
|
2232
|
+
type: 'button',
|
|
2233
|
+
className: 'dss-btn dss-small dss-ghost',
|
|
2234
|
+
title: t('settings.remove'),
|
|
2235
|
+
disabled: cp.locked || busy === `remove:${cp.id}`,
|
|
2236
|
+
onClick: () => run(`remove:${cp.id}`, () => call({ action: 'removeCheckpoint', sessionId: selected, checkpointId: cp.id }), t('toast.removed')),
|
|
2237
|
+
},
|
|
2238
|
+
h(Icons.trash, { size: 14 }),
|
|
2239
|
+
),
|
|
2240
|
+
),
|
|
2241
|
+
),
|
|
2242
|
+
),
|
|
2243
|
+
)
|
|
2244
|
+
|
|
2245
|
+
const undos = detail?.undos ?? []
|
|
2246
|
+
const undoList = undos.length === 0
|
|
2247
|
+
? null
|
|
2248
|
+
: h(
|
|
2249
|
+
'div',
|
|
2250
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
2251
|
+
h('div', { className: 'dss-group-title' }, t('settings.undoBundles')),
|
|
2252
|
+
h(
|
|
2253
|
+
'div',
|
|
2254
|
+
{ className: 'dss-card' },
|
|
2255
|
+
...undos.map(undo =>
|
|
2256
|
+
h(
|
|
2257
|
+
'div',
|
|
2258
|
+
{ className: 'dss-row', key: undo.id },
|
|
2259
|
+
h(
|
|
2260
|
+
'div',
|
|
2261
|
+
{ className: 'dss-row-main' },
|
|
2262
|
+
h('div', { className: 'dss-row-title' }, t('settings.undoBundle', { turn: undo.turn ?? '?' })),
|
|
2263
|
+
h('div', { className: 'dss-row-meta' }, h('span', null, t('settings.undoHint'))),
|
|
2264
|
+
),
|
|
2265
|
+
h(
|
|
2266
|
+
'div',
|
|
2267
|
+
{ className: 'dss-row-actions' },
|
|
2268
|
+
h(
|
|
2269
|
+
'button',
|
|
2270
|
+
{
|
|
2271
|
+
type: 'button',
|
|
2272
|
+
className: 'dss-btn dss-small',
|
|
2273
|
+
disabled: busy === `undo:${undo.id}`,
|
|
2274
|
+
onClick: () => run(`undo:${undo.id}`, () => call({ action: 'undo', undoId: undo.id }), t('message.undoDone')),
|
|
2275
|
+
},
|
|
2276
|
+
t('settings.undoRestore'),
|
|
2277
|
+
),
|
|
2278
|
+
),
|
|
2279
|
+
),
|
|
2280
|
+
),
|
|
2281
|
+
),
|
|
2282
|
+
)
|
|
2283
|
+
|
|
2284
|
+
const memory = detail?.memory ?? []
|
|
2285
|
+
const memoryBlock = h(
|
|
2286
|
+
'div',
|
|
2287
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
2288
|
+
h('div', { className: 'dss-group-title' }, t('settings.memory')),
|
|
2289
|
+
h(
|
|
2290
|
+
'div',
|
|
2291
|
+
{ className: 'dss-card' },
|
|
2292
|
+
memory.length === 0
|
|
2293
|
+
? h('div', { className: 'dss-empty' }, t('settings.memoryEmpty'))
|
|
2294
|
+
: memory.map(entry =>
|
|
2295
|
+
h(
|
|
2296
|
+
'div',
|
|
2297
|
+
{ className: 'dss-row', key: entry.id },
|
|
2298
|
+
h('div', { className: 'dss-row-main' }, h('div', { className: 'dss-memory-text' }, entry.text), h('div', { className: 'dss-row-meta' }, h('span', null, new Date(entry.createdAt).toLocaleString()))),
|
|
2299
|
+
h(
|
|
2300
|
+
'div',
|
|
2301
|
+
{ className: 'dss-row-actions' },
|
|
2302
|
+
h(
|
|
2303
|
+
'button',
|
|
2304
|
+
{
|
|
2305
|
+
type: 'button',
|
|
2306
|
+
className: 'dss-btn dss-small dss-ghost',
|
|
2307
|
+
title: t('settings.memoryRemove'),
|
|
2308
|
+
onClick: () => run(`mem:${entry.id}`, () => call({ action: 'memory.remove', sessionId: selected, id: entry.id }), t('toast.memoryRemoved')),
|
|
2309
|
+
},
|
|
2310
|
+
h(Icons.trash, { size: 14 }),
|
|
2311
|
+
),
|
|
2312
|
+
),
|
|
2313
|
+
),
|
|
2314
|
+
),
|
|
2315
|
+
),
|
|
2316
|
+
h(
|
|
2317
|
+
'div',
|
|
2318
|
+
{ className: 'dss-toolbar' },
|
|
2319
|
+
h('input', {
|
|
2320
|
+
className: 'dss-input',
|
|
2321
|
+
value: memoryDraft,
|
|
2322
|
+
placeholder: t('settings.memoryPlaceholder'),
|
|
2323
|
+
onChange: event => setMemoryDraft(event.target.value),
|
|
2324
|
+
}),
|
|
2325
|
+
h(
|
|
2326
|
+
'button',
|
|
2327
|
+
{
|
|
2328
|
+
type: 'button',
|
|
2329
|
+
className: 'dss-btn dss-small',
|
|
2330
|
+
disabled: !memoryDraft.trim() || !selected,
|
|
2331
|
+
onClick: () =>
|
|
2332
|
+
run('memory:add', async () => {
|
|
2333
|
+
await call({ action: 'memory.add', sessionId: selected, text: memoryDraft })
|
|
2334
|
+
setMemoryDraft('')
|
|
2335
|
+
}, t('toast.memoryAdded')),
|
|
2336
|
+
},
|
|
2337
|
+
t('settings.memorySave'),
|
|
2338
|
+
),
|
|
2339
|
+
),
|
|
2340
|
+
)
|
|
2341
|
+
|
|
2342
|
+
return h(
|
|
2343
|
+
'div',
|
|
2344
|
+
{ className: sectionClass },
|
|
2345
|
+
head,
|
|
2346
|
+
picker,
|
|
2347
|
+
statsLine,
|
|
2348
|
+
checkpointList,
|
|
2349
|
+
undoList,
|
|
2350
|
+
memoryBlock,
|
|
2351
|
+
)
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
// ------------------------------------------------------- long-term memory
|
|
2355
|
+
|
|
2356
|
+
function useWorkspacePath() {
|
|
2357
|
+
return runtime.resolveCwd?.() ?? runtime.cwd
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/** Last path segment, for a compact workspace label. */
|
|
2361
|
+
function basename(value) {
|
|
2362
|
+
const text = String(value ?? '')
|
|
2363
|
+
const cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'))
|
|
2364
|
+
return cut >= 0 ? text.slice(cut + 1) || text : text
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function MemorySection(props) {
|
|
2368
|
+
const t = props.t
|
|
2369
|
+
const rev = useStore(revision)
|
|
2370
|
+
const [tab, setTab] = useState('all')
|
|
2371
|
+
const [items, setItems] = useState(null)
|
|
2372
|
+
const [pending, setPending] = useState([])
|
|
2373
|
+
const [settings, setSettings] = useState(null)
|
|
2374
|
+
const [error, setError] = useState(null)
|
|
2375
|
+
const [draft, setDraft] = useState('')
|
|
2376
|
+
const [draftScope, setDraftScope] = useState('workspace')
|
|
2377
|
+
const [busy, setBusy] = useState(null)
|
|
2378
|
+
const [known, setKnown] = useState([])
|
|
2379
|
+
// Maintenance bookkeeping, so "did the timer ever run?" is a fact on
|
|
2380
|
+
// screen rather than something the user has to take on trust.
|
|
2381
|
+
const [maint, setMaint] = useState(null)
|
|
2382
|
+
// An explicit pick wins over the guessed path, so the tab never silently
|
|
2383
|
+
// shows another workspace's memories once the user chooses one.
|
|
2384
|
+
const [picked, setPicked] = useState(undefined)
|
|
2385
|
+
// "Which memories would this question pull in?" — answered on demand so
|
|
2386
|
+
// the answer is inspectable rather than something the user has to infer
|
|
2387
|
+
// from what the model happened to say.
|
|
2388
|
+
const [explain, setExplain] = useState(null)
|
|
2389
|
+
const [explainQuery, setExplainQuery] = useState('')
|
|
2390
|
+
const [explainBusy, setExplainBusy] = useState(false)
|
|
2391
|
+
// Filters: a library that grows past a screenful is not worth reading, so
|
|
2392
|
+
// the list has to be narrowable by tag and by recency.
|
|
2393
|
+
const [tagFilter, setTagFilter] = useState('')
|
|
2394
|
+
const [sinceFilter, setSinceFilter] = useState('')
|
|
2395
|
+
|
|
2396
|
+
const detected = useWorkspacePath()
|
|
2397
|
+
const cwd = picked !== undefined ? picked : detected
|
|
2398
|
+
|
|
2399
|
+
useEffect(() => {
|
|
2400
|
+
let alive = true
|
|
2401
|
+
call({ action: 'longterm.workspaces' })
|
|
2402
|
+
.then(list => alive && setKnown(Array.isArray(list) ? list : []))
|
|
2403
|
+
.catch(() => alive && setKnown([]))
|
|
2404
|
+
return () => {
|
|
2405
|
+
alive = false
|
|
2406
|
+
}
|
|
2407
|
+
}, [rev])
|
|
2408
|
+
|
|
2409
|
+
useEffect(() => {
|
|
2410
|
+
let alive = true
|
|
2411
|
+
Promise.all([
|
|
2412
|
+
// Filters go to the engine, not applied client-side, so the export and
|
|
2413
|
+
// this page always agree on what "my error lessons from this week" means.
|
|
2414
|
+
call({
|
|
2415
|
+
action: 'longterm.list',
|
|
2416
|
+
cwd,
|
|
2417
|
+
...(tagFilter ? { tag: tagFilter } : {}),
|
|
2418
|
+
...(sinceFilter ? { sinceDays: Number(sinceFilter) } : {}),
|
|
2419
|
+
}),
|
|
2420
|
+
call({ action: 'longterm.pending', cwd }),
|
|
2421
|
+
call({ action: 'longterm.settings' }),
|
|
2422
|
+
call({ action: 'longterm.status' }).catch(() => null),
|
|
2423
|
+
])
|
|
2424
|
+
.then(([list, waiting, config, status]) => {
|
|
2425
|
+
if (!alive) return
|
|
2426
|
+
setItems(list)
|
|
2427
|
+
setPending(waiting)
|
|
2428
|
+
setSettings(config)
|
|
2429
|
+
setMaint(status)
|
|
2430
|
+
})
|
|
2431
|
+
.catch(failure => alive && setError(failure.message))
|
|
2432
|
+
return () => {
|
|
2433
|
+
alive = false
|
|
2434
|
+
}
|
|
2435
|
+
}, [rev, cwd, tagFilter, sinceFilter])
|
|
2436
|
+
|
|
2437
|
+
const run = useCallback(async (key, operation, message) => {
|
|
2438
|
+
setBusy(key)
|
|
2439
|
+
setError(null)
|
|
2440
|
+
try {
|
|
2441
|
+
await operation()
|
|
2442
|
+
if (message) notify(message)
|
|
2443
|
+
bump()
|
|
2444
|
+
} catch (failure) {
|
|
2445
|
+
setError(failure.message)
|
|
2446
|
+
notify(failure.message, 'error')
|
|
2447
|
+
} finally {
|
|
2448
|
+
setBusy(null)
|
|
2449
|
+
}
|
|
2450
|
+
}, [])
|
|
2451
|
+
|
|
2452
|
+
const runExplain = useCallback(async () => {
|
|
2453
|
+
setExplainBusy(true)
|
|
2454
|
+
setError(null)
|
|
2455
|
+
try {
|
|
2456
|
+
setExplain(await call({ action: 'longterm.explain', cwd, query: explainQuery }))
|
|
2457
|
+
} catch (failure) {
|
|
2458
|
+
setError(failure.message)
|
|
2459
|
+
} finally {
|
|
2460
|
+
setExplainBusy(false)
|
|
2461
|
+
}
|
|
2462
|
+
}, [cwd, explainQuery])
|
|
2463
|
+
|
|
2464
|
+
// Known tags, so the filter is a pick rather than a guess at the exact
|
|
2465
|
+
// spelling — the engine matches tags exactly, so a typo silently empties
|
|
2466
|
+
// the list and looks like a bug.
|
|
2467
|
+
//
|
|
2468
|
+
// This hook MUST stay above the early returns below. Those returns happen
|
|
2469
|
+
// while `items` is still null, and a hook declared after them is called a
|
|
2470
|
+
// different number of times between renders — React then tears the whole
|
|
2471
|
+
// section down and the settings page shows nothing at all.
|
|
2472
|
+
const knownTags = useMemo(() => {
|
|
2473
|
+
const out = new Set()
|
|
2474
|
+
for (const item of items ?? []) {
|
|
2475
|
+
for (const tag of item.tags ?? []) {
|
|
2476
|
+
if (typeof tag === 'string' && tag.trim()) out.add(tag.trim())
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
return [...out].sort((a, b) => a.localeCompare(b))
|
|
2480
|
+
}, [items])
|
|
2481
|
+
|
|
2482
|
+
const head = h(
|
|
2483
|
+
'div',
|
|
2484
|
+
{ className: 'dss-section-head' },
|
|
2485
|
+
h('h2', null, t('memory.title')),
|
|
2486
|
+
h('div', { className: 'dss-section-desc' }, t('memory.description')),
|
|
2487
|
+
)
|
|
2488
|
+
|
|
2489
|
+
if (error && !items) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-dialog-error' }, error))
|
|
2490
|
+
if (!items) return h('div', { className: 'dss-section' }, head, h('div', { className: 'dss-empty' }, t('settings.loading')))
|
|
2491
|
+
|
|
2492
|
+
const visible = items.filter(item => {
|
|
2493
|
+
if (tab === 'workspace') return item.scope === 'workspace'
|
|
2494
|
+
if (tab === 'global') return item.scope === 'global'
|
|
2495
|
+
if (tab === 'archived') return item.archived === true
|
|
2496
|
+
return true
|
|
2497
|
+
})
|
|
2498
|
+
|
|
2499
|
+
const tabs = h(
|
|
2500
|
+
'div',
|
|
2501
|
+
{ className: 'dss-tabs' },
|
|
2502
|
+
...['all', 'workspace', 'global', 'archived'].map(key =>
|
|
2503
|
+
h(
|
|
2504
|
+
'button',
|
|
2505
|
+
{
|
|
2506
|
+
key,
|
|
2507
|
+
type: 'button',
|
|
2508
|
+
className: 'dss-tab' + (tab === key ? ' dss-tab-on' : ''),
|
|
2509
|
+
onClick: () => setTab(key),
|
|
2510
|
+
},
|
|
2511
|
+
t(`memory.tab.${key}`),
|
|
2512
|
+
),
|
|
2513
|
+
),
|
|
2514
|
+
)
|
|
2515
|
+
|
|
2516
|
+
const filterBlock = h(
|
|
2517
|
+
'div',
|
|
2518
|
+
{ className: 'dss-toolbar' },
|
|
2519
|
+
h(
|
|
2520
|
+
'select',
|
|
2521
|
+
{
|
|
2522
|
+
className: 'dss-select',
|
|
2523
|
+
value: tagFilter,
|
|
2524
|
+
onChange: event => setTagFilter(event.target.value),
|
|
2525
|
+
},
|
|
2526
|
+
h('option', { value: '' }, t('memory.filterAllTags')),
|
|
2527
|
+
...knownTags.map(tag => h('option', { key: tag, value: tag }, tag)),
|
|
2528
|
+
),
|
|
2529
|
+
h(
|
|
2530
|
+
'select',
|
|
2531
|
+
{
|
|
2532
|
+
className: 'dss-select',
|
|
2533
|
+
value: sinceFilter,
|
|
2534
|
+
onChange: event => setSinceFilter(event.target.value),
|
|
2535
|
+
},
|
|
2536
|
+
h('option', { value: '' }, t('memory.filterAnyTime')),
|
|
2537
|
+
h('option', { value: '1' }, t('memory.filterToday')),
|
|
2538
|
+
h('option', { value: '7' }, t('memory.filterWeek')),
|
|
2539
|
+
h('option', { value: '30' }, t('memory.filterMonth')),
|
|
2540
|
+
),
|
|
2541
|
+
tagFilter || sinceFilter
|
|
2542
|
+
? h(
|
|
2543
|
+
'button',
|
|
2544
|
+
{
|
|
2545
|
+
type: 'button',
|
|
2546
|
+
className: 'dss-action',
|
|
2547
|
+
onClick: () => {
|
|
2548
|
+
setTagFilter('')
|
|
2549
|
+
setSinceFilter('')
|
|
2550
|
+
},
|
|
2551
|
+
},
|
|
2552
|
+
t('memory.filterClear'),
|
|
2553
|
+
)
|
|
2554
|
+
: null,
|
|
2555
|
+
h('span', { className: 'dss-row-meta' }, t('memory.filterCount', { n: (items ?? []).length })),
|
|
2556
|
+
)
|
|
2557
|
+
|
|
2558
|
+
const pendingBlock =
|
|
2559
|
+
pending.length === 0
|
|
2560
|
+
? null
|
|
2561
|
+
: h(
|
|
2562
|
+
'div',
|
|
2563
|
+
{ className: 'dss-mem-pending' },
|
|
2564
|
+
h('div', { className: 'dss-group-title' }, t('memory.pendingTitle')),
|
|
2565
|
+
h('div', { className: 'dss-mem-pending-hint' }, t('memory.pendingHint')),
|
|
2566
|
+
...pending.map(item =>
|
|
2567
|
+
h(
|
|
2568
|
+
'div',
|
|
2569
|
+
{ className: 'dss-row', key: item.id },
|
|
2570
|
+
h(
|
|
2571
|
+
'div',
|
|
2572
|
+
{ className: 'dss-row-main' },
|
|
2573
|
+
h('div', { className: 'dss-memory-text' }, item.text),
|
|
2574
|
+
h('div', { className: 'dss-row-meta' }, h('span', { className: 'dss-badge' }, item.scope === 'global' ? t('memory.global') : t('memory.workspace'))),
|
|
2575
|
+
),
|
|
2576
|
+
h(
|
|
2577
|
+
'div',
|
|
2578
|
+
{ className: 'dss-row-actions' },
|
|
2579
|
+
h(
|
|
2580
|
+
'button',
|
|
2581
|
+
{
|
|
2582
|
+
type: 'button',
|
|
2583
|
+
className: 'dss-btn dss-small dss-primary',
|
|
2584
|
+
disabled: busy === `keep:${item.id}`,
|
|
2585
|
+
onClick: () => run(`keep:${item.id}`, () => call({ action: 'longterm.settle', id: item.id, accept: true }), t('memory.accepted')),
|
|
2586
|
+
},
|
|
2587
|
+
t('memory.accept'),
|
|
2588
|
+
),
|
|
2589
|
+
h(
|
|
2590
|
+
'button',
|
|
2591
|
+
{
|
|
2592
|
+
type: 'button',
|
|
2593
|
+
className: 'dss-btn dss-small dss-ghost',
|
|
2594
|
+
disabled: busy === `drop:${item.id}`,
|
|
2595
|
+
onClick: () => run(`drop:${item.id}`, () => call({ action: 'longterm.settle', id: item.id, accept: false }), t('memory.rejected')),
|
|
2596
|
+
},
|
|
2597
|
+
t('memory.reject'),
|
|
2598
|
+
),
|
|
2599
|
+
),
|
|
2600
|
+
),
|
|
2601
|
+
),
|
|
2602
|
+
)
|
|
2603
|
+
|
|
2604
|
+
const listBlock =
|
|
2605
|
+
visible.length === 0
|
|
2606
|
+
? h('div', { className: 'dss-empty' }, t('memory.empty'))
|
|
2607
|
+
: h(
|
|
2608
|
+
'div',
|
|
2609
|
+
{ className: 'dss-card' },
|
|
2610
|
+
...visible.map(entry =>
|
|
2611
|
+
h(
|
|
2612
|
+
'div',
|
|
2613
|
+
{ className: 'dss-row', key: entry.id },
|
|
2614
|
+
h(
|
|
2615
|
+
'div',
|
|
2616
|
+
{ className: 'dss-row-main' },
|
|
2617
|
+
h('div', { className: 'dss-memory-text' }, entry.text),
|
|
2618
|
+
h(
|
|
2619
|
+
'div',
|
|
2620
|
+
{ className: 'dss-row-meta' },
|
|
2621
|
+
h('span', { className: 'dss-badge' + (entry.scope === 'global' ? ' dss-locked' : '') }, entry.scope === 'global' ? t('memory.global') : t('memory.workspace')),
|
|
2622
|
+
entry.kind === 'rule' ? h('span', { className: 'dss-badge dss-temp' }, t('memory.rule')) : null,
|
|
2623
|
+
entry.summary ? h('span', { className: 'dss-badge' }, t('memory.summary')) : null,
|
|
2624
|
+
entry.frozen ? h('span', { className: 'dss-badge dss-locked' }, t('memory.frozen')) : null,
|
|
2625
|
+
entry.archived ? h('span', { className: 'dss-badge dss-temp' }, t('memory.archived')) : null,
|
|
2626
|
+
typeof entry.score === 'number' && !entry.archived ? h('span', null, t('memory.score', { score: entry.score.toFixed(2) })) : null,
|
|
2627
|
+
h('span', null, t('memory.access', { count: entry.accessCount ?? 0 })),
|
|
2628
|
+
),
|
|
2629
|
+
),
|
|
2630
|
+
h(
|
|
2631
|
+
'div',
|
|
2632
|
+
{ className: 'dss-row-actions' },
|
|
2633
|
+
h(
|
|
2634
|
+
'button',
|
|
2635
|
+
{
|
|
2636
|
+
type: 'button',
|
|
2637
|
+
className: 'dss-btn dss-small dss-ghost',
|
|
2638
|
+
title: entry.frozen ? t('memory.unfreeze') : t('memory.freeze'),
|
|
2639
|
+
onClick: () =>
|
|
2640
|
+
run(`freeze:${entry.id}`, () =>
|
|
2641
|
+
call({ action: 'longterm.update', id: entry.id, cwd, scope: entry.scope, frozen: !entry.frozen }),
|
|
2642
|
+
),
|
|
2643
|
+
},
|
|
2644
|
+
h(entry.frozen ? Icons.unlock : Icons.lock, { size: 14 }),
|
|
2645
|
+
),
|
|
2646
|
+
h(
|
|
2647
|
+
'button',
|
|
2648
|
+
{
|
|
2649
|
+
type: 'button',
|
|
2650
|
+
className: 'dss-btn dss-small dss-ghost',
|
|
2651
|
+
title: t('memory.remove'),
|
|
2652
|
+
onClick: () => run(`rm:${entry.id}`, () => call({ action: 'longterm.remove', id: entry.id, cwd, scope: entry.scope }), t('memory.removed')),
|
|
2653
|
+
},
|
|
2654
|
+
h(Icons.trash, { size: 14 }),
|
|
2655
|
+
),
|
|
2656
|
+
),
|
|
2657
|
+
),
|
|
2658
|
+
),
|
|
2659
|
+
)
|
|
2660
|
+
|
|
2661
|
+
const addBlock = h(
|
|
2662
|
+
'div',
|
|
2663
|
+
{ className: 'dss-toolbar' },
|
|
2664
|
+
h(
|
|
2665
|
+
'select',
|
|
2666
|
+
{ className: 'dss-select', value: draftScope, onChange: event => setDraftScope(event.target.value) },
|
|
2667
|
+
h('option', { value: 'workspace' }, t('memory.workspace')),
|
|
2668
|
+
h('option', { value: 'global' }, t('memory.global')),
|
|
2669
|
+
),
|
|
2670
|
+
h('input', {
|
|
2671
|
+
className: 'dss-input',
|
|
2672
|
+
value: draft,
|
|
2673
|
+
placeholder: t('memory.placeholder'),
|
|
2674
|
+
onChange: event => setDraft(event.target.value),
|
|
2675
|
+
}),
|
|
2676
|
+
h(
|
|
2677
|
+
'button',
|
|
2678
|
+
{
|
|
2679
|
+
type: 'button',
|
|
2680
|
+
className: 'dss-btn dss-small',
|
|
2681
|
+
disabled: !draft.trim() || busy === 'add',
|
|
2682
|
+
onClick: () =>
|
|
2683
|
+
run(
|
|
2684
|
+
'add',
|
|
2685
|
+
async () => {
|
|
2686
|
+
await call({ action: 'longterm.remember', text: draft, scope: draftScope, cwd })
|
|
2687
|
+
setDraft('')
|
|
2688
|
+
},
|
|
2689
|
+
t('memory.added'),
|
|
2690
|
+
),
|
|
2691
|
+
},
|
|
2692
|
+
t('memory.save'),
|
|
2693
|
+
),
|
|
2694
|
+
)
|
|
2695
|
+
|
|
2696
|
+
const footer = h(
|
|
2697
|
+
'div',
|
|
2698
|
+
{ className: 'dss-toolbar' },
|
|
2699
|
+
h(
|
|
2700
|
+
'button',
|
|
2701
|
+
{
|
|
2702
|
+
type: 'button',
|
|
2703
|
+
className: 'dss-btn dss-small',
|
|
2704
|
+
disabled: busy === 'maintain',
|
|
2705
|
+
onClick: () => run('maintain', () => call({ action: 'longterm.maintain', cwd }), t('memory.maintained')),
|
|
2706
|
+
},
|
|
2707
|
+
t('memory.maintain'),
|
|
2708
|
+
),
|
|
2709
|
+
h(
|
|
2710
|
+
'label',
|
|
2711
|
+
{ className: 'dss-curate dss-curate-inline' },
|
|
2712
|
+
h('input', {
|
|
2713
|
+
type: 'checkbox',
|
|
2714
|
+
checked: settings?.implicit !== false,
|
|
2715
|
+
onChange: event =>
|
|
2716
|
+
run('implicit', () =>
|
|
2717
|
+
call({ action: 'longterm.settings', patch: { implicit: event.target.checked } }),
|
|
2718
|
+
),
|
|
2719
|
+
}),
|
|
2720
|
+
h('span', { className: 'dss-curate-title' }, t('memory.implicit')),
|
|
2721
|
+
),
|
|
2722
|
+
h(
|
|
2723
|
+
'button',
|
|
2724
|
+
{
|
|
2725
|
+
type: 'button',
|
|
2726
|
+
className: 'dss-btn dss-small dss-ghost',
|
|
2727
|
+
onClick: () => run('cold', () => call({ action: 'longterm.remove', cwd, scope: 'workspace' }), t('memory.coldCleared')),
|
|
2728
|
+
},
|
|
2729
|
+
t('memory.clearCold'),
|
|
2730
|
+
),
|
|
2731
|
+
)
|
|
2732
|
+
|
|
2733
|
+
// The workspace picker only means something where a workspace is part of
|
|
2734
|
+
// the answer. On the 全局 tab every row is a global memory regardless of
|
|
2735
|
+
// which workspace is selected, so leaving the control visible invited the
|
|
2736
|
+
// reasonable question "why does switching it change nothing?" — the honest
|
|
2737
|
+
// fix is to hide it rather than to keep a control that does nothing.
|
|
2738
|
+
const showsWorkspaceScope = tab === 'all' || tab === 'workspace'
|
|
2739
|
+
const workspacePicker =
|
|
2740
|
+
!showsWorkspaceScope || known.length === 0
|
|
2741
|
+
? null
|
|
2742
|
+
: h(
|
|
2743
|
+
'div',
|
|
2744
|
+
{ className: 'dss-ws-picker' },
|
|
2745
|
+
h('label', { className: 'dss-ws-label' }, t('memory.workspacePicker')),
|
|
2746
|
+
h(
|
|
2747
|
+
'select',
|
|
2748
|
+
{
|
|
2749
|
+
className: 'dss-select',
|
|
2750
|
+
value: cwd ?? '',
|
|
2751
|
+
onChange: event => setPicked(event.target.value || undefined),
|
|
2752
|
+
},
|
|
2753
|
+
...(cwd ? [] : [h('option', { key: '__none', value: '' }, t('memory.workspaceNone'))]),
|
|
2754
|
+
...known.map(item =>
|
|
2755
|
+
h('option', { key: item.key, value: item.cwd }, `${basename(item.cwd)} (${item.entries})`),
|
|
2756
|
+
),
|
|
2757
|
+
),
|
|
2758
|
+
)
|
|
2759
|
+
|
|
2760
|
+
// Maintenance is the one part of this feature that runs without the user
|
|
2761
|
+
// present, so its history is shown rather than assumed. "never run" is a
|
|
2762
|
+
// distinct, honest state — it is what an idle timer looks like.
|
|
2763
|
+
const lastRun = maint && typeof maint.lastMaintainedAt === 'number' ? maint.lastMaintainedAt : 0
|
|
2764
|
+
const lastReport = maint?.lastReport
|
|
2765
|
+
const maintLine = h(
|
|
2766
|
+
'div',
|
|
2767
|
+
{ className: 'dss-maint' },
|
|
2768
|
+
h(
|
|
2769
|
+
'span',
|
|
2770
|
+
{ className: 'dss-maint-when' },
|
|
2771
|
+
lastRun > 0
|
|
2772
|
+
? `${t('memory.maintainedAt')} ${new Date(lastRun).toLocaleString()}`
|
|
2773
|
+
: t('memory.maintainedNever'),
|
|
2774
|
+
),
|
|
2775
|
+
lastReport
|
|
2776
|
+
? h(
|
|
2777
|
+
'span',
|
|
2778
|
+
{ className: 'dss-maint-report' },
|
|
2779
|
+
`${t('memory.maintainedScored')} ${lastReport.scored} · ` +
|
|
2780
|
+
`${t('memory.maintainedCompressed')} ${lastReport.compressed} · ` +
|
|
2781
|
+
`${t('memory.maintainedRules')} ${lastReport.rules}`,
|
|
2782
|
+
)
|
|
2783
|
+
: null,
|
|
2784
|
+
)
|
|
2785
|
+
|
|
2786
|
+
const reasonLabel = reason =>
|
|
2787
|
+
reason === 'no-match' ? t('memory.whyNoMatch') : t('memory.whyCapacity')
|
|
2788
|
+
|
|
2789
|
+
const explainBlock = h(
|
|
2790
|
+
'div',
|
|
2791
|
+
{ className: 'dss-card' },
|
|
2792
|
+
h('div', { className: 'dss-row-title' }, t('memory.explainTitle')),
|
|
2793
|
+
h('div', { className: 'dss-hint' }, t('memory.explainHint')),
|
|
2794
|
+
h(
|
|
2795
|
+
'div',
|
|
2796
|
+
{ className: 'dss-row-actions' },
|
|
2797
|
+
h('input', {
|
|
2798
|
+
className: 'dss-input',
|
|
2799
|
+
value: explainQuery,
|
|
2800
|
+
placeholder: t('memory.explainPlaceholder'),
|
|
2801
|
+
onChange: event => setExplainQuery(event.target.value),
|
|
2802
|
+
onKeyDown: event => {
|
|
2803
|
+
if (event.key === 'Enter') runExplain()
|
|
2804
|
+
},
|
|
2805
|
+
}),
|
|
2806
|
+
h(
|
|
2807
|
+
'button',
|
|
2808
|
+
{ className: 'dss-action', disabled: explainBusy || !cwd, onClick: runExplain },
|
|
2809
|
+
explainBusy ? t('settings.loading') : t('memory.explainRun'),
|
|
2810
|
+
),
|
|
2811
|
+
),
|
|
2812
|
+
explain
|
|
2813
|
+
? h(
|
|
2814
|
+
'div',
|
|
2815
|
+
null,
|
|
2816
|
+
h(
|
|
2817
|
+
'div',
|
|
2818
|
+
{ className: 'dss-row-meta' },
|
|
2819
|
+
t('memory.explainInjected', { n: explain.items.length, limit: explain.effectiveLimit }),
|
|
2820
|
+
),
|
|
2821
|
+
explain.items.length === 0
|
|
2822
|
+
? h('div', { className: 'dss-hint' }, t('memory.explainNone'))
|
|
2823
|
+
: null,
|
|
2824
|
+
...explain.items.map(item =>
|
|
2825
|
+
h('div', { className: 'dss-memory-text', key: `in:${item.id}` }, item.text),
|
|
2826
|
+
),
|
|
2827
|
+
explain.omitted.length > 0
|
|
2828
|
+
? h(
|
|
2829
|
+
'div',
|
|
2830
|
+
{ className: 'dss-row-meta', style: { marginTop: 10 } },
|
|
2831
|
+
t('memory.explainOmitted', { n: explain.omitted.length }),
|
|
2832
|
+
)
|
|
2833
|
+
: null,
|
|
2834
|
+
...explain.omitted
|
|
2835
|
+
.slice(0, 12)
|
|
2836
|
+
.map(item =>
|
|
2837
|
+
h(
|
|
2838
|
+
'div',
|
|
2839
|
+
{ className: 'dss-row-meta', key: `out:${item.id}` },
|
|
2840
|
+
h('span', { className: 'dss-badge dss-temp' }, reasonLabel(item.reason)),
|
|
2841
|
+
h('span', null, ` ${String(item.text).slice(0, 60)}`),
|
|
2842
|
+
),
|
|
2843
|
+
),
|
|
2844
|
+
)
|
|
2845
|
+
: null,
|
|
2846
|
+
)
|
|
2847
|
+
|
|
2848
|
+
return h(
|
|
2849
|
+
'div',
|
|
2850
|
+
{ className: 'dss-section' },
|
|
2851
|
+
head,
|
|
2852
|
+
pendingBlock,
|
|
2853
|
+
explainBlock,
|
|
2854
|
+
tabs,
|
|
2855
|
+
filterBlock,
|
|
2856
|
+
workspacePicker,
|
|
2857
|
+
listBlock,
|
|
2858
|
+
addBlock,
|
|
2859
|
+
footer,
|
|
2860
|
+
maintLine,
|
|
2861
|
+
)
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// ------------------------------------------------------------------ apply
|
|
2865
|
+
|
|
2866
|
+
return {
|
|
2867
|
+
inject: ['slots', 'locale'],
|
|
2868
|
+
apply(ctx) {
|
|
2869
|
+
const t = ctx.locale.bind(NS)
|
|
2870
|
+
ctx.effect(() => ctx.locale.register(NS, DICTS))
|
|
2871
|
+
ctx.effect(installStyles)
|
|
2872
|
+
|
|
2873
|
+
/** Open another session in the main panel (used after a rewind forks one). */
|
|
2874
|
+
const openSession = sessionId => {
|
|
2875
|
+
// Remember which session the panel is showing so the memory page can
|
|
2876
|
+
// resolve that session's workspace directory.
|
|
2877
|
+
runtime.sessionId = sessionId
|
|
2878
|
+
runtime.cwd = undefined
|
|
2879
|
+
const uiWorkspace = ctx.get('uiWorkspace')
|
|
2880
|
+
if (uiWorkspace?.openSession) {
|
|
2881
|
+
uiWorkspace.openSession(sessionId)
|
|
2882
|
+
return
|
|
2883
|
+
}
|
|
2884
|
+
const layout = ctx.get('layout')
|
|
2885
|
+
layout?.selectPanel?.('conversation')
|
|
2886
|
+
}
|
|
2887
|
+
runtime.openSession = openSession
|
|
2888
|
+
// The memory page needs the workspace directory that the settings page is
|
|
2889
|
+
// actually looking at. Resolve it from the session this page was opened
|
|
2890
|
+
// for; when there is none, the page offers the Host's recorded workspace
|
|
2891
|
+
// list instead of guessing, so it never silently reads the global store.
|
|
2892
|
+
runtime.cwd = undefined
|
|
2893
|
+
const resolveRuntimeCwd = () => {
|
|
2894
|
+
if (runtime.cwd) return runtime.cwd
|
|
2895
|
+
if (runtime.sessionId) {
|
|
2896
|
+
try {
|
|
2897
|
+
const binding = ctx.get('sessions')?.binding?.(runtime.sessionId)
|
|
2898
|
+
if (binding?.cwd) runtime.cwd = binding.cwd
|
|
2899
|
+
} catch {
|
|
2900
|
+
// leave undefined; the picker offers the known workspaces instead
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
return runtime.cwd
|
|
2904
|
+
}
|
|
2905
|
+
runtime.resolveCwd = resolveRuntimeCwd
|
|
2906
|
+
runtime.archiveSession = async sessionId => {
|
|
2907
|
+
const uiWorkspace = ctx.get('uiWorkspace')
|
|
2908
|
+
if (uiWorkspace?.archiveSession) return uiWorkspace.archiveSession(sessionId, { stopActivity: true })
|
|
2909
|
+
const workspaces = ctx.get('workspaces')
|
|
2910
|
+
if (workspaces?.archiveSession) return workspaces.archiveSession(sessionId, { stopActivity: true })
|
|
2911
|
+
return undefined
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
const withT = (component, extra) => props => h(component, { ...props, t: props.t ?? t, ...extra })
|
|
2915
|
+
|
|
2916
|
+
const menuSlots = [
|
|
2917
|
+
['sidebar.workspaces.session.menu.item', 'session-suite-share', 460, ShareRow],
|
|
2918
|
+
['sidebar.workspaces.session.menu.item', 'session-suite-snapshots', 470, SnapshotsRow],
|
|
2919
|
+
['sidebar.workspaces.session.menu.item', 'session-suite-segment', 880, SegmentRow],
|
|
2920
|
+
['sidebar.workspaces.session.menu.item', 'session-suite-delete', 900, DeleteSessionRow],
|
|
2921
|
+
]
|
|
2922
|
+
for (const [name, id, order, component] of menuSlots) {
|
|
2923
|
+
ctx.slots.inject(name, () =>
|
|
2924
|
+
ctx.slots.register({ name, id, order, locale: NS }, withT(component)),
|
|
2925
|
+
)
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
ctx.slots.inject('conversation.chat.assistant-actions', () =>
|
|
2929
|
+
ctx.slots.register(
|
|
2930
|
+
{ name: 'conversation.chat.assistant-actions', id: 'session-suite-rewind', order: 20, locale: NS },
|
|
2931
|
+
withT(RewindAction),
|
|
2932
|
+
),
|
|
2933
|
+
)
|
|
2934
|
+
|
|
2935
|
+
ctx.slots.inject('conversation.input.dock', () =>
|
|
2936
|
+
ctx.slots.register(
|
|
2937
|
+
{ name: 'conversation.input.dock', id: 'session-suite-rewind-banner', order: 30, locale: NS },
|
|
2938
|
+
withT(RewindBanner, { openSession }),
|
|
2939
|
+
),
|
|
2940
|
+
)
|
|
2941
|
+
|
|
2942
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
2943
|
+
ctx.slots.register({ name: 'shell.overlay', id: 'session-suite-overlay', order: 40 }, SuiteOverlay),
|
|
2944
|
+
)
|
|
2945
|
+
|
|
2946
|
+
ctx.slots.inject('settings.section', () =>
|
|
2947
|
+
ctx.slots.register(
|
|
2948
|
+
{
|
|
2949
|
+
name: 'settings.section',
|
|
2950
|
+
id: 'session-snapshots',
|
|
2951
|
+
order: 500,
|
|
2952
|
+
locale: NS,
|
|
2953
|
+
label: () => t('settings.title'),
|
|
2954
|
+
},
|
|
2955
|
+
withT(SnapshotSection),
|
|
2956
|
+
),
|
|
2957
|
+
)
|
|
2958
|
+
|
|
2959
|
+
ctx.slots.inject('settings.section', () =>
|
|
2960
|
+
ctx.slots.register(
|
|
2961
|
+
{
|
|
2962
|
+
name: 'settings.section',
|
|
2963
|
+
id: 'long-term-memory',
|
|
2964
|
+
order: 520,
|
|
2965
|
+
locale: NS,
|
|
2966
|
+
label: () => t('memory.title'),
|
|
2967
|
+
},
|
|
2968
|
+
withT(MemorySection),
|
|
2969
|
+
),
|
|
2970
|
+
)
|
|
2971
|
+
},
|
|
2972
|
+
}
|
|
2973
|
+
},
|
|
2974
|
+
})
|