dsh-session-title-stamp 0.0.0-stage → 0.4.2

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/client.js ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Browser half of dsh-session-title-stamp.
3
+ *
4
+ * Registers two seats:
5
+ * - a refresh button in the conversation header
6
+ * (`conversation.session.header.actions`)
7
+ * - a compact cost/duration line in the Session hover card
8
+ * (`sidebar.session.row.hover`)
9
+ *
10
+ * Hand-written module-loader bundle: React comes from the browser module
11
+ * table, so this package has no client build step.
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: 'dsh-session-title-stamp',
15
+ factory(require) {
16
+ const React = require('react')
17
+ const { useEffect, useState } = React
18
+ const h = React.createElement
19
+
20
+ const NS = 'dsh-session-title-stamp'
21
+ const STATS_PATH = '/api/session-title-stamp/stats'
22
+ const inject = ['slots', 'remote', 'remote.commands', 'locale']
23
+ const DICT = {
24
+ zh: {
25
+ menu: '自动生成标题',
26
+ tip: '自动识别分类,生成「日期 | 分类 | 名称」标题',
27
+ running: '会话运行中,稍后再试',
28
+ duration: '耗时',
29
+ estimate: '估算',
30
+ },
31
+ en: {
32
+ menu: 'Generate title',
33
+ tip: 'Detect the category and generate a DATE | CATEGORY | NAME title',
34
+ running: 'Session is running; try again later',
35
+ duration: 'Duration',
36
+ estimate: 'est.',
37
+ },
38
+ }
39
+
40
+ function formatDuration(ms) {
41
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
42
+ const seconds = Math.round(ms / 1000)
43
+ if (seconds < 60) return `${seconds}s`
44
+ const minutes = Math.round(seconds / 60)
45
+ if (minutes < 60) return `${minutes}m`
46
+ const hours = Math.floor(minutes / 60)
47
+ const rest = minutes % 60
48
+ return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`
49
+ }
50
+
51
+ function formatCost(cny) {
52
+ if (typeof cny !== 'number' || !Number.isFinite(cny) || cny < 0) return undefined
53
+ if (cny < 0.01) return `≈¥${cny.toFixed(3)}`
54
+ return `≈¥${cny.toFixed(2)}`
55
+ }
56
+
57
+ function formatTokens(tokens) {
58
+ if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens <= 0) return undefined
59
+ if (tokens < 1000) return `${tokens}`
60
+ if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}k`
61
+ return `${(tokens / 1_000_000).toFixed(2)}M`
62
+ }
63
+
64
+ function RefreshIcon() {
65
+ return h(
66
+ 'svg',
67
+ {
68
+ width: 14,
69
+ height: 14,
70
+ viewBox: '0 0 16 16',
71
+ fill: 'none',
72
+ stroke: 'currentColor',
73
+ strokeWidth: 1.5,
74
+ strokeLinecap: 'round',
75
+ strokeLinejoin: 'round',
76
+ 'aria-hidden': 'true',
77
+ },
78
+ h('path', { d: 'M13.5 8a5.5 5.5 0 1 1-1.6-3.9' }),
79
+ h('path', { d: 'M13.5 2v4h-4' }),
80
+ )
81
+ }
82
+
83
+ function RefreshAction({ sessionId, useSession, run, t }) {
84
+ const [busy, setBusy] = useState(false)
85
+ const [failed, setFailed] = useState(false)
86
+ const running =
87
+ typeof useSession === 'function' ? useSession((snapshot) => snapshot?.running === true) : false
88
+ const disabled = busy || running || typeof sessionId !== 'string' || sessionId === ''
89
+
90
+ const click = async () => {
91
+ if (busy || disabled) return
92
+ setBusy(true)
93
+ setFailed(false)
94
+ try {
95
+ await run(sessionId)
96
+ } catch (error) {
97
+ console.warn('session-title-stamp: /retitle failed', error)
98
+ setFailed(true)
99
+ setTimeout(() => setFailed(false), 2500)
100
+ } finally {
101
+ setBusy(false)
102
+ }
103
+ }
104
+
105
+ return h(
106
+ 'button',
107
+ {
108
+ type: 'button',
109
+ className: 'dsh-title-refresh',
110
+ disabled,
111
+ title: running ? t('running') : t('tip'),
112
+ 'aria-label': t('menu'),
113
+ onClick: click,
114
+ style: {
115
+ display: 'inline-flex',
116
+ alignItems: 'center',
117
+ justifyContent: 'center',
118
+ width: 26,
119
+ height: 26,
120
+ padding: 0,
121
+ border: 0,
122
+ borderRadius: 7,
123
+ background: 'transparent',
124
+ color: failed
125
+ ? 'var(--dsw-alias-state-error-primary, #f87171)'
126
+ : 'var(--dsw-alias-label-secondary, #94a3b8)',
127
+ cursor: disabled ? 'default' : 'pointer',
128
+ opacity: disabled && !busy ? 0.45 : 1,
129
+ },
130
+ },
131
+ busy ? h('span', { style: { fontSize: 12 } }, '…') : h(RefreshIcon),
132
+ )
133
+ }
134
+
135
+ function HoverStats({ sessionId, t }) {
136
+ const [data, setData] = useState(null)
137
+ useEffect(() => {
138
+ let cancelled = false
139
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
140
+ const url = `${STATS_PATH}?sessionId=${encodeURIComponent(sessionId)}`
141
+ fetch(url, { headers: { accept: 'application/json' } })
142
+ .then((response) => (response.ok ? response.json() : undefined))
143
+ .then((payload) => {
144
+ if (!cancelled && payload?.ok === true) setData(payload)
145
+ })
146
+ .catch(() => undefined)
147
+ return () => {
148
+ cancelled = true
149
+ }
150
+ }, [sessionId])
151
+
152
+ if (data === null) return null
153
+ const duration = formatDuration(data.durationMs)
154
+ const cost = formatCost(data.costCny)
155
+ const tokens = formatTokens(data.tokens)
156
+ const parts = [
157
+ duration === undefined ? undefined : `${t('duration')} ${duration}`,
158
+ cost === undefined ? undefined : `${t('estimate')} ${cost}`,
159
+ tokens === undefined ? undefined : `${tokens} tokens`,
160
+ ].filter(Boolean)
161
+ if (parts.length === 0) return null
162
+ return h(
163
+ 'div',
164
+ {
165
+ style: {
166
+ marginTop: 6,
167
+ color: 'var(--dsw-alias-label-tertiary, #94a3b8)',
168
+ fontSize: 12,
169
+ lineHeight: 1.4,
170
+ whiteSpace: 'nowrap',
171
+ },
172
+ },
173
+ parts.join(' · '),
174
+ )
175
+ }
176
+
177
+ function RegenerateMenuItem({ sessionId, useMenuOpenState, run, t }) {
178
+ const menuState =
179
+ typeof useMenuOpenState === 'function' ? useMenuOpenState() : null
180
+ return h(
181
+ 'button',
182
+ {
183
+ type: 'button',
184
+ role: 'menuitem',
185
+ className: 'dsh-title-menu-item',
186
+ title: t('tip'),
187
+ onClick: async () => {
188
+ if (Array.isArray(menuState) && typeof menuState[1] === 'function') {
189
+ menuState[1](false)
190
+ }
191
+ try {
192
+ await run(sessionId)
193
+ } catch (error) {
194
+ console.warn('session-title-stamp: /retitle failed', error)
195
+ }
196
+ },
197
+ },
198
+ h(
199
+ 'span',
200
+ { className: 'dsh-title-menu-icon', 'aria-hidden': 'true' },
201
+ h(RefreshIcon),
202
+ ),
203
+ h('span', null, t('menu')),
204
+ )
205
+ }
206
+
207
+ const STYLE_ID = 'dsh-session-title-stamp-style'
208
+
209
+ function installStyle(ctx) {
210
+ const style = document.createElement('style')
211
+ style.id = STYLE_ID
212
+ style.textContent = `
213
+ .dsh-title-menu-item{display:flex;align-items:center;gap:6px;width:100%;min-height:34px;padding:6px 8px;border:0;border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;line-height:20px;font-weight:400;text-align:left;cursor:pointer;box-sizing:border-box}
214
+ .dsh-title-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}
215
+ .dsh-title-menu-icon{display:inline-flex;flex:none;width:14px;height:14px;align-items:center;justify-content:center;color:var(--dsw-alias-menu-icon,var(--dsw-alias-label-tertiary,#94a3b8))}
216
+ .dsh-title-menu-icon svg{width:14px;height:14px}
217
+ .dsh-title-menu-item > span:last-child{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
218
+ `
219
+ document.head.appendChild(style)
220
+ ctx.effect?.(() => () => style.remove())
221
+ }
222
+
223
+ function apply(ctx) {
224
+ installStyle(ctx)
225
+ ctx.effect?.(() => ctx.locale.register(NS, DICT))
226
+ const t = ctx.locale.bind(NS)
227
+ const run = async (sessionId) => {
228
+ const response = await ctx.remote.commands.execute(sessionId, '/retitle', [])
229
+ if (!response?.ok) {
230
+ throw new Error(response?.error?.message ?? 'title command failed')
231
+ }
232
+ const outcome = response.value?.result
233
+ if (outcome?.kind !== 'success') {
234
+ throw new Error(outcome?.text ?? 'title command returned no result')
235
+ }
236
+ return outcome.text
237
+ }
238
+
239
+ ctx.slots.inject('conversation.session.header.actions', () =>
240
+ ctx.slots.register(
241
+ {
242
+ name: 'conversation.session.header.actions',
243
+ id: 'session-title-stamp-refresh',
244
+ order: 40,
245
+ inject: () => ({ run, t }),
246
+ },
247
+ RefreshAction,
248
+ ),
249
+ )
250
+
251
+ ctx.slots.inject('sidebar.workspaces.session.menu.item', () =>
252
+ ctx.slots.register(
253
+ {
254
+ name: 'sidebar.workspaces.session.menu.item',
255
+ id: 'session-title-stamp-retitle',
256
+ order: 0,
257
+ inject: () => ({ run, t }),
258
+ },
259
+ RegenerateMenuItem,
260
+ ),
261
+ )
262
+
263
+ ctx.slots.inject('sidebar.session.row.hover', () =>
264
+ ctx.slots.register(
265
+ {
266
+ name: 'sidebar.session.row.hover',
267
+ id: 'session-title-stamp-stats',
268
+ order: 0,
269
+ inject: () => ({ t }),
270
+ },
271
+ HoverStats,
272
+ ),
273
+ )
274
+ }
275
+
276
+ return { inject, apply }
277
+ },
278
+ })
@@ -0,0 +1,29 @@
1
+ # DSH profile bundle for dated, categorised session titles.
2
+ #
3
+ # Selecting this bundle replaces the shipped first-prompt title provider. The
4
+ # inserted row is the provider package itself; disabling `session-title-llm`
5
+ # keeps only one provider registered, as the title service requires.
6
+ - id: session-title-llm
7
+ disabled: true
8
+
9
+ - insert:
10
+ - id: session-title-stamp
11
+ name: dsh-session-title-stamp
12
+ config:
13
+ # Refresh after a completed turn, at most once every 2 minutes, and
14
+ # stop automatic refreshes after 100 user messages.
15
+ refreshOnTurnEnd: true
16
+ refreshMinIntervalMs: 120000
17
+ refreshMaxTurns: 100
18
+ categoryPreset: zh
19
+ # MMDD matches the reference list (1027). The template can also use
20
+ # {YYYYMMDD}, {YYMM}, {HHmm}, {type}, {topic}, etc.
21
+ dateFormat: MMDD
22
+ # Omit timeZone to follow the host's local zone.
23
+ maxInputBytes: 4096
24
+ maxOutputTokens: 4096
25
+ timeoutMs: 90000
26
+ # Optional: repair fallback titles of live sessions on startup.
27
+ backfillOnStart: false
28
+ backfillMaxSessions: 30
29
+ backfillIntervalMs: 2000
package/icon.svg ADDED
@@ -0,0 +1,45 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Session title stamp">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#0b1220"/>
5
+ <stop offset="1" stop-color="#111c33"/>
6
+ </linearGradient>
7
+ <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
8
+ <stop offset="0" stop-color="#38bdf8"/>
9
+ <stop offset="1" stop-color="#22d3ee"/>
10
+ </linearGradient>
11
+ <clipPath id="calendar">
12
+ <rect x="120" y="210" width="330" height="510" rx="48"/>
13
+ </clipPath>
14
+ </defs>
15
+
16
+ <rect width="1024" height="1024" rx="220" fill="url(#bg)"/>
17
+ <rect x="14" y="14" width="996" height="996" rx="206" fill="none" stroke="#1e293b" stroke-width="4"/>
18
+
19
+ <g clip-path="url(#calendar)">
20
+ <rect x="120" y="210" width="330" height="510" rx="48" fill="#0f1b33"/>
21
+ <rect x="120" y="210" width="330" height="104" fill="url(#accent)"/>
22
+ <rect x="120" y="210" width="330" height="510" rx="48" fill="none" stroke="#38bdf8" stroke-width="12"/>
23
+ </g>
24
+ <rect x="180" y="178" width="34" height="84" rx="17" fill="#e2e8f0"/>
25
+ <rect x="356" y="178" width="34" height="84" rx="17" fill="#e2e8f0"/>
26
+
27
+ <g fill="#1e3a5f">
28
+ <rect x="176" y="378" width="66" height="58" rx="14"/>
29
+ <rect x="252" y="378" width="66" height="58" rx="14"/>
30
+ <rect x="328" y="378" width="66" height="58" rx="14"/>
31
+ <rect x="176" y="460" width="66" height="58" rx="14"/>
32
+ <rect x="252" y="460" width="66" height="58" rx="14"/>
33
+ <rect x="328" y="460" width="66" height="58" rx="14"/>
34
+ <rect x="176" y="542" width="66" height="58" rx="14"/>
35
+ <rect x="252" y="542" width="66" height="58" rx="14"/>
36
+ </g>
37
+ <rect x="176" y="624" width="218" height="44" rx="22" fill="#38bdf8"/>
38
+
39
+ <rect x="500" y="300" width="10" height="360" rx="5" fill="#1e293b"/>
40
+
41
+ <rect x="556" y="300" width="360" height="68" rx="34" fill="#f8fafc"/>
42
+ <rect x="556" y="420" width="292" height="68" rx="34" fill="url(#accent)"/>
43
+ <rect x="556" y="540" width="330" height="68" rx="34" fill="#64748b"/>
44
+ <rect x="556" y="660" width="228" height="68" rx="34" fill="#334155"/>
45
+ </svg>