dsh-tacit 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/README.zh.md +77 -0
- package/client/client.js +1691 -0
- package/cordis.patch.yml +15 -0
- package/lib/analyze.js +893 -0
- package/lib/fold.js +300 -0
- package/lib/index.js +86 -0
- package/lib/routes.js +152 -0
- package/lib/schema.js +294 -0
- package/lib/service.js +1044 -0
- package/lib/store.js +219 -0
- package/package.json +104 -0
package/client/client.js
ADDED
|
@@ -0,0 +1,1691 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
|
|
3
|
+
/**
|
|
4
|
+
* dsh-tacit — Client half (installed package bundle entry, zero-build).
|
|
5
|
+
*
|
|
6
|
+
* Registered through window.__ModuleLoader__ like every web client bundle;
|
|
7
|
+
* `react` and the UI primitives resolve from the browser module table via the
|
|
8
|
+
* injected require. Everything else is inlined plain JavaScript.
|
|
9
|
+
*
|
|
10
|
+
* UI surface:
|
|
11
|
+
* - a "Tacit" tab in the conversation view ring (`conversation.view`
|
|
12
|
+
* slot, beside Chat/Trajectory) listing every turn's digest with an
|
|
13
|
+
* Analyze button and the analysis report;
|
|
14
|
+
* - a small ✨ Improve button in the composer tool row
|
|
15
|
+
* (`conversation.input.left`) once Tacit has learned enough patterns;
|
|
16
|
+
* - a before/after preview popup in `conversation.input.overlay` whose
|
|
17
|
+
* Apply action replaces the composer draft via `inputActions.setDraft`.
|
|
18
|
+
*
|
|
19
|
+
* Data plane: the host half pushes the trajectory digest through the
|
|
20
|
+
* harness's session-projection pipeline (`tacitTimeline`, read with the
|
|
21
|
+
* standard `useProjection` prop) and serves the model calls over the
|
|
22
|
+
* plugin's own /api/tacit/* JSON routes on the harness web server.
|
|
23
|
+
* No custom server, no bundled zod, no secrets on this side.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Non-browser imports are a no-op (the harness loads this bundle in the
|
|
27
|
+
// browser only; the guard also keeps Node-based tests of this file safe).
|
|
28
|
+
if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || typeof window.__ModuleLoader__.load !== 'function') {
|
|
29
|
+
// eslint-disable-next-line no-void
|
|
30
|
+
void 0
|
|
31
|
+
} else window.__ModuleLoader__.load({
|
|
32
|
+
id: 'dsh-tacit',
|
|
33
|
+
factory: (require) => {
|
|
34
|
+
const React = require('react')
|
|
35
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
36
|
+
const MarkdownText = primitives !== undefined && primitives.MarkdownText !== undefined
|
|
37
|
+
? primitives.MarkdownText
|
|
38
|
+
: null
|
|
39
|
+
const h = React.createElement
|
|
40
|
+
const { useState, useEffect } = React
|
|
41
|
+
|
|
42
|
+
const NS = 'dsh-tacit'
|
|
43
|
+
|
|
44
|
+
// ── i18n ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const DICT_ZH = {
|
|
47
|
+
tab: 'Tacit',
|
|
48
|
+
'status.learned': '已从 {count} 个提示词中学习',
|
|
49
|
+
'status.autoOn': '自动学习已开启 · 今日 {today}/{budget}',
|
|
50
|
+
'status.autoOff': '自动学习已关闭 — 仅在你点击「分析」时学习。',
|
|
51
|
+
'status.autoHint': '混乱的轮次(重试、工具错误、压缩)以及你纠正智能体的下一条消息会被自动分析,无需点击。',
|
|
52
|
+
'status.trendMessy': '混乱轮次:{a} → {b}',
|
|
53
|
+
'status.trendTokens': '每轮 tokens:{a} → {b}',
|
|
54
|
+
'status.trendHint': '(最早 {n} 轮 vs 最近 {n} 轮,真实数据)',
|
|
55
|
+
'turn.enrichment': '发送前补充的上下文',
|
|
56
|
+
'trigger.auto': '自动',
|
|
57
|
+
'trigger.correction': '纠正',
|
|
58
|
+
'trigger.manual': '手动',
|
|
59
|
+
'trigger.bootstrap': '引导',
|
|
60
|
+
'bootstrap.btn': '从我最近 20 轮中学习',
|
|
61
|
+
'bootstrap.running': '学习中… {done}/{total}',
|
|
62
|
+
'bootstrap.hint': '≈ $0.02,一次性。已顺利完成的提示词会被跳过。',
|
|
63
|
+
'steer.enrich': '发送前补充学到的上下文(实验性,每次发送一次小调用)',
|
|
64
|
+
'steer.title': '智能体被告知的关于你的信息',
|
|
65
|
+
'steer.desc': '这些指令会注入每个新会话的系统提示词,让智能体替你补上你通常没说的内容。',
|
|
66
|
+
'steer.toggle': '将学到的指令注入系统提示词',
|
|
67
|
+
'steer.empty': '还没有指令——分析几个提示词后会自动提炼,或在下方添加你自己的。',
|
|
68
|
+
'steer.add': '添加你自己的指令',
|
|
69
|
+
'steer.addPlaceholder': '例如:「你觉得呢」只是要意见,不要动手实现。',
|
|
70
|
+
'steer.remove': '删除',
|
|
71
|
+
'steer.preview': '实际注入的文本',
|
|
72
|
+
'steer.distilled': '自动提炼',
|
|
73
|
+
'steer.trial': '试用 {n}/{total}',
|
|
74
|
+
'steer.active': '已生效',
|
|
75
|
+
'steer.retired': '已淘汰 · {reason}',
|
|
76
|
+
'steer.user': '你添加的',
|
|
77
|
+
'settings.auto': '自动分析混乱轮次',
|
|
78
|
+
'settings.budget': '每日自动分析上限',
|
|
79
|
+
settings: '设置',
|
|
80
|
+
'settings.title': 'Tacit 设置',
|
|
81
|
+
'settings.sectionLabel': 'Tacit',
|
|
82
|
+
'panel.title': 'Tacit',
|
|
83
|
+
'panel.coached': '已分析的提示词',
|
|
84
|
+
'panel.coachedEmpty': '还没有分析过的提示词——在对话的「Tacit」标签里勾选提示词并点击「分析所选」。',
|
|
85
|
+
'panel.hint': '挑选提示词:打开任意对话,点击「Tacit」标签。',
|
|
86
|
+
'settings.model': '分析模型',
|
|
87
|
+
'settings.live': '启用输入框改进按钮',
|
|
88
|
+
'settings.apply': '应用',
|
|
89
|
+
'settings.clear': '清除所有会话的分析报告',
|
|
90
|
+
'settings.cleared': '已清除 {n} 份报告',
|
|
91
|
+
'settings.close': '收起',
|
|
92
|
+
empty: '发送消息后,每一轮对话的轨迹摘要会出现在这里——勾选你想分析的提示词,然后点击「分析所选」。',
|
|
93
|
+
'coach.selected': '分析所选({n})',
|
|
94
|
+
'coach.select': '选择提示词…',
|
|
95
|
+
'coach.selectDone': '完成选择',
|
|
96
|
+
'manual.hint': '觉得某个提示词写得不好,但那一轮却顺利完成了?可以手动分析——一次小调用。',
|
|
97
|
+
'turn.interrupted': '已中断',
|
|
98
|
+
'turn.report': '报告',
|
|
99
|
+
'coach.batchRunning': '正在分析…',
|
|
100
|
+
'turn.select': '选择此提示词以分析',
|
|
101
|
+
'turn.tools': '{n} 个工具调用',
|
|
102
|
+
'turn.steps': '{n} 步',
|
|
103
|
+
'turn.tokens': '≈{n} tokens',
|
|
104
|
+
'turn.retries': '重试 {n}',
|
|
105
|
+
'turn.expand': '展开',
|
|
106
|
+
'turn.collapse': '收起',
|
|
107
|
+
'turn.analyze': '分析',
|
|
108
|
+
'turn.analyzing': '分析中…',
|
|
109
|
+
'turn.reanalyze': '重新分析',
|
|
110
|
+
'turn.running': '进行中',
|
|
111
|
+
'report.problems': '发现的问题',
|
|
112
|
+
'report.improved': '改进后的提示词',
|
|
113
|
+
'report.explanation': '说明',
|
|
114
|
+
'report.copy': '复制',
|
|
115
|
+
'report.copied': '已复制',
|
|
116
|
+
'report.emptyImproved': '(模型未给出改写版本)',
|
|
117
|
+
'sev.high': '高',
|
|
118
|
+
'sev.medium': '中',
|
|
119
|
+
'sev.low': '低',
|
|
120
|
+
'sev.info': '提示',
|
|
121
|
+
'improve.btn': '改进提示词',
|
|
122
|
+
'preview.title': '改进预览',
|
|
123
|
+
'preview.original': '原文',
|
|
124
|
+
'preview.improved': '改进后',
|
|
125
|
+
'preview.rationale': '改动说明',
|
|
126
|
+
'preview.pending': '正在分析草稿…',
|
|
127
|
+
'preview.apply': '应用改进',
|
|
128
|
+
'preview.cancel': '取消',
|
|
129
|
+
'feedback.title': '这次改进有帮助吗?',
|
|
130
|
+
'feedback.up': '有帮助',
|
|
131
|
+
'feedback.down': '没有帮助',
|
|
132
|
+
'feedback.reasonPlaceholder': '一句话说明哪里不好…',
|
|
133
|
+
'feedback.send': '发送反馈',
|
|
134
|
+
'feedback.noted': '已记录,谢谢!',
|
|
135
|
+
'panel.styleRules': '已学习的风格规则',
|
|
136
|
+
'panel.styleRulesEmpty': '还没有学习到风格规则——应用改进后给出 👎 反馈(附一句话原因),累计 3 次即可提炼出规则。',
|
|
137
|
+
'err.bad-request': '请求无效。',
|
|
138
|
+
'err.bad-json': '请求体无效。',
|
|
139
|
+
'err.no-session': '当前会话不可用(可能尚未在本进程恢复)。',
|
|
140
|
+
'err.not-retained': '该轮次已超出保留窗口,无法分析。',
|
|
141
|
+
'err.busy': '该轮次的分析已在运行中。',
|
|
142
|
+
'err.no-llm': '无法访问模型服务。',
|
|
143
|
+
'err.no-api-key': 'DeepSeek API Key 缺失或无效(请在 设置 → 模型 中配置)。',
|
|
144
|
+
'err.rate-limited': '请求被限流,请稍后重试。',
|
|
145
|
+
'err.timeout': '模型调用超时,请重试。',
|
|
146
|
+
'err.empty-response': '模型没有返回内容,请重试。',
|
|
147
|
+
'err.call-failed': '模型调用失败:{detail}',
|
|
148
|
+
'err.network': '无法连接 Tacit 服务。',
|
|
149
|
+
'err.internal': '内部错误:{detail}',
|
|
150
|
+
'err.continuation': '这是一条纯粹的「继续」类消息(没有可分析的意图)——请分析它之前的那一轮。',
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const DICT_EN = {
|
|
154
|
+
tab: 'Tacit',
|
|
155
|
+
'status.learned': 'Learned from {count} prompt(s)',
|
|
156
|
+
'status.autoOn': 'Auto-learning on · {today}/{budget} today',
|
|
157
|
+
'status.autoOff': 'Auto-learning off — Tacit only learns when you click Analyze.',
|
|
158
|
+
'status.autoHint': 'Messy turns (retries, tool errors, compactions) and turns you correct in your next message are analyzed automatically — no clicks.',
|
|
159
|
+
'status.trendMessy': 'Messy turns: {a} → {b}',
|
|
160
|
+
'status.trendTokens': 'Tokens/turn: {a} → {b}',
|
|
161
|
+
'status.trendHint': '(first {n} turns vs. latest {n}, measured)',
|
|
162
|
+
'turn.enrichment': 'Context added before the send',
|
|
163
|
+
'trigger.auto': 'auto',
|
|
164
|
+
'trigger.correction': 'correction',
|
|
165
|
+
'trigger.manual': 'manual',
|
|
166
|
+
'trigger.bootstrap': 'bootstrap',
|
|
167
|
+
'bootstrap.btn': 'Learn from my last 20 turns',
|
|
168
|
+
'bootstrap.running': 'Learning… {done}/{total}',
|
|
169
|
+
'bootstrap.hint': '≈ $0.02, one time. Skips prompts that already went fine on their own.',
|
|
170
|
+
'steer.enrich': 'Add learned context before each send (experimental — one small call per send)',
|
|
171
|
+
'steer.title': 'What the agent is told about you',
|
|
172
|
+
'steer.desc': 'These directives ride every new session\'s system prompt so the agent fills in what you usually leave unsaid.',
|
|
173
|
+
'steer.toggle': 'Inject learned directives into the system prompt',
|
|
174
|
+
'steer.empty': 'No directives yet — they are distilled automatically after a few analyses, or add your own below.',
|
|
175
|
+
'steer.add': 'Add your own directive',
|
|
176
|
+
'steer.addPlaceholder': 'e.g. "What do you think" means opinion only — do not build anything.',
|
|
177
|
+
'steer.remove': 'Remove',
|
|
178
|
+
'steer.preview': 'Exact text injected',
|
|
179
|
+
'steer.distilled': 'distilled',
|
|
180
|
+
'steer.trial': 'trial {n}/{total}',
|
|
181
|
+
'steer.active': 'active',
|
|
182
|
+
'steer.retired': 'retired · {reason}',
|
|
183
|
+
'steer.user': 'yours',
|
|
184
|
+
'settings.auto': 'Auto-analyze messy turns',
|
|
185
|
+
'settings.budget': 'Daily cap on automatic analyses',
|
|
186
|
+
settings: 'Settings',
|
|
187
|
+
'settings.title': 'Tacit settings',
|
|
188
|
+
'settings.sectionLabel': 'Tacit',
|
|
189
|
+
'panel.title': 'Tacit',
|
|
190
|
+
'panel.coached': 'Analyzed prompts',
|
|
191
|
+
'panel.coachedEmpty': 'No analyzed prompts yet — tick prompts in the Tacit tab of a conversation and click "Analyze selected".',
|
|
192
|
+
'panel.hint': 'To pick prompts: open any conversation and click the "Tacit" tab.',
|
|
193
|
+
'settings.model': 'Analysis model',
|
|
194
|
+
'settings.live': 'Enable the composer Improve button',
|
|
195
|
+
'settings.apply': 'Apply',
|
|
196
|
+
'settings.clear': 'Clear all analysis reports (every session)',
|
|
197
|
+
'settings.cleared': 'Cleared {n} report(s)',
|
|
198
|
+
'settings.close': 'Collapse',
|
|
199
|
+
empty: 'After you send a message, each turn\'s trajectory digest appears here — tick the prompts you want analyzed, then click "Analyze selected".',
|
|
200
|
+
'coach.selected': 'Analyze selected ({n})',
|
|
201
|
+
'coach.select': 'Select prompts…',
|
|
202
|
+
'coach.selectDone': 'Done selecting',
|
|
203
|
+
'manual.hint': 'Think a prompt was weak even though the turn went fine? Analyze it manually — one small call.',
|
|
204
|
+
'turn.interrupted': 'interrupted',
|
|
205
|
+
'turn.report': 'Report',
|
|
206
|
+
'coach.batchRunning': 'Analyzing…',
|
|
207
|
+
'turn.select': 'Select this prompt to analyze',
|
|
208
|
+
'turn.tools': '{n} tool calls',
|
|
209
|
+
'turn.steps': '{n} steps',
|
|
210
|
+
'turn.tokens': '≈{n} tokens',
|
|
211
|
+
'turn.retries': 'retries {n}',
|
|
212
|
+
'turn.expand': 'Expand',
|
|
213
|
+
'turn.collapse': 'Collapse',
|
|
214
|
+
'turn.analyze': 'Analyze',
|
|
215
|
+
'turn.analyzing': 'Analyzing…',
|
|
216
|
+
'turn.reanalyze': 'Re-analyze',
|
|
217
|
+
'turn.running': 'In progress',
|
|
218
|
+
'report.problems': 'Problems found',
|
|
219
|
+
'report.improved': 'Improved prompt',
|
|
220
|
+
'report.explanation': 'Explanation',
|
|
221
|
+
'report.copy': 'Copy',
|
|
222
|
+
'report.copied': 'Copied',
|
|
223
|
+
'report.emptyImproved': '(the model returned no rewrite)',
|
|
224
|
+
'sev.high': 'High',
|
|
225
|
+
'sev.medium': 'Medium',
|
|
226
|
+
'sev.low': 'Low',
|
|
227
|
+
'sev.info': 'Note',
|
|
228
|
+
'improve.btn': 'Improve prompt',
|
|
229
|
+
'preview.title': 'Improvement preview',
|
|
230
|
+
'preview.original': 'Original',
|
|
231
|
+
'preview.improved': 'Improved',
|
|
232
|
+
'preview.rationale': 'What changed',
|
|
233
|
+
'preview.pending': 'Analyzing your draft…',
|
|
234
|
+
'preview.apply': 'Apply improvement',
|
|
235
|
+
'preview.cancel': 'Cancel',
|
|
236
|
+
'feedback.title': 'Was this better?',
|
|
237
|
+
'feedback.up': 'Yes',
|
|
238
|
+
'feedback.down': 'No',
|
|
239
|
+
'feedback.reasonPlaceholder': 'What was wrong? (one line)',
|
|
240
|
+
'feedback.send': 'Send feedback',
|
|
241
|
+
'feedback.noted': 'Noted — thanks!',
|
|
242
|
+
'panel.styleRules': 'Learned style rules',
|
|
243
|
+
'panel.styleRulesEmpty': 'No style rules yet — apply an improvement and rate it 👎 with a one-line reason; after 3, Tacit distills durable rules from them.',
|
|
244
|
+
'err.bad-request': 'Invalid request.',
|
|
245
|
+
'err.bad-json': 'Invalid request body.',
|
|
246
|
+
'err.no-session': 'The session is not available (it may not be restored in this process).',
|
|
247
|
+
'err.not-retained': 'This turn is outside the retained history window and cannot be analyzed.',
|
|
248
|
+
'err.busy': 'An analysis for this turn is already running.',
|
|
249
|
+
'err.no-llm': 'The model service is unavailable.',
|
|
250
|
+
'err.no-api-key': 'The DeepSeek API key is missing or invalid (configure it in Settings → Models).',
|
|
251
|
+
'err.rate-limited': 'The request was rate-limited; try again shortly.',
|
|
252
|
+
'err.timeout': 'The model call timed out; please retry.',
|
|
253
|
+
'err.empty-response': 'The model returned no content; please retry.',
|
|
254
|
+
'err.call-failed': 'The model call failed: {detail}',
|
|
255
|
+
'err.network': 'Could not reach the Tacit service.',
|
|
256
|
+
'err.internal': 'Internal error: {detail}',
|
|
257
|
+
'err.continuation': 'This is a bare continuation ("continue", "go ahead") with nothing to analyze — analyze the turn before it instead.',
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── API client (the host half's own routes on the harness origin) ──────
|
|
261
|
+
|
|
262
|
+
async function api(pathName, payload) {
|
|
263
|
+
const response = await fetch('/api/tacit' + pathName, {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'content-type': 'application/json' },
|
|
266
|
+
body: JSON.stringify(payload === undefined ? {} : payload),
|
|
267
|
+
})
|
|
268
|
+
let data = null
|
|
269
|
+
try {
|
|
270
|
+
data = await response.json()
|
|
271
|
+
} catch {
|
|
272
|
+
data = null
|
|
273
|
+
}
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
// Server envelopes carry a code (forbidden, bad-request, internal…);
|
|
276
|
+
// surface it so the dictionary can explain instead of a generic "network".
|
|
277
|
+
const error = new Error('http ' + String(response.status))
|
|
278
|
+
error.code = data !== null && typeof data === 'object' && typeof data.code === 'string' && data.code.length > 0 ? data.code : 'network'
|
|
279
|
+
error.detail = data !== null && typeof data === 'object' && typeof data.detail === 'string' ? data.detail : ''
|
|
280
|
+
throw error
|
|
281
|
+
}
|
|
282
|
+
if (data === null || typeof data !== 'object') throw new Error('bad response')
|
|
283
|
+
return data
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** {code, detail} for the UI from any thrown value (network failures → 'network'). */
|
|
287
|
+
function errorOf(error) {
|
|
288
|
+
return {
|
|
289
|
+
code: error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : 'network',
|
|
290
|
+
detail: error !== null && typeof error === 'object' && typeof error.detail === 'string' ? error.detail : '',
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Shared per-session store (tab + composer button + preview) ─────────
|
|
295
|
+
|
|
296
|
+
function createSessionStore(sessionId) {
|
|
297
|
+
return {
|
|
298
|
+
sessionId,
|
|
299
|
+
config: null,
|
|
300
|
+
profile: null,
|
|
301
|
+
auto: null, // {today, budget}
|
|
302
|
+
bootstrap: null, // {running, done, total}
|
|
303
|
+
reports: {},
|
|
304
|
+
inFlight: {}, // String(turn) -> true
|
|
305
|
+
selection: new Set(), // turn numbers ticked for batch analysis
|
|
306
|
+
selecting: false, // selection mode (checkboxes + batch button) shown?
|
|
307
|
+
expanded: new Set(), // turn numbers whose report is unfolded
|
|
308
|
+
batchRunning: false,
|
|
309
|
+
preview: { open: false, pending: false, original: '', data: null, error: null },
|
|
310
|
+
// Feedback strip state for the last APPLIED improve response:
|
|
311
|
+
// {open, verdict: null|'up'|'down', reason, sending, noted, rewriteId, fading}
|
|
312
|
+
feedback: { open: false, verdict: null, reason: '', sending: false, noted: false, rewriteId: null, fading: false },
|
|
313
|
+
error: null, // transient {code, detail} for the tab
|
|
314
|
+
notice: null, // transient {code} after a successful settings action
|
|
315
|
+
initStarted: false,
|
|
316
|
+
initDone: false,
|
|
317
|
+
listeners: new Set(),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const sessionStores = new Map()
|
|
322
|
+
function storeFor(sessionId) {
|
|
323
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null
|
|
324
|
+
let store = sessionStores.get(sessionId)
|
|
325
|
+
if (store === undefined) {
|
|
326
|
+
store = createSessionStore(sessionId)
|
|
327
|
+
sessionStores.set(sessionId, store)
|
|
328
|
+
}
|
|
329
|
+
return store
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function notify(store) {
|
|
333
|
+
for (const listener of store.listeners) listener()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function useStoreVersion(store) {
|
|
337
|
+
const [version, setVersion] = useState(0)
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
if (store === null) return undefined
|
|
340
|
+
const listener = () => setVersion((value) => value + 1)
|
|
341
|
+
store.listeners.add(listener)
|
|
342
|
+
return () => {
|
|
343
|
+
store.listeners.delete(listener)
|
|
344
|
+
}
|
|
345
|
+
}, [store])
|
|
346
|
+
return version
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Root store (sidebar action + settings section + frame overlay) ─────
|
|
350
|
+
|
|
351
|
+
const rootStore = {
|
|
352
|
+
config: null,
|
|
353
|
+
profile: null,
|
|
354
|
+
auto: null,
|
|
355
|
+
steering: null, // {enabled, text}
|
|
356
|
+
trend: null, // measured early-vs-recent trend
|
|
357
|
+
bootstrap: null, // {running, done, total}
|
|
358
|
+
coached: [], // cross-session coached-prompt entries
|
|
359
|
+
initStarted: false,
|
|
360
|
+
initDone: false,
|
|
361
|
+
error: null,
|
|
362
|
+
listeners: new Set(),
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function notifyRoot() {
|
|
366
|
+
for (const listener of rootStore.listeners) listener()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function useRootVersion() {
|
|
370
|
+
const [version, setVersion] = useState(0)
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
const listener = () => setVersion((value) => value + 1)
|
|
373
|
+
rootStore.listeners.add(listener)
|
|
374
|
+
return () => {
|
|
375
|
+
rootStore.listeners.delete(listener)
|
|
376
|
+
}
|
|
377
|
+
}, [])
|
|
378
|
+
return version
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function initRootStore() {
|
|
382
|
+
if (rootStore.initStarted) return
|
|
383
|
+
rootStore.initStarted = true
|
|
384
|
+
try {
|
|
385
|
+
const state = await api('/state', {})
|
|
386
|
+
if (state !== null && typeof state === 'object') {
|
|
387
|
+
rootStore.config = state.config !== null && typeof state.config === 'object' ? state.config : null
|
|
388
|
+
rootStore.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : null
|
|
389
|
+
rootStore.auto = state.auto !== null && typeof state.auto === 'object' ? state.auto : null
|
|
390
|
+
rootStore.steering = state.steering !== null && typeof state.steering === 'object' ? state.steering : null
|
|
391
|
+
rootStore.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
|
|
392
|
+
}
|
|
393
|
+
const coached = await api('/history', { limit: 50 })
|
|
394
|
+
if (coached !== null && typeof coached === 'object' && coached.ok && Array.isArray(coached.entries)) {
|
|
395
|
+
rootStore.coached = coached.entries
|
|
396
|
+
}
|
|
397
|
+
const stats = await api('/stats', {})
|
|
398
|
+
if (stats !== null && typeof stats === 'object' && stats.ok && stats.trend !== null && typeof stats.trend === 'object') {
|
|
399
|
+
rootStore.trend = stats.trend
|
|
400
|
+
}
|
|
401
|
+
rootStore.initDone = true
|
|
402
|
+
} catch (error) {
|
|
403
|
+
rootStore.error = errorOf(error)
|
|
404
|
+
rootStore.initDone = true
|
|
405
|
+
}
|
|
406
|
+
notifyRoot()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Re-fetch profile/config so freshly distilled style rules show up. */
|
|
410
|
+
async function refreshRootState() {
|
|
411
|
+
try {
|
|
412
|
+
const state = await api('/state', {})
|
|
413
|
+
if (state !== null && typeof state === 'object') {
|
|
414
|
+
rootStore.config = state.config !== null && typeof state.config === 'object' ? state.config : null
|
|
415
|
+
rootStore.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : null
|
|
416
|
+
rootStore.auto = state.auto !== null && typeof state.auto === 'object' ? state.auto : null
|
|
417
|
+
rootStore.steering = state.steering !== null && typeof state.steering === 'object' ? state.steering : null
|
|
418
|
+
rootStore.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// Stale view beats a broken panel.
|
|
422
|
+
}
|
|
423
|
+
notifyRoot()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function updateRootConfig(patch) {
|
|
427
|
+
rootStore.error = null
|
|
428
|
+
try {
|
|
429
|
+
const result = await api('/config', { patch })
|
|
430
|
+
if (result !== null && typeof result === 'object' && result.ok && result.config !== null && typeof result.config === 'object') {
|
|
431
|
+
rootStore.config = result.config
|
|
432
|
+
} else {
|
|
433
|
+
rootStore.error = {
|
|
434
|
+
code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request',
|
|
435
|
+
detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
} catch (error) {
|
|
439
|
+
rootStore.error = errorOf(error)
|
|
440
|
+
}
|
|
441
|
+
notifyRoot()
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function clearAllRoot() {
|
|
445
|
+
rootStore.error = null
|
|
446
|
+
try {
|
|
447
|
+
const result = await api('/clear', {})
|
|
448
|
+
if (result !== null && typeof result === 'object' && result.ok) {
|
|
449
|
+
rootStore.coached = []
|
|
450
|
+
} else {
|
|
451
|
+
rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request', detail: '' }
|
|
452
|
+
}
|
|
453
|
+
} catch (error) {
|
|
454
|
+
rootStore.error = errorOf(error)
|
|
455
|
+
}
|
|
456
|
+
notifyRoot()
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Poll /state while a bootstrap runs so the counter moves; `apply` receives each snapshot. */
|
|
460
|
+
function pollBootstrap(apply) {
|
|
461
|
+
const tick = async () => {
|
|
462
|
+
try {
|
|
463
|
+
const state = await api('/state', {})
|
|
464
|
+
const running = state !== null && typeof state === 'object' && state.bootstrap !== null && typeof state.bootstrap === 'object' && state.bootstrap.running === true
|
|
465
|
+
apply(state)
|
|
466
|
+
if (running) setTimeout(tick, 2000)
|
|
467
|
+
} catch {
|
|
468
|
+
// Give up quietly; the final response of the bootstrap call still lands.
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
setTimeout(tick, 1500)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function bootstrapSession(store) {
|
|
475
|
+
if (store.bootstrap !== null && typeof store.bootstrap === 'object' && store.bootstrap.running) return
|
|
476
|
+
store.bootstrap = { running: true, done: 0, total: 0 }
|
|
477
|
+
store.error = null
|
|
478
|
+
notify(store)
|
|
479
|
+
pollBootstrap((state) => {
|
|
480
|
+
if (state !== null && typeof state === 'object' && state.bootstrap !== null && typeof state.bootstrap === 'object') store.bootstrap = state.bootstrap
|
|
481
|
+
notify(store)
|
|
482
|
+
})
|
|
483
|
+
try {
|
|
484
|
+
const result = await api('/bootstrap', { sessionId: store.sessionId, limit: 20 })
|
|
485
|
+
if (!(result !== null && typeof result === 'object' && result.ok)) {
|
|
486
|
+
store.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
|
|
487
|
+
}
|
|
488
|
+
const reports = await api('/reports', { sessionId: store.sessionId })
|
|
489
|
+
if (reports !== null && typeof reports === 'object' && reports.ok && reports.reports !== null && typeof reports.reports === 'object') store.reports = reports.reports
|
|
490
|
+
const state = await api('/state', {})
|
|
491
|
+
if (state !== null && typeof state === 'object') {
|
|
492
|
+
store.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : store.profile
|
|
493
|
+
store.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
store.error = errorOf(error)
|
|
497
|
+
store.bootstrap = null
|
|
498
|
+
}
|
|
499
|
+
notify(store)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function bootstrapAll() {
|
|
503
|
+
if (rootStore.bootstrap !== null && typeof rootStore.bootstrap === 'object' && rootStore.bootstrap.running) return
|
|
504
|
+
rootStore.bootstrap = { running: true, done: 0, total: 0 }
|
|
505
|
+
rootStore.error = null
|
|
506
|
+
notifyRoot()
|
|
507
|
+
pollBootstrap((state) => {
|
|
508
|
+
if (state !== null && typeof state === 'object' && state.bootstrap !== null && typeof state.bootstrap === 'object') rootStore.bootstrap = state.bootstrap
|
|
509
|
+
notifyRoot()
|
|
510
|
+
})
|
|
511
|
+
try {
|
|
512
|
+
const result = await api('/bootstrap', { limit: 20 })
|
|
513
|
+
if (!(result !== null && typeof result === 'object' && result.ok)) {
|
|
514
|
+
rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
|
|
515
|
+
}
|
|
516
|
+
} catch (error) {
|
|
517
|
+
rootStore.error = errorOf(error)
|
|
518
|
+
}
|
|
519
|
+
rootStore.initStarted = false
|
|
520
|
+
rootStore.initDone = false
|
|
521
|
+
await initRootStore()
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function editDirectives(payload) {
|
|
525
|
+
rootStore.error = null
|
|
526
|
+
try {
|
|
527
|
+
const result = await api('/directives', payload)
|
|
528
|
+
if (result !== null && typeof result === 'object' && result.ok) {
|
|
529
|
+
if (result.profile !== null && typeof result.profile === 'object') rootStore.profile = result.profile
|
|
530
|
+
if (result.steering !== null && typeof result.steering === 'object') rootStore.steering = result.steering
|
|
531
|
+
} else {
|
|
532
|
+
rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request', detail: '' }
|
|
533
|
+
}
|
|
534
|
+
} catch (error) {
|
|
535
|
+
rootStore.error = errorOf(error)
|
|
536
|
+
}
|
|
537
|
+
notifyRoot()
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function initStore(store) {
|
|
541
|
+
if (store === null || store.initStarted) return
|
|
542
|
+
store.initStarted = true
|
|
543
|
+
try {
|
|
544
|
+
const state = await api('/state', {})
|
|
545
|
+
if (state !== null && typeof state === 'object') {
|
|
546
|
+
store.config = state.config !== null && typeof state.config === 'object' ? state.config : null
|
|
547
|
+
store.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : null
|
|
548
|
+
store.auto = state.auto !== null && typeof state.auto === 'object' ? state.auto : null
|
|
549
|
+
store.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
|
|
550
|
+
}
|
|
551
|
+
const reports = await api('/reports', { sessionId: store.sessionId })
|
|
552
|
+
if (reports !== null && typeof reports === 'object' && reports.ok && reports.reports !== null && typeof reports.reports === 'object') {
|
|
553
|
+
store.reports = reports.reports
|
|
554
|
+
}
|
|
555
|
+
store.initDone = true
|
|
556
|
+
} catch (error) {
|
|
557
|
+
store.error = errorOf(error)
|
|
558
|
+
store.initDone = true
|
|
559
|
+
}
|
|
560
|
+
notify(store)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function analyzeTurn(store, turn) {
|
|
564
|
+
if (store.inFlight[String(turn)]) return
|
|
565
|
+
store.inFlight[String(turn)] = true
|
|
566
|
+
store.expanded.add(turn)
|
|
567
|
+
store.error = null
|
|
568
|
+
notify(store)
|
|
569
|
+
api('/analyze', { sessionId: store.sessionId, turn })
|
|
570
|
+
.then((result) => {
|
|
571
|
+
if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
|
|
572
|
+
store.reports[String(turn)] = result.report
|
|
573
|
+
store.error = null
|
|
574
|
+
} else {
|
|
575
|
+
store.error = {
|
|
576
|
+
code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
|
|
577
|
+
detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (result !== null && typeof result === 'object' && result.profile !== null && typeof result.profile === 'object') {
|
|
581
|
+
store.profile = result.profile
|
|
582
|
+
}
|
|
583
|
+
})
|
|
584
|
+
.catch((error) => {
|
|
585
|
+
store.error = errorOf(error)
|
|
586
|
+
})
|
|
587
|
+
.finally(() => {
|
|
588
|
+
delete store.inFlight[String(turn)]
|
|
589
|
+
notify(store)
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Run one /analyze call and settle when it finishes (batch building block). */
|
|
594
|
+
function analyzeTurnAsync(store, turn) {
|
|
595
|
+
return new Promise((resolve) => {
|
|
596
|
+
store.inFlight[String(turn)] = true
|
|
597
|
+
notify(store)
|
|
598
|
+
api('/analyze', { sessionId: store.sessionId, turn })
|
|
599
|
+
.then((result) => {
|
|
600
|
+
if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
|
|
601
|
+
store.reports[String(turn)] = result.report
|
|
602
|
+
store.selection.delete(turn)
|
|
603
|
+
store.expanded.add(turn)
|
|
604
|
+
store.error = null
|
|
605
|
+
} else {
|
|
606
|
+
store.error = {
|
|
607
|
+
code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
|
|
608
|
+
detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (result !== null && typeof result === 'object' && result.profile !== null && typeof result.profile === 'object') {
|
|
612
|
+
store.profile = result.profile
|
|
613
|
+
}
|
|
614
|
+
})
|
|
615
|
+
.catch((error) => {
|
|
616
|
+
store.error = errorOf(error)
|
|
617
|
+
})
|
|
618
|
+
.finally(() => {
|
|
619
|
+
delete store.inFlight[String(turn)]
|
|
620
|
+
notify(store)
|
|
621
|
+
resolve()
|
|
622
|
+
})
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Coach every ticked prompt sequentially (the user-chosen 20). */
|
|
627
|
+
async function coachSelected(store) {
|
|
628
|
+
if (store.batchRunning) return
|
|
629
|
+
const turns = [...store.selection].sort((a, b) => a - b)
|
|
630
|
+
if (turns.length === 0) return
|
|
631
|
+
store.batchRunning = true
|
|
632
|
+
store.error = null
|
|
633
|
+
notify(store)
|
|
634
|
+
for (const turn of turns) {
|
|
635
|
+
if (store.inFlight[String(turn)]) continue
|
|
636
|
+
await analyzeTurnAsync(store, turn)
|
|
637
|
+
}
|
|
638
|
+
store.batchRunning = false
|
|
639
|
+
store.selecting = false
|
|
640
|
+
notify(store)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function toggleSelecting(store) {
|
|
644
|
+
store.selecting = !store.selecting
|
|
645
|
+
if (!store.selecting) store.selection.clear()
|
|
646
|
+
notify(store)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function toggleReport(store, turn) {
|
|
650
|
+
if (store.expanded.has(turn)) store.expanded.delete(turn)
|
|
651
|
+
else store.expanded.add(turn)
|
|
652
|
+
notify(store)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function improveDraft(store, draft) {
|
|
656
|
+
if (store.preview.open || typeof draft !== 'string' || draft.trim().length === 0) return
|
|
657
|
+
store.preview = { open: true, pending: true, original: draft, data: null, error: null }
|
|
658
|
+
notify(store)
|
|
659
|
+
api('/improve', { sessionId: store.sessionId, draft })
|
|
660
|
+
.then((result) => {
|
|
661
|
+
if (result !== null && typeof result === 'object' && result.ok && typeof result.improved === 'string' && result.improved.trim().length > 0) {
|
|
662
|
+
store.preview = { ...store.preview, pending: false, data: result }
|
|
663
|
+
} else {
|
|
664
|
+
store.preview = {
|
|
665
|
+
...store.preview,
|
|
666
|
+
pending: false,
|
|
667
|
+
error: {
|
|
668
|
+
code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
|
|
669
|
+
detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
|
|
670
|
+
},
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
})
|
|
674
|
+
.catch((error) => {
|
|
675
|
+
store.preview = { ...store.preview, pending: false, error: errorOf(error) }
|
|
676
|
+
})
|
|
677
|
+
.finally(() => notify(store))
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Is the live improve feature enabled for this store? (No learning gate.) */
|
|
681
|
+
function storeReady(store) {
|
|
682
|
+
if (store === null || !store.initDone) return false
|
|
683
|
+
const config = configOf(store.config)
|
|
684
|
+
return config !== null && config.liveSuggestions !== false
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function applyImproved(store, inputActions) {
|
|
688
|
+
const data = store.preview.data
|
|
689
|
+
if (data !== null && typeof data === 'object' && typeof data.improved === 'string'
|
|
690
|
+
&& inputActions !== undefined && inputActions !== null && typeof inputActions.setDraft === 'function') {
|
|
691
|
+
inputActions.setDraft(data.improved)
|
|
692
|
+
}
|
|
693
|
+
// The feedback strip rides every applied rewrite while the feature is on.
|
|
694
|
+
const rewriteId = data !== null && typeof data === 'object' && typeof data.rewriteId === 'string' && data.rewriteId.length > 0
|
|
695
|
+
? data.rewriteId
|
|
696
|
+
: null
|
|
697
|
+
if (storeReady(store) && rewriteId !== null) {
|
|
698
|
+
store.feedback = { open: true, verdict: null, reason: '', sending: false, noted: false, rewriteId, fading: false }
|
|
699
|
+
// Free bookkeeping on an existing call: host bumps `applied` and
|
|
700
|
+
// captures the verification baseline for the next finished turn.
|
|
701
|
+
api('/applied', { sessionId: store.sessionId, rewriteId }).catch(() => {})
|
|
702
|
+
} else {
|
|
703
|
+
store.feedback = { ...store.feedback, open: false }
|
|
704
|
+
}
|
|
705
|
+
store.preview = { open: false, pending: false, original: '', data: null, error: null }
|
|
706
|
+
notify(store)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function closeFeedback(store) {
|
|
710
|
+
store.feedback = { open: false, verdict: null, reason: '', sending: false, noted: false, rewriteId: null, fading: false }
|
|
711
|
+
notify(store)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** The strip fades out after the next send (observed via the input machine phase). */
|
|
715
|
+
function fadeFeedback(store) {
|
|
716
|
+
if (!store.feedback.open || store.feedback.fading) return
|
|
717
|
+
store.feedback = { ...store.feedback, fading: true }
|
|
718
|
+
notify(store)
|
|
719
|
+
setTimeout(() => {
|
|
720
|
+
if (store.feedback.open && store.feedback.fading) closeFeedback(store)
|
|
721
|
+
}, 350)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** 👍 posts immediately; 👎 expands the one-line reason field first. */
|
|
725
|
+
function voteFeedback(store, verdict) {
|
|
726
|
+
if (!store.feedback.open || store.feedback.sending || store.feedback.rewriteId === null) return
|
|
727
|
+
if (verdict === 'down') {
|
|
728
|
+
store.feedback = { ...store.feedback, verdict: 'down' }
|
|
729
|
+
notify(store)
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
sendFeedback(store, 'up')
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function sendFeedback(store, verdict) {
|
|
736
|
+
if (!store.feedback.open || store.feedback.sending || store.feedback.rewriteId === null) return
|
|
737
|
+
const reason = verdict === 'down' ? store.feedback.reason.trim() : ''
|
|
738
|
+
if (verdict === 'down' && reason.length === 0) return
|
|
739
|
+
store.feedback = { ...store.feedback, sending: true }
|
|
740
|
+
notify(store)
|
|
741
|
+
const payload = { rewriteId: store.feedback.rewriteId, verdict }
|
|
742
|
+
if (verdict === 'down') payload.reason = reason.slice(0, 300)
|
|
743
|
+
api('/feedback', payload)
|
|
744
|
+
.then((result) => {
|
|
745
|
+
if (result !== null && typeof result === 'object' && result.ok && result.profile !== null && typeof result.profile === 'object') {
|
|
746
|
+
store.profile = result.profile
|
|
747
|
+
store.feedback = { ...store.feedback, sending: false, noted: true }
|
|
748
|
+
notify(store)
|
|
749
|
+
setTimeout(() => {
|
|
750
|
+
if (store.feedback.noted) closeFeedback(store)
|
|
751
|
+
}, 1500)
|
|
752
|
+
} else {
|
|
753
|
+
closeFeedback(store)
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
.catch(() => closeFeedback(store))
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function closePreview(store) {
|
|
760
|
+
store.preview = { open: false, pending: false, original: '', data: null, error: null }
|
|
761
|
+
notify(store)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function updateConfig(store, patch) {
|
|
765
|
+
try {
|
|
766
|
+
const result = await api('/config', { patch })
|
|
767
|
+
if (result !== null && typeof result === 'object' && result.ok && result.config !== null && typeof result.config === 'object') {
|
|
768
|
+
store.config = result.config
|
|
769
|
+
} else {
|
|
770
|
+
store.error = {
|
|
771
|
+
code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request',
|
|
772
|
+
detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
} catch (error) {
|
|
776
|
+
store.error = errorOf(error)
|
|
777
|
+
}
|
|
778
|
+
notify(store)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function clearReports(store) {
|
|
782
|
+
store.error = null
|
|
783
|
+
try {
|
|
784
|
+
const result = await api('/clear', {})
|
|
785
|
+
if (result !== null && typeof result === 'object' && result.ok) {
|
|
786
|
+
store.reports = {}
|
|
787
|
+
store.notice = { code: 'settings.cleared', n: typeof result.removed === 'number' ? result.removed : 0 }
|
|
788
|
+
} else {
|
|
789
|
+
store.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request', detail: '' }
|
|
790
|
+
}
|
|
791
|
+
} catch (error) {
|
|
792
|
+
store.error = errorOf(error)
|
|
793
|
+
}
|
|
794
|
+
notify(store)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── Formatting & defensive narrowing ───────────────────────────────────
|
|
798
|
+
|
|
799
|
+
function fmt(n) {
|
|
800
|
+
const value = Number(n)
|
|
801
|
+
if (!Number.isFinite(value)) return '—'
|
|
802
|
+
const sign = value < 0 ? '-' : ''
|
|
803
|
+
const abs = Math.abs(value)
|
|
804
|
+
if (abs >= 1e6) return sign + (abs / 1e6).toFixed(1) + 'M'
|
|
805
|
+
if (abs >= 1e3) return sign + (abs / 1e3).toFixed(1) + 'k'
|
|
806
|
+
return sign + String(Math.round(abs))
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function fmtTime(ms) {
|
|
810
|
+
const date = new Date(Number(ms))
|
|
811
|
+
if (Number.isNaN(date.getTime())) return '—'
|
|
812
|
+
return date.toLocaleTimeString('en-GB', { hour12: false })
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function turnsOf(value) {
|
|
816
|
+
if (value === null || typeof value !== 'object' || !Array.isArray(value.turns)) return []
|
|
817
|
+
return value.turns.filter((turn) => turn !== null && typeof turn === 'object' && typeof turn.turn === 'number')
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function profileOf(value) {
|
|
821
|
+
if (value === null || typeof value !== 'object') return null
|
|
822
|
+
return {
|
|
823
|
+
analyzedCount: typeof value.analyzedCount === 'number' ? value.analyzedCount : 0,
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function configOf(value) {
|
|
828
|
+
if (value === null || typeof value !== 'object') return null
|
|
829
|
+
return value
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function reportOf(value) {
|
|
833
|
+
if (value === null || typeof value !== 'object') return null
|
|
834
|
+
const problems = Array.isArray(value.problems)
|
|
835
|
+
? value.problems.filter((p) => p !== null && typeof p === 'object')
|
|
836
|
+
: []
|
|
837
|
+
return {
|
|
838
|
+
ok: value.ok === true,
|
|
839
|
+
turn: typeof value.turn === 'number' ? value.turn : 0,
|
|
840
|
+
model: typeof value.model === 'string' ? value.model : '',
|
|
841
|
+
problems,
|
|
842
|
+
improvedPrompt: typeof value.improvedPrompt === 'string' ? value.improvedPrompt : '',
|
|
843
|
+
explanation: typeof value.explanation === 'string' ? value.explanation : '',
|
|
844
|
+
trigger: value.trigger === 'auto' || value.trigger === 'correction' || value.trigger === 'bootstrap' ? value.trigger : 'manual',
|
|
845
|
+
followUp: typeof value.followUp === 'string' ? value.followUp : '',
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function copyText(text) {
|
|
850
|
+
if (navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
|
|
851
|
+
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text))
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
fallbackCopy(text)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function fallbackCopy(text) {
|
|
858
|
+
try {
|
|
859
|
+
const textarea = document.createElement('textarea')
|
|
860
|
+
textarea.value = text
|
|
861
|
+
textarea.style.position = 'fixed'
|
|
862
|
+
textarea.style.opacity = '0'
|
|
863
|
+
document.body.appendChild(textarea)
|
|
864
|
+
textarea.select()
|
|
865
|
+
document.execCommand('copy')
|
|
866
|
+
document.body.removeChild(textarea)
|
|
867
|
+
} catch {
|
|
868
|
+
// Clipboard unavailable; the text remains selectable in the card.
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── Components ─────────────────────────────────────────────────────────
|
|
873
|
+
|
|
874
|
+
function makeKit(t) {
|
|
875
|
+
return { t, fmt, fmtTime }
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** "Learned from N prompts · Auto-learning on · x/y today" status card. */
|
|
879
|
+
/** "Learn from my last 20 turns" — spinner label while a bootstrap runs. */
|
|
880
|
+
function BootstrapButton(kit, { bootstrap, onClick }) {
|
|
881
|
+
const { t } = kit
|
|
882
|
+
const running = bootstrap !== null && bootstrap !== undefined && typeof bootstrap === 'object' && bootstrap.running === true
|
|
883
|
+
return h('span', { className: 'tacit-bootstrap' },
|
|
884
|
+
h('button', {
|
|
885
|
+
type: 'button',
|
|
886
|
+
className: 'tacit-btn tacit-btn-sm',
|
|
887
|
+
disabled: running,
|
|
888
|
+
title: t('bootstrap.hint'),
|
|
889
|
+
onClick,
|
|
890
|
+
}, running
|
|
891
|
+
? t('bootstrap.running', { done: String(typeof bootstrap.done === 'number' ? bootstrap.done : 0), total: String(typeof bootstrap.total === 'number' ? bootstrap.total : 0) })
|
|
892
|
+
: t('bootstrap.btn')))
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function pct(value) {
|
|
896
|
+
return String(Math.round((Number(value) || 0) * 100)) + '%'
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function StatusCard(kit, { config, profile, auto, trend }) {
|
|
900
|
+
const { t } = kit
|
|
901
|
+
const hasTrend = trend !== null && trend !== undefined && typeof trend === 'object' && trend.enough === true
|
|
902
|
+
&& trend.early !== null && typeof trend.early === 'object' && trend.recent !== null && typeof trend.recent === 'object'
|
|
903
|
+
const analyzed = profile !== null ? profile.analyzedCount : 0
|
|
904
|
+
const autoOn = config !== null && config.autoAnalyze !== false
|
|
905
|
+
const today = auto !== null && typeof auto === 'object' && typeof auto.today === 'number' ? auto.today : 0
|
|
906
|
+
const budget = auto !== null && typeof auto === 'object' && typeof auto.budget === 'number'
|
|
907
|
+
? auto.budget
|
|
908
|
+
: (config !== null && typeof config.autoDailyBudget === 'number' ? config.autoDailyBudget : 30)
|
|
909
|
+
return h('div', { className: 'tacit-progress' },
|
|
910
|
+
h('div', { className: 'tacit-progress-head' },
|
|
911
|
+
h('span', { className: 'tacit-progress-title' }, t('status.learned', { count: String(analyzed) })),
|
|
912
|
+
h('span', { className: 'tacit-progress-count' }, autoOn ? t('status.autoOn', { today: String(today), budget: String(budget) }) : '')),
|
|
913
|
+
h('div', { className: 'tacit-progress-text' }, autoOn ? t('status.autoHint') : t('status.autoOff')),
|
|
914
|
+
hasTrend
|
|
915
|
+
? h('div', { className: 'tacit-trend' },
|
|
916
|
+
h('span', { className: 'tacit-chip' }, t('status.trendMessy', { a: pct(trend.early.messyRate), b: pct(trend.recent.messyRate) })),
|
|
917
|
+
h('span', { className: 'tacit-chip' }, t('status.trendTokens', { a: fmt(trend.early.tokensPerTurn), b: fmt(trend.recent.tokensPerTurn) })),
|
|
918
|
+
h('span', { className: 'tacit-progress-text' }, t('status.trendHint', { n: String(trend.window) })))
|
|
919
|
+
: null)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function ProblemRow(kit, problem, index) {
|
|
923
|
+
const { t } = kit
|
|
924
|
+
const severity = String(problem.severity === 'high' || problem.severity === 'medium' || problem.severity === 'low' ? problem.severity : 'info')
|
|
925
|
+
const sevClass = 'tacit-sev-' + (severity === 'info' ? 'info' : severity)
|
|
926
|
+
return h('div', { key: 'p' + index, className: 'tacit-problem' },
|
|
927
|
+
h('div', { className: 'tacit-problem-head' },
|
|
928
|
+
h('span', { className: 'tacit-problem-kind' }, String(problem.kind || 'general')),
|
|
929
|
+
h('span', { className: 'tacit-problem-sev ' + sevClass }, t('sev.' + severity))),
|
|
930
|
+
h('div', { className: 'tacit-problem-what' }, String(problem.what || '')),
|
|
931
|
+
h('div', { className: 'tacit-problem-why' }, String(problem.why || '')))
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function ReportCard(props) {
|
|
935
|
+
const { kit, report } = props
|
|
936
|
+
const { t } = kit
|
|
937
|
+
const [copied, setCopied] = useState(false)
|
|
938
|
+
if (report === null) return null
|
|
939
|
+
const improved = report.improvedPrompt
|
|
940
|
+
const onCopy = () => {
|
|
941
|
+
copyText(improved)
|
|
942
|
+
setCopied(true)
|
|
943
|
+
setTimeout(() => setCopied(false), 1500)
|
|
944
|
+
}
|
|
945
|
+
return h('div', { className: 'tacit-report' },
|
|
946
|
+
h('div', { className: 'tacit-report-title' },
|
|
947
|
+
t('report.problems'),
|
|
948
|
+
' ',
|
|
949
|
+
h('span', { className: 'tacit-chip tacit-chip-trigger' }, t('trigger.' + report.trigger))),
|
|
950
|
+
report.problems.length === 0
|
|
951
|
+
? h('div', { className: 'tacit-report-note' }, '—')
|
|
952
|
+
: report.problems.map((problem, index) => ProblemRow(kit, problem, index)),
|
|
953
|
+
h('div', { className: 'tacit-report-title' }, t('report.improved')),
|
|
954
|
+
improved.length > 0
|
|
955
|
+
? h('div', { className: 'tacit-report-improved' },
|
|
956
|
+
h('div', { className: 'tacit-report-actions' },
|
|
957
|
+
h('button', {
|
|
958
|
+
type: 'button',
|
|
959
|
+
className: 'tacit-btn tacit-btn-sm',
|
|
960
|
+
onClick: onCopy,
|
|
961
|
+
}, copied ? t('report.copied') : t('report.copy'))),
|
|
962
|
+
h('div', { className: 'tacit-report-body' },
|
|
963
|
+
MarkdownText !== null
|
|
964
|
+
? h(MarkdownText, { text: improved })
|
|
965
|
+
: h('pre', { className: 'tacit-pre' }, improved)))
|
|
966
|
+
: h('div', { className: 'tacit-report-note' }, t('report.emptyImproved')),
|
|
967
|
+
typeof report.explanation === 'string' && report.explanation.length > 0
|
|
968
|
+
? h('div', null,
|
|
969
|
+
h('div', { className: 'tacit-report-title' }, t('report.explanation')),
|
|
970
|
+
h('div', { className: 'tacit-report-body' },
|
|
971
|
+
MarkdownText !== null
|
|
972
|
+
? h(MarkdownText, { text: report.explanation })
|
|
973
|
+
: h('pre', { className: 'tacit-pre' }, report.explanation)))
|
|
974
|
+
: null)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function TurnRow(props) {
|
|
978
|
+
const { kit, store, turn, isNewest } = props
|
|
979
|
+
const { t, fmtTime } = kit
|
|
980
|
+
const [expanded, setExpanded] = useState(false)
|
|
981
|
+
const analyzing = store.inFlight[String(turn.turn)] === true
|
|
982
|
+
const report = reportOf(store.reports[String(turn.turn)])
|
|
983
|
+
const selected = store.selection.has(turn.turn)
|
|
984
|
+
const reportOpen = store.expanded.has(turn.turn)
|
|
985
|
+
const usage = turn.usage !== null && typeof turn.usage === 'object' ? turn.usage : {}
|
|
986
|
+
const tokens = (typeof usage.inputTokens === 'number' ? usage.inputTokens : 0)
|
|
987
|
+
+ (typeof usage.outputTokens === 'number' ? usage.outputTokens : 0)
|
|
988
|
+
const tools = Array.isArray(turn.toolCalls) ? turn.toolCalls.length : 0
|
|
989
|
+
const prompt = typeof turn.prompt === 'string' ? turn.prompt : ''
|
|
990
|
+
const promptPreview = prompt.length > 120 ? prompt.slice(0, 120) + '…' : prompt
|
|
991
|
+
|
|
992
|
+
const toggleSelected = () => {
|
|
993
|
+
if (selected) store.selection.delete(turn.turn)
|
|
994
|
+
else store.selection.add(turn.turn)
|
|
995
|
+
notify(store)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// A turn without turn/end is live only when it is the newest one; an
|
|
999
|
+
// older unfinished turn belonged to a session that was interrupted.
|
|
1000
|
+
const status = turn.finished === true
|
|
1001
|
+
? null
|
|
1002
|
+
: (isNewest === true
|
|
1003
|
+
? h('span', { className: 'tacit-row-live' }, t('turn.running'))
|
|
1004
|
+
: h('span', { className: 'tacit-chip tacit-chip-muted' }, t('turn.interrupted')))
|
|
1005
|
+
|
|
1006
|
+
return h('div', { className: 'tacit-row' + (report !== null ? ' tacit-row-analyzed' : '') },
|
|
1007
|
+
h('div', { className: 'tacit-row-head' },
|
|
1008
|
+
store.selecting
|
|
1009
|
+
? h('input', {
|
|
1010
|
+
type: 'checkbox',
|
|
1011
|
+
className: 'tacit-check',
|
|
1012
|
+
checked: selected,
|
|
1013
|
+
title: t('turn.select'),
|
|
1014
|
+
onChange: toggleSelected,
|
|
1015
|
+
})
|
|
1016
|
+
: null,
|
|
1017
|
+
h('div', { className: 'tacit-row-heading' },
|
|
1018
|
+
h('span', { className: 'tacit-row-turn' }, '# ' + String(turn.turn)),
|
|
1019
|
+
h('span', { className: 'tacit-row-time' }, fmtTime(turn.startedAt)),
|
|
1020
|
+
report !== null ? h('span', { className: 'tacit-chip tacit-chip-trigger' }, t('trigger.' + report.trigger)) : null,
|
|
1021
|
+
status),
|
|
1022
|
+
h('div', { className: 'tacit-row-chips' },
|
|
1023
|
+
h('span', { className: 'tacit-chip' }, t('turn.tools', { n: String(tools) })),
|
|
1024
|
+
h('span', { className: 'tacit-chip' }, t('turn.steps', { n: String(typeof turn.steps === 'number' ? turn.steps : 0) })),
|
|
1025
|
+
h('span', { className: 'tacit-chip' }, t('turn.tokens', { n: fmt(tokens) })),
|
|
1026
|
+
typeof turn.retries === 'number' && turn.retries > 0
|
|
1027
|
+
? h('span', { className: 'tacit-chip tacit-chip-warn' }, t('turn.retries', { n: String(turn.retries) }))
|
|
1028
|
+
: null,
|
|
1029
|
+
report !== null
|
|
1030
|
+
? h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => toggleReport(store, turn.turn) },
|
|
1031
|
+
t('turn.report') + (reportOpen ? ' ▾' : ' ▸'))
|
|
1032
|
+
: null,
|
|
1033
|
+
h('button', {
|
|
1034
|
+
type: 'button',
|
|
1035
|
+
className: 'tacit-btn tacit-btn-sm tacit-btn-quiet',
|
|
1036
|
+
disabled: analyzing,
|
|
1037
|
+
onClick: () => analyzeTurn(store, turn.turn),
|
|
1038
|
+
}, analyzing ? t('turn.analyzing') : (report !== null ? t('turn.reanalyze') : t('turn.analyze'))))),
|
|
1039
|
+
prompt.length > 0
|
|
1040
|
+
? h('div', { className: 'tacit-row-prompt' },
|
|
1041
|
+
h('button', {
|
|
1042
|
+
type: 'button',
|
|
1043
|
+
className: 'tacit-row-prompt-text',
|
|
1044
|
+
onClick: () => setExpanded((open) => !open),
|
|
1045
|
+
}, expanded ? prompt : promptPreview),
|
|
1046
|
+
prompt.length > 120
|
|
1047
|
+
? h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => setExpanded((open) => !open) },
|
|
1048
|
+
expanded ? t('turn.collapse') : t('turn.expand'))
|
|
1049
|
+
: null)
|
|
1050
|
+
: null,
|
|
1051
|
+
typeof turn.enrichment === 'string' && turn.enrichment.length > 0
|
|
1052
|
+
? h('div', { className: 'tacit-row-enrichment' },
|
|
1053
|
+
h('span', { className: 'tacit-report-title' }, t('turn.enrichment')),
|
|
1054
|
+
h('div', { className: 'tacit-pre' }, turn.enrichment))
|
|
1055
|
+
: null,
|
|
1056
|
+
report !== null && reportOpen ? h(ReportCard, { kit, report }) : null)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function SettingsPanel(props) {
|
|
1060
|
+
const { kit, store } = props
|
|
1061
|
+
const { t } = kit
|
|
1062
|
+
const config = configOf(store.config)
|
|
1063
|
+
const [model, setModel] = useState(config !== null && typeof config.model === 'string' ? config.model : 'deepseek-v4-flash')
|
|
1064
|
+
const [budgetText, setBudgetText] = useState(config !== null && typeof config.autoDailyBudget === 'number' ? String(config.autoDailyBudget) : '30')
|
|
1065
|
+
const [auto, setAuto] = useState(config !== null && config.autoAnalyze !== false)
|
|
1066
|
+
const [live, setLive] = useState(config !== null && config.liveSuggestions !== false)
|
|
1067
|
+
const [notice, setNotice] = useState(null)
|
|
1068
|
+
|
|
1069
|
+
useEffect(() => {
|
|
1070
|
+
if (config !== null) {
|
|
1071
|
+
if (typeof config.model === 'string') setModel(config.model)
|
|
1072
|
+
if (typeof config.autoDailyBudget === 'number') setBudgetText(String(config.autoDailyBudget))
|
|
1073
|
+
if (typeof config.autoAnalyze === 'boolean') setAuto(config.autoAnalyze)
|
|
1074
|
+
if (typeof config.liveSuggestions === 'boolean') setLive(config.liveSuggestions)
|
|
1075
|
+
}
|
|
1076
|
+
}, [config])
|
|
1077
|
+
|
|
1078
|
+
const applyModel = (value) => {
|
|
1079
|
+
setModel(value)
|
|
1080
|
+
updateConfig(store, { model: value })
|
|
1081
|
+
}
|
|
1082
|
+
const applyBudget = () => {
|
|
1083
|
+
const number = Math.max(0, Math.min(1000, Math.round(Number(budgetText) || 0)))
|
|
1084
|
+
setBudgetText(String(number))
|
|
1085
|
+
updateConfig(store, { autoDailyBudget: number })
|
|
1086
|
+
}
|
|
1087
|
+
const toggleAuto = () => {
|
|
1088
|
+
const next = !auto
|
|
1089
|
+
setAuto(next)
|
|
1090
|
+
updateConfig(store, { autoAnalyze: next })
|
|
1091
|
+
}
|
|
1092
|
+
const toggleLive = () => {
|
|
1093
|
+
const next = !live
|
|
1094
|
+
setLive(next)
|
|
1095
|
+
updateConfig(store, { liveSuggestions: next })
|
|
1096
|
+
}
|
|
1097
|
+
const onClear = async () => {
|
|
1098
|
+
await clearReports(store)
|
|
1099
|
+
if (store.notice !== null && store.notice.code === 'settings.cleared') {
|
|
1100
|
+
setNotice(t(store.notice.code, { n: String(store.notice.n) }))
|
|
1101
|
+
setTimeout(() => setNotice(null), 3000)
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return h('div', { className: 'tacit-settings' },
|
|
1106
|
+
h('div', { className: 'tacit-settings-title' }, t('settings.title')),
|
|
1107
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1108
|
+
h('label', { className: 'tacit-settings-label' }, t('settings.model')),
|
|
1109
|
+
h('select', {
|
|
1110
|
+
className: 'tacit-select',
|
|
1111
|
+
value: model,
|
|
1112
|
+
onChange: (event) => applyModel(event.target.value),
|
|
1113
|
+
},
|
|
1114
|
+
h('option', { value: 'deepseek-v4-flash' }, 'deepseek-v4-flash'),
|
|
1115
|
+
h('option', { value: 'deepseek-v4-pro' }, 'deepseek-v4-pro'))),
|
|
1116
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1117
|
+
h('label', { className: 'tacit-settings-label' }, t('settings.auto')),
|
|
1118
|
+
h('input', { type: 'checkbox', checked: auto, onChange: toggleAuto })),
|
|
1119
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1120
|
+
h('label', { className: 'tacit-settings-label' }, t('settings.budget')),
|
|
1121
|
+
h('input', {
|
|
1122
|
+
className: 'tacit-input',
|
|
1123
|
+
type: 'number',
|
|
1124
|
+
min: 0,
|
|
1125
|
+
value: budgetText,
|
|
1126
|
+
onChange: (event) => setBudgetText(event.target.value),
|
|
1127
|
+
}),
|
|
1128
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: applyBudget }, t('settings.apply'))),
|
|
1129
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1130
|
+
h('label', { className: 'tacit-settings-label' }, t('settings.live')),
|
|
1131
|
+
h('input', { type: 'checkbox', checked: live, onChange: toggleLive })),
|
|
1132
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1133
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: onClear }, t('settings.clear')),
|
|
1134
|
+
notice !== null ? h('span', { className: 'tacit-settings-notice' }, notice) : null))
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function CoachTab(kit) {
|
|
1138
|
+
const { t } = kit
|
|
1139
|
+
return function CoachTabView(props) {
|
|
1140
|
+
const sessionId = props.sessionId
|
|
1141
|
+
const store = storeFor(sessionId)
|
|
1142
|
+
useStoreVersion(store)
|
|
1143
|
+
useEffect(() => {
|
|
1144
|
+
if (store !== null) initStore(store)
|
|
1145
|
+
}, [store])
|
|
1146
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
1147
|
+
|
|
1148
|
+
if (store === null) {
|
|
1149
|
+
return h('div', { className: 'tacit-root' }, h('div', { className: 'tacit-empty' }, t('err.no-session')))
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const rawTurns = typeof props.useProjection === 'function' ? props.useProjection('tacitTimeline') : undefined
|
|
1153
|
+
const turns = turnsOf(rawTurns).slice().reverse()
|
|
1154
|
+
const config = configOf(store.config)
|
|
1155
|
+
const profile = profileOf(store.profile)
|
|
1156
|
+
const selectedCount = store.selection.size
|
|
1157
|
+
const error = store.error
|
|
1158
|
+
|
|
1159
|
+
return h('div', { className: 'tacit-root' },
|
|
1160
|
+
h('div', { className: 'tacit-head' },
|
|
1161
|
+
h('div', { className: 'tacit-head-title' }, t('tab')),
|
|
1162
|
+
h('button', {
|
|
1163
|
+
type: 'button',
|
|
1164
|
+
className: 'tacit-btn',
|
|
1165
|
+
onClick: () => setSettingsOpen((open) => !open),
|
|
1166
|
+
}, settingsOpen ? t('settings.close') : t('settings'))),
|
|
1167
|
+
StatusCard(kit, { config, profile, auto: store.auto }),
|
|
1168
|
+
settingsOpen ? h(SettingsPanel, { kit, store }) : null,
|
|
1169
|
+
error !== null && typeof error === 'object'
|
|
1170
|
+
? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
|
|
1171
|
+
: null,
|
|
1172
|
+
turns.length === 0
|
|
1173
|
+
? h('div', { className: 'tacit-empty' }, t('empty'))
|
|
1174
|
+
: h('div', null,
|
|
1175
|
+
h('div', { className: 'tacit-toolbar' },
|
|
1176
|
+
h('span', { className: 'tacit-panel-hint tacit-toolbar-hint' }, t('manual.hint')),
|
|
1177
|
+
BootstrapButton(kit, { bootstrap: store.bootstrap, onClick: () => bootstrapSession(store) }),
|
|
1178
|
+
h('button', {
|
|
1179
|
+
type: 'button',
|
|
1180
|
+
className: 'tacit-btn tacit-btn-sm',
|
|
1181
|
+
onClick: () => toggleSelecting(store),
|
|
1182
|
+
}, store.selecting ? t('coach.selectDone') : t('coach.select')),
|
|
1183
|
+
store.selecting
|
|
1184
|
+
? h('button', {
|
|
1185
|
+
type: 'button',
|
|
1186
|
+
className: 'tacit-btn tacit-btn-sm tacit-btn-primary',
|
|
1187
|
+
disabled: selectedCount === 0 || store.batchRunning,
|
|
1188
|
+
onClick: () => coachSelected(store),
|
|
1189
|
+
}, store.batchRunning ? t('coach.batchRunning') : t('coach.selected', { n: String(selectedCount) }))
|
|
1190
|
+
: null),
|
|
1191
|
+
turns.map((turn, index) => h(TurnRow, { key: String(turn.turn), kit, store, turn, isNewest: index === 0 }))))
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function ImproveButton(kit) {
|
|
1196
|
+
const { t } = kit
|
|
1197
|
+
return function ImproveButtonView(props) {
|
|
1198
|
+
const sessionId = props.sessionId
|
|
1199
|
+
const store = storeFor(sessionId)
|
|
1200
|
+
useStoreVersion(store)
|
|
1201
|
+
useEffect(() => {
|
|
1202
|
+
if (store !== null) initStore(store)
|
|
1203
|
+
}, [store])
|
|
1204
|
+
|
|
1205
|
+
if (!storeReady(store)) return null
|
|
1206
|
+
|
|
1207
|
+
const draft = props.input !== null && typeof props.input === 'object' && typeof props.input.draft === 'string'
|
|
1208
|
+
? props.input.draft
|
|
1209
|
+
: ''
|
|
1210
|
+
|
|
1211
|
+
return h('button', {
|
|
1212
|
+
type: 'button',
|
|
1213
|
+
className: 'tacit-improve-btn',
|
|
1214
|
+
title: t('improve.btn'),
|
|
1215
|
+
onClick: () => improveDraft(store, draft),
|
|
1216
|
+
}, '✨ ' + t('improve.btn'))
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function PreviewOverlay(kit) {
|
|
1221
|
+
const { t } = kit
|
|
1222
|
+
return function PreviewOverlayView(props) {
|
|
1223
|
+
const hooks = props.hooks !== null && typeof props.hooks === 'object' ? props.hooks : null
|
|
1224
|
+
const store = hooks !== null ? hooks.tacitStore : null
|
|
1225
|
+
useStoreVersion(store)
|
|
1226
|
+
if (store === null || !store.preview.open) return null
|
|
1227
|
+
|
|
1228
|
+
const preview = store.preview
|
|
1229
|
+
const onApply = () => applyImproved(store, props.inputActions)
|
|
1230
|
+
const onCancel = () => closePreview(store)
|
|
1231
|
+
|
|
1232
|
+
return h('div', { className: 'tacit-modal-backdrop' },
|
|
1233
|
+
h('div', { className: 'tacit-modal-card' },
|
|
1234
|
+
h('div', { className: 'tacit-modal-head' },
|
|
1235
|
+
h('span', { className: 'tacit-modal-title' }, t('preview.title')),
|
|
1236
|
+
h('button', { type: 'button', className: 'tacit-modal-close', onClick: onCancel }, '×')),
|
|
1237
|
+
preview.pending
|
|
1238
|
+
? h('div', { className: 'tacit-modal-pending' }, t('preview.pending'))
|
|
1239
|
+
: preview.error !== null
|
|
1240
|
+
? h('div', { className: 'tacit-error' },
|
|
1241
|
+
t('err.' + String(preview.error.code), { detail: String(preview.error.detail || '') }))
|
|
1242
|
+
: h('div', null,
|
|
1243
|
+
h('div', { className: 'tacit-modal-cols' },
|
|
1244
|
+
h('div', { className: 'tacit-modal-col' },
|
|
1245
|
+
h('div', { className: 'tacit-modal-col-title' }, t('preview.original')),
|
|
1246
|
+
h('pre', { className: 'tacit-pre' }, preview.original)),
|
|
1247
|
+
h('div', { className: 'tacit-modal-col' },
|
|
1248
|
+
h('div', { className: 'tacit-modal-col-title' }, t('preview.improved')),
|
|
1249
|
+
h('pre', { className: 'tacit-pre' }, preview.data !== null && typeof preview.data.improved === 'string' ? preview.data.improved : ''))),
|
|
1250
|
+
preview.data !== null && typeof preview.data === 'object' && typeof preview.data.rationale === 'string' && preview.data.rationale.length > 0
|
|
1251
|
+
? h('div', { className: 'tacit-modal-rationale' },
|
|
1252
|
+
h('div', { className: 'tacit-modal-col-title' }, t('preview.rationale')),
|
|
1253
|
+
h('div', { className: 'tacit-modal-rationale-text' }, preview.data.rationale))
|
|
1254
|
+
: null,
|
|
1255
|
+
h('div', { className: 'tacit-modal-actions' },
|
|
1256
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-primary', onClick: onApply }, t('preview.apply')),
|
|
1257
|
+
h('button', { type: 'button', className: 'tacit-btn', onClick: onCancel }, t('preview.cancel'))))))
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// ── Feedback strip (under the composer, live stage only) ────────────────
|
|
1262
|
+
|
|
1263
|
+
function FeedbackStrip(kit) {
|
|
1264
|
+
const { t } = kit
|
|
1265
|
+
return function FeedbackStripView(props) {
|
|
1266
|
+
const store = storeFor(props.sessionId)
|
|
1267
|
+
useStoreVersion(store)
|
|
1268
|
+
useEffect(() => {
|
|
1269
|
+
if (store !== null) initStore(store)
|
|
1270
|
+
}, [store])
|
|
1271
|
+
// The input machine's phase: 'submitting' means the user sent — the
|
|
1272
|
+
// strip fades out right after the next send.
|
|
1273
|
+
const phase = typeof props.useInput === 'function'
|
|
1274
|
+
? props.useInput((state) => (state === null || typeof state !== 'object' ? '' : state.phase))
|
|
1275
|
+
: ''
|
|
1276
|
+
useEffect(() => {
|
|
1277
|
+
if (phase === 'submitting' && store !== null && store.feedback.open && !store.feedback.sending) {
|
|
1278
|
+
fadeFeedback(store)
|
|
1279
|
+
}
|
|
1280
|
+
}, [phase, store])
|
|
1281
|
+
|
|
1282
|
+
if (store === null || !store.feedback.open) return null
|
|
1283
|
+
const feedback = store.feedback
|
|
1284
|
+
const reason = feedback.reason
|
|
1285
|
+
|
|
1286
|
+
return h('div', {
|
|
1287
|
+
className: 'tacit-feedback' + (feedback.fading ? ' tacit-feedback-fading' : ''),
|
|
1288
|
+
'data-testid': 'tacit-feedback',
|
|
1289
|
+
},
|
|
1290
|
+
feedback.noted
|
|
1291
|
+
? h('span', { className: 'tacit-feedback-noted' }, t('feedback.noted'))
|
|
1292
|
+
: h('div', { className: 'tacit-feedback-row' },
|
|
1293
|
+
h('span', { className: 'tacit-feedback-title' }, t('feedback.title')),
|
|
1294
|
+
h('button', {
|
|
1295
|
+
type: 'button',
|
|
1296
|
+
className: 'tacit-feedback-vote',
|
|
1297
|
+
title: t('feedback.up'),
|
|
1298
|
+
'aria-label': t('feedback.up'),
|
|
1299
|
+
onClick: () => voteFeedback(store, 'up'),
|
|
1300
|
+
}, '👍'),
|
|
1301
|
+
h('button', {
|
|
1302
|
+
type: 'button',
|
|
1303
|
+
className: 'tacit-feedback-vote' + (feedback.verdict === 'down' ? ' tacit-feedback-vote-active' : ''),
|
|
1304
|
+
title: t('feedback.down'),
|
|
1305
|
+
'aria-label': t('feedback.down'),
|
|
1306
|
+
onClick: () => voteFeedback(store, 'down'),
|
|
1307
|
+
}, '👎'),
|
|
1308
|
+
feedback.verdict === 'down'
|
|
1309
|
+
? h('div', { className: 'tacit-feedback-reason' },
|
|
1310
|
+
h('input', {
|
|
1311
|
+
className: 'tacit-input tacit-feedback-input',
|
|
1312
|
+
type: 'text',
|
|
1313
|
+
maxLength: 300,
|
|
1314
|
+
placeholder: t('feedback.reasonPlaceholder'),
|
|
1315
|
+
value: reason,
|
|
1316
|
+
onChange: (event) => {
|
|
1317
|
+
store.feedback = { ...store.feedback, reason: event.target.value }
|
|
1318
|
+
notify(store)
|
|
1319
|
+
},
|
|
1320
|
+
}),
|
|
1321
|
+
h('button', {
|
|
1322
|
+
type: 'button',
|
|
1323
|
+
className: 'tacit-btn tacit-btn-sm',
|
|
1324
|
+
disabled: feedback.sending || reason.trim().length === 0,
|
|
1325
|
+
onClick: () => sendFeedback(store, 'down'),
|
|
1326
|
+
}, feedback.sending ? '…' : t('feedback.send')))
|
|
1327
|
+
: null))
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// ── Global panel (settings section, sidebar action, frame overlay) ─────
|
|
1332
|
+
|
|
1333
|
+
function DirectivesEditor(kit) {
|
|
1334
|
+
const { t } = kit
|
|
1335
|
+
return function DirectivesEditorView(props) {
|
|
1336
|
+
const { config, directives, steering } = props
|
|
1337
|
+
const [draft, setDraft] = useState('')
|
|
1338
|
+
const [steerOn, setSteerOn] = useState(config !== null && config.steerAgent !== false)
|
|
1339
|
+
const [enrichOn, setEnrichOn] = useState(config !== null && config.enrichPrompts === true)
|
|
1340
|
+
useEffect(() => {
|
|
1341
|
+
if (config !== null && typeof config.steerAgent === 'boolean') setSteerOn(config.steerAgent)
|
|
1342
|
+
if (config !== null && typeof config.enrichPrompts === 'boolean') setEnrichOn(config.enrichPrompts)
|
|
1343
|
+
}, [config])
|
|
1344
|
+
const toggleSteer = () => {
|
|
1345
|
+
const next = !steerOn
|
|
1346
|
+
setSteerOn(next)
|
|
1347
|
+
updateRootConfig({ steerAgent: next })
|
|
1348
|
+
}
|
|
1349
|
+
const toggleEnrich = () => {
|
|
1350
|
+
const next = !enrichOn
|
|
1351
|
+
setEnrichOn(next)
|
|
1352
|
+
updateRootConfig({ enrichPrompts: next })
|
|
1353
|
+
}
|
|
1354
|
+
const onAdd = () => {
|
|
1355
|
+
const text = draft.trim()
|
|
1356
|
+
if (text.length === 0) return
|
|
1357
|
+
setDraft('')
|
|
1358
|
+
editDirectives({ action: 'add', text: text.slice(0, 300) })
|
|
1359
|
+
}
|
|
1360
|
+
return h('div', { className: 'tacit-panel-section', 'data-testid': 'tacit-steering' },
|
|
1361
|
+
h('div', { className: 'tacit-report-title' }, t('steer.title')),
|
|
1362
|
+
h('div', { className: 'tacit-panel-hint' }, t('steer.desc')),
|
|
1363
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1364
|
+
h('label', { className: 'tacit-settings-label' }, t('steer.toggle')),
|
|
1365
|
+
h('input', { type: 'checkbox', checked: steerOn, onChange: toggleSteer })),
|
|
1366
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1367
|
+
h('label', { className: 'tacit-settings-label' }, t('steer.enrich')),
|
|
1368
|
+
h('input', { type: 'checkbox', checked: enrichOn, onChange: toggleEnrich })),
|
|
1369
|
+
directives.length === 0
|
|
1370
|
+
? h('div', { className: 'tacit-empty' }, t('steer.empty'))
|
|
1371
|
+
: h('div', { className: 'tacit-rules-list' },
|
|
1372
|
+
directives.map((entry) => h('div', { key: entry.id, className: 'tacit-rule tacit-directive' + (entry.enabled === false ? ' tacit-directive-off' : '') },
|
|
1373
|
+
h('input', {
|
|
1374
|
+
type: 'checkbox',
|
|
1375
|
+
className: 'tacit-check',
|
|
1376
|
+
'data-testid': 'tacit-directive-toggle',
|
|
1377
|
+
checked: entry.enabled !== false,
|
|
1378
|
+
onChange: () => editDirectives({ action: 'toggle', id: entry.id, enabled: entry.enabled === false }),
|
|
1379
|
+
}),
|
|
1380
|
+
h('span', { className: 'tacit-directive-text' }, String(entry.text)),
|
|
1381
|
+
h('span', { className: 'tacit-chip' }, entry.source === 'user' ? t('steer.user') : t('steer.distilled')),
|
|
1382
|
+
entry.status === 'candidate'
|
|
1383
|
+
? h('span', { className: 'tacit-chip tacit-chip-trial' }, t('steer.trial', {
|
|
1384
|
+
n: String(entry.trial !== null && typeof entry.trial === 'object' && typeof entry.trial.turns === 'number' ? entry.trial.turns : 0),
|
|
1385
|
+
total: String(config !== null && typeof config.directiveTrialTurns === 'number' ? config.directiveTrialTurns : 10),
|
|
1386
|
+
}))
|
|
1387
|
+
: entry.status === 'retired'
|
|
1388
|
+
? h('span', { className: 'tacit-chip tacit-chip-warn' }, t('steer.retired', { reason: String(entry.retiredReason || '') }))
|
|
1389
|
+
: h('span', { className: 'tacit-chip tacit-chip-ok' }, t('steer.active')),
|
|
1390
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => editDirectives({ action: 'remove', id: entry.id }) }, t('steer.remove'))))),
|
|
1391
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1392
|
+
h('input', {
|
|
1393
|
+
className: 'tacit-input tacit-directive-input',
|
|
1394
|
+
type: 'text',
|
|
1395
|
+
maxLength: 300,
|
|
1396
|
+
placeholder: t('steer.addPlaceholder'),
|
|
1397
|
+
'aria-label': t('steer.add'),
|
|
1398
|
+
value: draft,
|
|
1399
|
+
onChange: (event) => setDraft(event.target.value),
|
|
1400
|
+
onKeyDown: (event) => {
|
|
1401
|
+
if (event.key === 'Enter') onAdd()
|
|
1402
|
+
},
|
|
1403
|
+
}),
|
|
1404
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', disabled: draft.trim().length === 0, onClick: onAdd }, t('steer.add'))),
|
|
1405
|
+
steering !== null && typeof steering === 'object' && typeof steering.text === 'string' && steering.text.length > 0
|
|
1406
|
+
? h('details', { className: 'tacit-preview' },
|
|
1407
|
+
h('summary', null, t('steer.preview')),
|
|
1408
|
+
h('pre', { className: 'tacit-pre' }, steering.text))
|
|
1409
|
+
: null)
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function CoachPanel(kit) {
|
|
1414
|
+
const { t, fmtTime } = kit
|
|
1415
|
+
const DirectivesEditorView = DirectivesEditor(kit)
|
|
1416
|
+
return function CoachPanelView() {
|
|
1417
|
+
useRootVersion()
|
|
1418
|
+
useEffect(() => {
|
|
1419
|
+
initRootStore()
|
|
1420
|
+
}, [])
|
|
1421
|
+
|
|
1422
|
+
const config = configOf(rootStore.config)
|
|
1423
|
+
const profile = profileOf(rootStore.profile)
|
|
1424
|
+
const [model, setModel] = useState(config !== null && typeof config.model === 'string' ? config.model : 'deepseek-v4-flash')
|
|
1425
|
+
const [budgetText, setBudgetText] = useState(config !== null && typeof config.autoDailyBudget === 'number' ? String(config.autoDailyBudget) : '30')
|
|
1426
|
+
const [auto, setAuto] = useState(config !== null && config.autoAnalyze !== false)
|
|
1427
|
+
const [live, setLive] = useState(config !== null && config.liveSuggestions !== false)
|
|
1428
|
+
|
|
1429
|
+
useEffect(() => {
|
|
1430
|
+
if (config !== null) {
|
|
1431
|
+
if (typeof config.model === 'string') setModel(config.model)
|
|
1432
|
+
if (typeof config.autoDailyBudget === 'number') setBudgetText(String(config.autoDailyBudget))
|
|
1433
|
+
if (typeof config.autoAnalyze === 'boolean') setAuto(config.autoAnalyze)
|
|
1434
|
+
if (typeof config.liveSuggestions === 'boolean') setLive(config.liveSuggestions)
|
|
1435
|
+
}
|
|
1436
|
+
}, [config])
|
|
1437
|
+
|
|
1438
|
+
const applyModel = (value) => {
|
|
1439
|
+
setModel(value)
|
|
1440
|
+
updateRootConfig({ model: value })
|
|
1441
|
+
}
|
|
1442
|
+
const applyBudget = () => {
|
|
1443
|
+
const number = Math.max(0, Math.min(1000, Math.round(Number(budgetText) || 0)))
|
|
1444
|
+
setBudgetText(String(number))
|
|
1445
|
+
updateRootConfig({ autoDailyBudget: number })
|
|
1446
|
+
}
|
|
1447
|
+
const toggleAuto = () => {
|
|
1448
|
+
const next = !auto
|
|
1449
|
+
setAuto(next)
|
|
1450
|
+
updateRootConfig({ autoAnalyze: next })
|
|
1451
|
+
}
|
|
1452
|
+
const toggleLive = () => {
|
|
1453
|
+
const next = !live
|
|
1454
|
+
setLive(next)
|
|
1455
|
+
updateRootConfig({ liveSuggestions: next })
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
const coached = Array.isArray(rootStore.coached) ? rootStore.coached : []
|
|
1459
|
+
const error = rootStore.error
|
|
1460
|
+
const styleRules = rootStore.profile !== null && typeof rootStore.profile === 'object' && Array.isArray(rootStore.profile.styleRules)
|
|
1461
|
+
? rootStore.profile.styleRules.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.rule === 'string' && entry.rule.length > 0)
|
|
1462
|
+
: []
|
|
1463
|
+
const directives = rootStore.profile !== null && typeof rootStore.profile === 'object' && Array.isArray(rootStore.profile.directives)
|
|
1464
|
+
? rootStore.profile.directives.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.id === 'string' && typeof entry.text === 'string')
|
|
1465
|
+
: []
|
|
1466
|
+
|
|
1467
|
+
return h('div', { className: 'tacit-panel' },
|
|
1468
|
+
StatusCard(kit, { config, profile, auto: rootStore.auto, trend: rootStore.trend }),
|
|
1469
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1470
|
+
BootstrapButton(kit, { bootstrap: rootStore.bootstrap, onClick: () => bootstrapAll() }),
|
|
1471
|
+
h('span', { className: 'tacit-panel-hint' }, t('bootstrap.hint'))),
|
|
1472
|
+
h('div', { className: 'tacit-settings' },
|
|
1473
|
+
h('div', { className: 'tacit-settings-title' }, t('settings.title')),
|
|
1474
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1475
|
+
h('label', { className: 'tacit-settings-label' }, t('settings.model')),
|
|
1476
|
+
h('select', {
|
|
1477
|
+
className: 'tacit-select',
|
|
1478
|
+
value: model,
|
|
1479
|
+
onChange: (event) => applyModel(event.target.value),
|
|
1480
|
+
},
|
|
1481
|
+
h('option', { value: 'deepseek-v4-flash' }, 'deepseek-v4-flash'),
|
|
1482
|
+
h('option', { value: 'deepseek-v4-pro' }, 'deepseek-v4-pro'))),
|
|
1483
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1484
|
+
h('label', { className: 'tacit-settings-label' }, t('settings.auto')),
|
|
1485
|
+
h('input', { type: 'checkbox', checked: auto, onChange: toggleAuto })),
|
|
1486
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1487
|
+
h('label', { className: 'tacit-settings-label' }, t('settings.budget')),
|
|
1488
|
+
h('input', {
|
|
1489
|
+
className: 'tacit-input',
|
|
1490
|
+
type: 'number',
|
|
1491
|
+
min: 0,
|
|
1492
|
+
value: budgetText,
|
|
1493
|
+
onChange: (event) => setBudgetText(event.target.value),
|
|
1494
|
+
}),
|
|
1495
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: applyBudget }, t('settings.apply'))),
|
|
1496
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1497
|
+
h('label', { className: 'tacit-settings-label' }, t('settings.live')),
|
|
1498
|
+
h('input', { type: 'checkbox', checked: live, onChange: toggleLive })),
|
|
1499
|
+
h('div', { className: 'tacit-settings-row' },
|
|
1500
|
+
h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => clearAllRoot() }, t('settings.clear')))),
|
|
1501
|
+
error !== null && typeof error === 'object'
|
|
1502
|
+
? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
|
|
1503
|
+
: null,
|
|
1504
|
+
h(DirectivesEditorView, { config, directives, steering: rootStore.steering }),
|
|
1505
|
+
h('div', { className: 'tacit-panel-section' },
|
|
1506
|
+
h('div', { className: 'tacit-report-title' }, t('panel.styleRules')),
|
|
1507
|
+
styleRules.length === 0
|
|
1508
|
+
? h('div', { className: 'tacit-empty' }, t('panel.styleRulesEmpty'))
|
|
1509
|
+
: h('div', { className: 'tacit-rules-list' },
|
|
1510
|
+
styleRules.map((entry, index) => h('div', { key: 'rule-' + index, className: 'tacit-rule' }, String(entry.rule))))),
|
|
1511
|
+
h('div', { className: 'tacit-panel-section' },
|
|
1512
|
+
h('div', { className: 'tacit-report-title' }, t('panel.coached')),
|
|
1513
|
+
coached.length === 0
|
|
1514
|
+
? h('div', { className: 'tacit-empty' }, t('panel.coachedEmpty'))
|
|
1515
|
+
: h('div', { className: 'tacit-coached-list' },
|
|
1516
|
+
coached.map((entry, index) => h('div', { key: 'c' + index, className: 'tacit-coached-row' },
|
|
1517
|
+
h('div', { className: 'tacit-coached-meta' },
|
|
1518
|
+
h('span', { className: 'tacit-coached-turn' }, (typeof entry.sessionLabel === 'string' && entry.sessionLabel.length > 0 ? entry.sessionLabel + ' · ' : '') + '# ' + String(entry.turn)),
|
|
1519
|
+
h('span', { className: 'tacit-coached-time' }, fmtTime(entry.time)),
|
|
1520
|
+
h('span', { className: 'tacit-chip tacit-coached-trigger' }, t('trigger.' + (entry.trigger === 'auto' || entry.trigger === 'correction' || entry.trigger === 'bootstrap' ? entry.trigger : 'manual')))),
|
|
1521
|
+
typeof entry.promptExcerpt === 'string' && entry.promptExcerpt.length > 0
|
|
1522
|
+
? h('div', { className: 'tacit-coached-excerpt' }, entry.promptExcerpt)
|
|
1523
|
+
: null,
|
|
1524
|
+
typeof entry.improvedPrompt === 'string' && entry.improvedPrompt.length > 0
|
|
1525
|
+
? h('div', { className: 'tacit-coached-improved' }, entry.improvedPrompt.slice(0, 240) + (entry.improvedPrompt.length > 240 ? '…' : ''))
|
|
1526
|
+
: null)))),
|
|
1527
|
+
h('div', { className: 'tacit-panel-hint' }, t('panel.hint')))
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function SettingsSection(kit) {
|
|
1532
|
+
const { t } = kit
|
|
1533
|
+
const CoachPanelView = CoachPanel(kit)
|
|
1534
|
+
return function SettingsSectionView() {
|
|
1535
|
+
useEffect(() => {
|
|
1536
|
+
initRootStore()
|
|
1537
|
+
// Fresh read so freshly distilled style rules appear on open.
|
|
1538
|
+
refreshRootState()
|
|
1539
|
+
}, [])
|
|
1540
|
+
return h('div', { className: 'tacit-root' },
|
|
1541
|
+
h('div', { className: 'tacit-head' }, h('div', { className: 'tacit-head-title' }, t('panel.title'))),
|
|
1542
|
+
h(CoachPanelView))
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// ── CSS (injected once) ────────────────────────────────────────────────
|
|
1547
|
+
|
|
1548
|
+
const css = '.tacit-root{box-sizing:border-box;height:100%;color:var(--dsw-alias-label-primary);padding:16px 20px 32px;font-size:13px;overflow-y:auto}'
|
|
1549
|
+
+ '.tacit-head{display:flex;align-items:baseline;gap:8px;margin-bottom:10px}.tacit-head-title{font-size:14px;font-weight:600;margin-right:auto}'
|
|
1550
|
+
+ '.tacit-progress{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 12px;margin-bottom:12px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}.tacit-progress-title{font-weight:600;color:var(--dsw-alias-label-primary)}'
|
|
1551
|
+
+ '.tacit-empty{color:var(--dsw-alias-label-secondary);text-align:center;padding:24px 8px;line-height:1.6}'
|
|
1552
|
+
+ '.tacit-error{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 12px;margin-bottom:12px;overflow-wrap:anywhere;font-size:12px}'
|
|
1553
|
+
+ '.tacit-btn{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;cursor:pointer;padding:4px 10px}.tacit-btn:hover{border-color:var(--dsw-alias-label-primary)}.tacit-btn:disabled{opacity:.55;cursor:default}'
|
|
1554
|
+
+ '.tacit-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border-color:transparent}.tacit-btn-primary:hover{border-color:transparent;filter:brightness(1.1)}'
|
|
1555
|
+
+ '.tacit-btn-sm{padding:2px 8px;font-size:11px}'
|
|
1556
|
+
+ '.tacit-row{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin-bottom:10px}'
|
|
1557
|
+
+ '.tacit-row-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}.tacit-row-heading{display:flex;align-items:baseline;gap:8px;font-weight:600}.tacit-row-turn{font-size:13px}.tacit-row-time{color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}.tacit-row-live{color:var(--dsw-alias-state-success-primary);font-size:11px;font-weight:600}'
|
|
1558
|
+
+ '.tacit-row-chips{display:flex;flex-wrap:wrap;gap:4px;margin-left:auto}.tacit-chip{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);border-radius:4px;padding:1px 7px;font-size:11px}.tacit-chip-warn{color:var(--dsw-alias-state-warn-primary)}'
|
|
1559
|
+
+ '.tacit-row-prompt{display:flex;align-items:flex-start;gap:8px;margin-top:8px}.tacit-row-prompt-text{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:none;border:0;cursor:pointer;text-align:left;padding:0;white-space:pre-wrap;word-break:break-word;flex:1;line-height:1.55}'
|
|
1560
|
+
+ '.tacit-report{border-top:1px dashed var(--dsw-alias-border-l1);margin-top:10px;padding-top:10px;display:flex;flex-direction:column;gap:6px}'
|
|
1561
|
+
+ '.tacit-report-title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary)}'
|
|
1562
|
+
+ '.tacit-report-note{color:var(--dsw-alias-label-secondary);font-size:12px}'
|
|
1563
|
+
+ '.tacit-report-body{color:var(--dsw-alias-label-primary);font-size:12px;line-height:1.6;word-break:break-word}.tacit-report-body>div{font-size:13px;line-height:1.6}'
|
|
1564
|
+
+ '.tacit-report-improved{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);padding:8px 10px;display:flex;flex-direction:column;gap:6px}'
|
|
1565
|
+
+ '.tacit-report-actions{display:flex;justify-content:flex-end}'
|
|
1566
|
+
+ '.tacit-problem{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-state-warn-primary);border-radius:6px;background:var(--dsw-alias-bg-layer-2);padding:6px 10px;display:flex;flex-direction:column;gap:3px}'
|
|
1567
|
+
+ '.tacit-problem-head{display:flex;align-items:center;gap:8px}.tacit-problem-kind{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;font-weight:600}.tacit-problem-sev{font-size:10px;font-weight:600;border-radius:4px;padding:1px 6px}'
|
|
1568
|
+
+ '.tacit-sev-high{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 18%, transparent);color:var(--dsw-alias-state-error-primary)}'
|
|
1569
|
+
+ '.tacit-sev-medium{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 18%, transparent);color:var(--dsw-alias-state-warn-primary)}'
|
|
1570
|
+
+ '.tacit-sev-low{background:color-mix(in srgb, var(--dsw-alias-label-secondary) 18%, transparent);color:var(--dsw-alias-label-secondary)}'
|
|
1571
|
+
+ '.tacit-sev-info{background:color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);color:var(--dsw-alias-brand-primary)}'
|
|
1572
|
+
+ '.tacit-problem-what{font-size:12px;color:var(--dsw-alias-label-primary);line-height:1.5}.tacit-problem-why{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.5}'
|
|
1573
|
+
+ '.tacit-pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary)}'
|
|
1574
|
+
+ '.tacit-settings{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin-bottom:12px;display:flex;flex-direction:column;gap:8px}'
|
|
1575
|
+
+ '.tacit-settings-title{font-size:12px;font-weight:600}.tacit-settings-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.tacit-settings-label{font-size:12px;color:var(--dsw-alias-label-secondary);min-width:150px}'
|
|
1576
|
+
+ '.tacit-settings-notice{font-size:11px;color:var(--dsw-alias-state-success-primary)}'
|
|
1577
|
+
+ '.tacit-select,.tacit-input{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:3px 8px}.tacit-input{width:90px}'
|
|
1578
|
+
+ '.tacit-improve-btn{font:inherit;font-size:13px;font-weight:500;line-height:20px;color:var(--dsw-alias-label-secondary);background:transparent;border:0;border-radius:24px;cursor:pointer;height:28px;padding:0 8px;white-space:nowrap;display:inline-flex;align-items:center;gap:4px}.tacit-improve-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}'
|
|
1579
|
+
+ '.tacit-check{width:14px;height:14px;accent-color:var(--dsw-alias-brand-primary);flex:none;margin:2px 4px 0 0}'
|
|
1580
|
+
+ '.tacit-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}.tacit-toolbar-hint{flex:1;min-width:200px}.tacit-btn-quiet{color:var(--dsw-alias-label-secondary);background:transparent}.tacit-chip-muted{opacity:.7}.tacit-row-analyzed{border-left:3px solid var(--dsw-alias-brand-primary)}'
|
|
1581
|
+
+ '.tacit-progress-head{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.tacit-progress-count{font-variant-numeric:tabular-nums;font-weight:600;color:var(--dsw-alias-label-primary)}'
|
|
1582
|
+
+ '.tacit-progress-text{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.5}'
|
|
1583
|
+
+ '.tacit-panel{display:flex;flex-direction:column;gap:10px}.tacit-panel-section{display:flex;flex-direction:column;gap:6px}.tacit-panel-hint{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.5}'
|
|
1584
|
+
+ '.tacit-coached-list{display:flex;flex-direction:column;gap:6px}.tacit-coached-row{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;display:flex;flex-direction:column;gap:3px}'
|
|
1585
|
+
+ '.tacit-coached-meta{display:flex;align-items:baseline;gap:8px}.tacit-coached-turn{font-weight:600;font-size:12px}.tacit-coached-time{color:var(--dsw-alias-label-secondary);font-size:11px}'
|
|
1586
|
+
+ '.tacit-coached-excerpt{font-size:12px;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word;line-height:1.5}.tacit-coached-improved{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.5;white-space:pre-wrap;word-break:break-word}'
|
|
1587
|
+
+ ''
|
|
1588
|
+
+ '.tacit-modal-backdrop{z-index:200;background:#00000073;display:flex;justify-content:center;align-items:center;position:fixed;inset:0}'
|
|
1589
|
+
+ '.tacit-modal-card{box-sizing:border-box;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:min(760px,100vw - 48px);max-height:min(84vh,800px);box-shadow:var(--dsw-shadow-lv3,0 12px 32px #0006);color:var(--dsw-alias-label-primary);border-radius:12px;padding:16px 18px 18px;font-size:13px;display:flex;flex-direction:column;gap:10px;overflow-y:auto}'
|
|
1590
|
+
+ '.tacit-modal-head{display:flex;align-items:baseline;gap:8px}.tacit-modal-title{font-size:14px;font-weight:600;margin-right:auto}.tacit-modal-close{color:var(--dsw-alias-label-secondary);cursor:pointer;background:none;border:0;border-radius:6px;padding:2px 8px;font-family:inherit;font-size:18px;line-height:1}.tacit-modal-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}'
|
|
1591
|
+
+ '.tacit-modal-pending{color:var(--dsw-alias-label-secondary);padding:16px 0;text-align:center}'
|
|
1592
|
+
+ '.tacit-modal-cols{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tacit-modal-col{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);padding:8px 10px;min-width:0;max-height:300px;overflow-y:auto}'
|
|
1593
|
+
+ '.tacit-modal-col-title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}'
|
|
1594
|
+
+ '.tacit-modal-rationale-text{font-size:12px;color:var(--dsw-alias-label-primary);line-height:1.6}.tacit-modal-savings{color:var(--dsw-alias-state-success-primary);font-size:12px;font-weight:600}'
|
|
1595
|
+
+ '.tacit-modal-actions{display:flex;gap:8px;justify-content:flex-end}'
|
|
1596
|
+
+ '.tacit-feedback{display:flex;justify-content:center;align-items:center;margin:8px 2px 0;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;gap:8px;transition:opacity .35s ease}.tacit-feedback-fading{opacity:0}'
|
|
1597
|
+
+ '.tacit-feedback-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.tacit-feedback-title{color:var(--dsw-alias-label-secondary)}'
|
|
1598
|
+
+ '.tacit-feedback-vote{font:inherit;font-size:14px;line-height:1;cursor:pointer;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 8px}.tacit-feedback-vote:hover{border-color:var(--dsw-alias-brand-primary)}.tacit-feedback-vote-active{border-color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent)}'
|
|
1599
|
+
+ '.tacit-feedback-reason{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tacit-feedback-input{width:260px;max-width:60vw}'
|
|
1600
|
+
+ '.tacit-feedback-noted{color:var(--dsw-alias-state-success-primary);font-weight:600}'
|
|
1601
|
+
+ '.tacit-trend{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}.tacit-chip-trigger,.tacit-coached-trigger{text-transform:uppercase;letter-spacing:.04em;font-size:10px}.tacit-coached-trigger{margin-left:auto}.tacit-row-enrichment{margin-top:8px;border-left:3px solid var(--dsw-alias-brand-primary);padding:4px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:6px}'
|
|
1602
|
+
+ '.tacit-chip-trial{color:var(--dsw-alias-brand-primary)}.tacit-chip-ok{color:var(--dsw-alias-state-success-primary)}'
|
|
1603
|
+
+ '.tacit-directive{display:flex;align-items:center;gap:8px}.tacit-directive-text{flex:1;min-width:0}.tacit-directive-off .tacit-directive-text{opacity:.5;text-decoration:line-through}.tacit-directive-input{width:min(420px,100%);flex:1}.tacit-preview summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-secondary)}.tacit-preview pre{margin-top:6px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:8px 10px}'
|
|
1604
|
+
+ '.tacit-rules-list{display:flex;flex-direction:column;gap:6px}.tacit-rule{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-brand-primary);border-radius:6px;padding:6px 10px;font-size:12px;line-height:1.55;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}'
|
|
1605
|
+
|
|
1606
|
+
function injectCss() {
|
|
1607
|
+
const tagId = 'dsh-tacit/styles.css'
|
|
1608
|
+
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
|
|
1609
|
+
const tag = document.createElement('style')
|
|
1610
|
+
tag.dataset.plugin = NS
|
|
1611
|
+
tag.dataset.pluginCss = tagId
|
|
1612
|
+
tag.textContent = css
|
|
1613
|
+
document.head.appendChild(tag)
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// ── Client plugin body ──────────────────────────────────────────────────
|
|
1618
|
+
|
|
1619
|
+
function apply(ctx) {
|
|
1620
|
+
ctx.effect(() => ctx.locale.register(NS, { zh: DICT_ZH, en: DICT_EN }), 'dsh-tacit: dictionaries')
|
|
1621
|
+
const t = ctx.locale.bind(NS)
|
|
1622
|
+
const kit = makeKit(t)
|
|
1623
|
+
injectCss()
|
|
1624
|
+
|
|
1625
|
+
const CoachTabView = CoachTab(kit)
|
|
1626
|
+
ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
1627
|
+
name: 'conversation.view',
|
|
1628
|
+
id: 'tacit',
|
|
1629
|
+
order: 30,
|
|
1630
|
+
locale: NS,
|
|
1631
|
+
label: () => t('tab'),
|
|
1632
|
+
}, (props) => h(CoachTabView, props)))
|
|
1633
|
+
|
|
1634
|
+
const ImproveButtonView = ImproveButton(kit)
|
|
1635
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
1636
|
+
name: 'conversation.input.left',
|
|
1637
|
+
id: 'tacit-improve',
|
|
1638
|
+
order: 100,
|
|
1639
|
+
locale: NS,
|
|
1640
|
+
label: () => t('improve.btn'),
|
|
1641
|
+
}, (props) => h(ImproveButtonView, props)))
|
|
1642
|
+
|
|
1643
|
+
const PreviewOverlayView = PreviewOverlay(kit)
|
|
1644
|
+
ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
|
|
1645
|
+
name: 'conversation.input.overlay',
|
|
1646
|
+
id: 'tacit-preview',
|
|
1647
|
+
order: 10,
|
|
1648
|
+
locale: NS,
|
|
1649
|
+
inject: (sessionId) => ({ hooks: { tacitStore: storeFor(sessionId) } }),
|
|
1650
|
+
}, (props) => h(PreviewOverlayView, props)))
|
|
1651
|
+
|
|
1652
|
+
// The post-apply 👍/👎 strip lives in the band UNDER the composer card
|
|
1653
|
+
// (composer.dock), only for the live stage (threshold reached).
|
|
1654
|
+
const FeedbackStripView = FeedbackStrip(kit)
|
|
1655
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
1656
|
+
name: 'conversation.composer.dock',
|
|
1657
|
+
id: 'tacit-feedback',
|
|
1658
|
+
order: 10,
|
|
1659
|
+
locale: NS,
|
|
1660
|
+
label: () => t('feedback.title'),
|
|
1661
|
+
}, (props) => h(FeedbackStripView, props)))
|
|
1662
|
+
|
|
1663
|
+
// Own Settings page under "Cost": a nav section in the Settings panel,
|
|
1664
|
+
// on its own. Reuses the coach panel (progress, model, threshold, live,
|
|
1665
|
+
// clear reports, coached prompts). Order 32 places it right after
|
|
1666
|
+
// cost-meter's "Cost" section (order 30/31).
|
|
1667
|
+
const SettingsSectionView = SettingsSection(kit)
|
|
1668
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
1669
|
+
name: 'settings.section',
|
|
1670
|
+
id: 'tacit',
|
|
1671
|
+
order: 32,
|
|
1672
|
+
locale: NS,
|
|
1673
|
+
label: () => t('settings.sectionLabel'),
|
|
1674
|
+
}, (props) => h(SettingsSectionView, props)))
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
return {
|
|
1678
|
+
name: NS,
|
|
1679
|
+
inject: ['slots', 'locale'],
|
|
1680
|
+
apply,
|
|
1681
|
+
// Test-only handles for the SSR suite (ignored by the module loader).
|
|
1682
|
+
__test: {
|
|
1683
|
+
css,
|
|
1684
|
+
applyImproved,
|
|
1685
|
+
closeFeedback,
|
|
1686
|
+
fadeFeedback,
|
|
1687
|
+
rootStore,
|
|
1688
|
+
},
|
|
1689
|
+
}
|
|
1690
|
+
},
|
|
1691
|
+
})
|