@jetecho/dsh-csv-and-image-preview 0.1.1 → 0.1.3

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/lib/client.js CHANGED
@@ -1,190 +1,318 @@
1
- /**
2
- * dsh-csv-and-image-preview 浏览器端 bundle(单文件,经 __ModuleLoader__ 加载)。
3
- *
4
- * 为两个工具注册键控 toolview(`tool.call.toolview`, key = 工具名):
5
- * - `preview_image`:从结果节点 meta 读取 { src, mime, label },渲染浏览器
6
- * 原生 <img>;
7
- * - `preview_csv`:从结果节点 meta 读取 { label, delimiter, rows, ... },
8
- * 渲染原生 <table>(粘性表头、控件限高 320px 局部滚动、表头点击排序)。
9
- *
10
- * 这样图片/表格都不经过聊天渲染器的 markdown/genui 白名单,直接以真实 DOM
11
- * 显示。样式使用 --dsw-* 主题变量,跟随全局亮/暗主题。
12
- * @module dsh-csv-and-image-preview/client
13
- */
14
-
15
- window.__ModuleLoader__.load({
16
- id: 'dsh-csv-and-image-preview',
17
- factory: (require) => {
18
- var module = { exports: {} }
19
- var exports = module.exports
20
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
21
-
22
- const React = require('react')
23
-
24
- // ── 样式 ────────────────────────────────────────────────────────────────
25
- const css = [
26
- '/* dsh-csv-and-image-preview */',
27
- '.dip-root{display:flex;flex-direction:column;gap:6px;align-items:flex-start;max-width:100%}',
28
- '.dip-label{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:20px}',
29
- '.dip-figure{margin:0;padding:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);max-width:100%}',
30
- '.dip-img{display:block;max-width:100%;height:auto;border-radius:8px}',
31
- '.dip-img.svg{background:#fff}',
32
- '.dip-missing{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
33
- '/* preview_csv 表格:控件限高,内部滚动,不撑爆聊天卡片 */',
34
- '.dip-table-wrap{max-height:320px;max-width:100%;overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:8px}',
35
- '.dip-table{border-collapse:separate;border-spacing:0;font-size:12px;line-height:20px;white-space:nowrap}',
36
- '.dip-table th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-weight:600;text-align:left;cursor:pointer;user-select:none;-webkit-user-select:none}',
37
- '.dip-table th,.dip-table td{padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);border-right:1px solid var(--dsw-alias-border-l1);overflow:hidden;text-overflow:ellipsis}',
38
- '.dip-table th:last-child,.dip-table td:last-child{border-right:none}',
39
- '.dip-table tbody tr:nth-child(even) td{background:rgba(127,127,127,0.08)}',
40
- '.dip-table td.num{text-align:right;font-variant-numeric:tabular-nums}',
41
- '.dip-csv-note{font-size:12px;color:var(--dsw-alias-label-tertiary);line-height:18px}',
42
- ].join('\n')
43
- if (typeof document !== 'undefined') {
44
- const key = 'dip-' + 'dsh-csv-and-image-preview'
45
- if (document.querySelector('style[data-plugin-css="' + key + '"]') === null) {
46
- const el = document.createElement('style')
47
- el.dataset.plugin = 'dsh-csv-and-image-preview'
48
- el.dataset.pluginCss = key
49
- el.textContent = css
50
- document.head.appendChild(el)
51
- }
52
- }
53
-
54
- /** 从 props.block 取 meta;工具调用 running 或回退时 meta 不存在。 */
55
- function metaOf(props) {
56
- const block = props && props.block
57
- return block !== null && typeof block === 'object' && 'meta' in block ? block.meta : undefined
58
- }
59
-
60
- /**
61
- * 键控 toolview(preview_image):从 meta 读取 { src, mime, label } 渲染
62
- * 原生 <img>。
63
- */
64
- function PreviewToolView(props) {
65
- const meta = metaOf(props)
66
- const label = typeof meta === 'object' && meta !== null && typeof meta.label === 'string' ? meta.label : '预览'
67
- const src = typeof meta === 'object' && meta !== null && typeof meta.src === 'string' ? meta.src : null
68
- const isSvg = typeof meta === 'object' && meta !== null && meta.mime === 'image/svg+xml'
69
- if (src === null) {
70
- return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
71
- React.createElement('span', { className: 'dip-missing' }, '图片预览缺少数据(meta 缺失)'))
72
- }
73
- return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
74
- React.createElement('span', { className: 'dip-label' }, label),
75
- React.createElement('figure', { className: 'dip-figure' },
76
- React.createElement('img', {
77
- className: 'dip-img' + (isSvg ? ' svg' : ''),
78
- src: src,
79
- alt: label,
80
- })))
81
- }
82
-
83
- const NUMERIC_RE = /^-?\d+(\.\d+)?$/
84
-
85
- /** 截断/统计脚注:「显示前 50 / 1234 · 前 5 / 8 列 · 分隔符 ","」。 */
86
- function csvNote(meta) {
87
- const parts = []
88
- parts.push(meta.rowsShown < meta.totalRows
89
- ? `显示前 ${meta.rowsShown} / ${meta.totalRows} 行`
90
- : `共 ${meta.totalRows} 行`)
91
- if (meta.colsShown < meta.totalCols) parts.push(`前 ${meta.colsShown} / ${meta.totalCols} 列`)
92
- parts.push(`分隔符 "${meta.delimiter === '\t' ? 'Tab' : meta.delimiter}"`)
93
- return parts.join(' · ')
94
- }
95
-
96
- /**
97
- * 对数据行(不含表头)按列排序:整列(除空串)可解析为数字时按数值比较,
98
- * 否则按中文 locale 比较;空值恒沉底。dir=1 升序,-1 降序。
99
- */
100
- function sortBodyRows(meta, col, dir) {
101
- const body = meta.rows.slice(1)
102
- const numeric = body.every((r) => {
103
- const v = col < r.length ? r[col] : ''
104
- return v === '' || NUMERIC_RE.test(v)
105
- }) && body.some((r) => col < r.length && NUMERIC_RE.test(r[col]))
106
- const cmp = numeric
107
- ? (a, b) => (parseFloat(a) || 0) - (parseFloat(b) || 0)
108
- : (a, b) => String(a).localeCompare(String(b), 'zh-Hans-CN')
109
- return body
110
- .map((_, i) => i)
111
- .sort((x, y) => {
112
- const a = x < body.length && col < body[x].length ? body[x][col] : ''
113
- const b = y < body.length && col < body[y].length ? body[y][col] : ''
114
- if ((a === '') !== (b === '')) return a === '' ? 1 : -1
115
- return cmp(a, b) * dir
116
- })
117
- .map((i) => body[i])
118
- }
119
-
120
- /**
121
- * 键控 toolview(preview_csv):从 meta 读取 { label, delimiter, rows,
122
- * totalRows, totalCols, rowsShown, colsShown },渲染原生 <table>。
123
- * rows[0] 为表头;数字单元格右对齐;表头点击循环排序(升→降→取消)。
124
- */
125
- function CsvToolView(props) {
126
- const meta = metaOf(props)
127
- const ok = meta !== null && typeof meta === 'object' && Array.isArray(meta.rows) && meta.rows.length > 0
128
- // hooks 必须无条件调用,放在提前 return 之前。
129
- const sortPair = React.useState(null) // null | { col, dir }
130
- const sort = sortPair[0]
131
- const setSort = sortPair[1]
132
- if (!ok) {
133
- return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
134
- React.createElement('span', { className: 'dip-missing' }, 'CSV 预览缺少数据(meta 缺失)'))
135
- }
136
- const label = typeof meta.label === 'string' && meta.label !== '' ? meta.label : 'CSV 预览'
137
-
138
- const headerClick = (ci) => {
139
- setSort((prev) => (prev !== null && prev.col === ci ? (prev.dir === 1 ? { col: ci, dir: -1 } : null) : { col: ci, dir: 1 }))
140
- }
141
-
142
- const thead = React.createElement('thead', null,
143
- React.createElement('tr', null, meta.rows[0].map((c, i) => {
144
- const indicator = sort !== null && sort.col === i ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''
145
- return React.createElement('th', {
146
- key: i,
147
- onClick: () => headerClick(i),
148
- title: String(c),
149
- }, String(c) + indicator)
150
- })))
151
- const bodyRows = sort !== null ? sortBodyRows(meta, sort.col, sort.dir) : meta.rows.slice(1)
152
- const tbody = React.createElement('tbody', null, bodyRows.map((r, ri) =>
153
- React.createElement('tr', { key: ri }, r.map((c, ci) => {
154
- const s = String(c)
155
- return React.createElement('td', {
156
- key: ci,
157
- className: NUMERIC_RE.test(s) ? 'num' : undefined,
158
- title: s,
159
- }, s)
160
- }))))
161
- return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
162
- React.createElement('span', { className: 'dip-label' }, label),
163
- React.createElement('div', { className: 'dip-table-wrap' },
164
- React.createElement('table', { className: 'dip-table' }, thead, tbody)),
165
- React.createElement('span', { className: 'dip-csv-note' }, csvNote(meta)))
166
- }
167
-
168
- // ── 服务接入 ─────────────────────────────────────────────────────────────
169
- const inject = []
170
- function apply(ctx) {
171
- const slots = ctx.get('slots')
172
- if (slots === undefined) return
173
- const disposals = [
174
- slots.inject('tool.call.toolview', () => slots.register(
175
- { name: 'tool.call.toolview', key: 'preview_image' },
176
- PreviewToolView,
177
- )),
178
- slots.inject('tool.call.toolview', () => slots.register(
179
- { name: 'tool.call.toolview', key: 'preview_csv' },
180
- CsvToolView,
181
- )),
182
- ]
183
- return () => { for (const dispose of disposals) dispose() }
184
- }
185
-
186
- exports.apply = apply
187
- exports.inject = inject
188
- return module.exports
189
- },
190
- })
1
+ /**
2
+ * dsh-csv-and-image-preview 浏览器端 bundle(单文件,经 __ModuleLoader__ 加载)。
3
+ *
4
+ * 为两个工具注册键控 toolview(`tool.call.toolview`, key = 工具名):
5
+ * - `preview_image`:通过会话/调用 ID 读取快照(兼容旧 meta),渲染浏览器
6
+ * 原生 <img>;
7
+ * - `preview_csv`:读取表格快照 { label, delimiter, rows, ... },
8
+ * 渲染原生 <table>(粘性表头、控件限高 320px 局部滚动、表头点击排序)。
9
+ *
10
+ * 这样图片/表格都不经过聊天渲染器的 markdown/genui 白名单,直接以真实 DOM
11
+ * 显示。样式使用 --dsw-* 主题变量,跟随全局亮/暗主题。
12
+ * @module dsh-csv-and-image-preview/client
13
+ */
14
+
15
+ window.__ModuleLoader__.load({
16
+ id: '@jetecho/dsh-csv-and-image-preview',
17
+ factory: (require) => {
18
+ var module = { exports: {} }
19
+ var exports = module.exports
20
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
21
+
22
+ const React = require('react')
23
+
24
+ // ── 样式 ────────────────────────────────────────────────────────────────
25
+ const css = [
26
+ '/* dsh-csv-and-image-preview */',
27
+ '.dip-root{display:flex;flex-direction:column;gap:6px;align-items:flex-start;max-width:100%}',
28
+ '.dip-label{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:20px}',
29
+ '.dip-figure{margin:0;padding:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);max-width:100%}',
30
+ '.dip-img{display:block;max-width:100%;height:auto;border-radius:8px}',
31
+ '.dip-img.svg{background:#fff}',
32
+ '.dip-missing{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
33
+ '.dip-error{font-size:12px;color:var(--dsw-alias-state-error-primary,#c33)}',
34
+ '/* preview_csv 表格:控件限高,内部滚动,不撑爆聊天卡片 */',
35
+ '.dip-table-wrap{max-height:320px;max-width:100%;overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:8px}',
36
+ '.dip-table{border-collapse:separate;border-spacing:0;font-size:12px;line-height:20px;white-space:nowrap}',
37
+ '.dip-table th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-weight:600;text-align:left;cursor:pointer;user-select:none;-webkit-user-select:none}',
38
+ '.dip-table th,.dip-table td{padding:4px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);border-right:1px solid var(--dsw-alias-border-l1);overflow:hidden;text-overflow:ellipsis}',
39
+ '.dip-table th:last-child,.dip-table td:last-child{border-right:none}',
40
+ '.dip-table tbody tr:nth-child(even) td{background:rgba(127,127,127,0.08)}',
41
+ '.dip-table td.num{text-align:right;font-variant-numeric:tabular-nums}',
42
+ '.dip-csv-note{font-size:12px;color:var(--dsw-alias-label-tertiary);line-height:18px}',
43
+ ].join('\n')
44
+ if (typeof document !== 'undefined') {
45
+ const key = 'dip-' + 'dsh-csv-and-image-preview'
46
+ if (document.querySelector('style[data-plugin-css="' + key + '"]') === null) {
47
+ const el = document.createElement('style')
48
+ el.dataset.plugin = 'dsh-csv-and-image-preview'
49
+ el.dataset.pluginCss = key
50
+ el.textContent = css
51
+ document.head.appendChild(el)
52
+ }
53
+ }
54
+
55
+ /** props.block 取 meta;工具调用 running 或回退时 meta 不存在。 */
56
+ function metaOf(props) {
57
+ const block = props && props.block
58
+ return block !== null && typeof block === 'object' && 'meta' in block ? block.meta : undefined
59
+ }
60
+
61
+ function legacyMeta(props) {
62
+ const meta = metaOf(props)
63
+ if (!meta || typeof meta !== 'object') return null
64
+ if (props.toolName === 'preview_image' && typeof meta.src === 'string') return meta
65
+ if (props.toolName === 'preview_csv' && Array.isArray(meta.rows)) return meta
66
+ return null
67
+ }
68
+
69
+ /** Cancel stale reads on navigation; re-read after connection recovery or retry. */
70
+ function usePreview(props) {
71
+ const block = props.block
72
+ const legacy = legacyMeta(props)
73
+ const settled = block?.kind === 'tool-result'
74
+ const text = (block?.content || []).filter(c => c.type === 'text').map(c => c.text).join('\n')
75
+ const failed = block?.isError || /^preview_(?:image|csv) 失败:/.test(text)
76
+ const key = JSON.stringify([props.sessionId, props.callId, props.toolName])
77
+ const [attempt, setAttempt] = React.useState(0)
78
+ const [state, setState] = React.useState(null)
79
+ const generation = React.useSyncExternalStore(props.subscribeConnection, props.getConnection, props.getConnection)
80
+ React.useEffect(() => {
81
+ if (legacy || !settled || failed) return
82
+ const controller = new AbortController()
83
+ let active = true
84
+ setState({ key, loading: true })
85
+ props.loadPreview({ sessionId: props.sessionId, callId: props.callId, toolName: props.toolName }, controller.signal)
86
+ .then(meta => { if (active) setState({ key, meta }) })
87
+ .catch(error => { if (active) setState({ key, error: error.message || '加载预览失败。' }) })
88
+ return () => { active = false; controller.abort() }
89
+ }, [key, settled, failed, legacy, attempt, generation, props.loadPreview])
90
+ if (failed) return { error: text || '预览执行失败。' }
91
+ if (legacy) return { meta: legacy }
92
+ if (!settled) return { loading: true, message: '正在生成预览…' }
93
+ const current = state?.key === key ? state : { loading: true }
94
+ return { ...current, retry: () => setAttempt(n => n + 1) }
95
+ }
96
+
97
+ function PreviewStatus({ state }) {
98
+ return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
99
+ React.createElement('span', { className: state.error ? 'dip-error' : 'dip-missing', role: state.error ? 'alert' : 'status' },
100
+ state.error || state.message || '正在加载预览…'),
101
+ state.error && state.retry ? React.createElement('button', { type: 'button', onClick: state.retry }, '重试') : null)
102
+ }
103
+
104
+ function validImage(meta) {
105
+ return meta && typeof meta.src === 'string'
106
+ && /^data:image\/(?:svg\+xml|png|jpeg|gif|webp|bmp|x-icon|avif);base64,/.test(meta.src)
107
+ }
108
+
109
+ /**
110
+ * 键控 toolview(preview_image):从 meta 读取 { src, mime, label } 渲染
111
+ * 原生 <img>。
112
+ */
113
+ function PreviewToolView(props) {
114
+ const state = usePreview(props)
115
+ const meta = state.meta
116
+ const [failedSrc, setFailedSrc] = React.useState(null)
117
+ React.useEffect(() => { setFailedSrc(null) }, [props.sessionId, props.callId])
118
+ const label = typeof meta === 'object' && meta !== null && typeof meta.label === 'string' ? meta.label : '预览'
119
+ const src = typeof meta === 'object' && meta !== null && typeof meta.src === 'string' ? meta.src : null
120
+ const isSvg = typeof meta === 'object' && meta !== null && meta.mime === 'image/svg+xml'
121
+ if (!meta) return React.createElement(PreviewStatus, { state })
122
+ if (!validImage(meta)) return React.createElement(PreviewStatus, { state: { error: '图片预览格式无效。' } })
123
+ if (failedSrc === src) return React.createElement(PreviewStatus, { state: {
124
+ error: '浏览器无法解码这张图片,请检查图片文件或重新生成预览。', retry: () => setFailedSrc(null),
125
+ } })
126
+ return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
127
+ React.createElement('span', { className: 'dip-label' }, label),
128
+ React.createElement('figure', { className: 'dip-figure' },
129
+ React.createElement('img', {
130
+ className: 'dip-img' + (isSvg ? ' svg' : ''),
131
+ src: src,
132
+ alt: label,
133
+ onError: () => setFailedSrc(src),
134
+ })))
135
+ }
136
+
137
+ const NUMERIC_RE = /^-?\d+(\.\d+)?$/
138
+
139
+ /** 截断/统计脚注:「显示前 50 / 1234 · 5 / 8 · 分隔符 ","」。 */
140
+ function csvNote(meta) {
141
+ const parts = []
142
+ parts.push(meta.rowsShown < meta.totalRows
143
+ ? `显示前 ${meta.rowsShown} / ${meta.totalRows} 行`
144
+ : `共 ${meta.totalRows} 行`)
145
+ if (meta.colsShown < meta.totalCols) parts.push(`前 ${meta.colsShown} / ${meta.totalCols} 列`)
146
+ parts.push(`分隔符 "${meta.delimiter === '\t' ? 'Tab' : meta.delimiter}"`)
147
+ return parts.join(' · ')
148
+ }
149
+
150
+ /**
151
+ * 对数据行(不含表头)按列排序:整列(除空串)可解析为数字时按数值比较,
152
+ * 否则按中文 locale 比较;空值恒沉底。dir=1 升序,-1 降序。
153
+ */
154
+ function sortBodyRows(meta, col, dir) {
155
+ const body = meta.rows.slice(1)
156
+ const numeric = body.every((r) => {
157
+ const v = col < r.length ? r[col] : ''
158
+ return v === '' || NUMERIC_RE.test(v)
159
+ }) && body.some((r) => col < r.length && NUMERIC_RE.test(r[col]))
160
+ const cmp = numeric
161
+ ? (a, b) => (parseFloat(a) || 0) - (parseFloat(b) || 0)
162
+ : (a, b) => String(a).localeCompare(String(b), 'zh-Hans-CN')
163
+ return body
164
+ .map((_, i) => i)
165
+ .sort((x, y) => {
166
+ const a = x < body.length && col < body[x].length ? body[x][col] : ''
167
+ const b = y < body.length && col < body[y].length ? body[y][col] : ''
168
+ if ((a === '') !== (b === '')) return a === '' ? 1 : -1
169
+ return cmp(a, b) * dir
170
+ })
171
+ .map((i) => body[i])
172
+ }
173
+
174
+ /**
175
+ * 键控 toolview(preview_csv):从 meta 读取 { label, delimiter, rows,
176
+ * totalRows, totalCols, rowsShown, colsShown },渲染原生 <table>。
177
+ * rows[0] 为表头;数字单元格右对齐;表头点击循环排序(升→降→取消)
178
+ */
179
+ function CsvToolView(props) {
180
+ const state = usePreview(props)
181
+ const meta = state.meta
182
+ const ok = meta !== null && typeof meta === 'object' && Array.isArray(meta.rows)
183
+ && meta.rows.length > 0 && meta.rows.every(Array.isArray)
184
+ // hooks 必须无条件调用,放在提前 return 之前。
185
+ const sortPair = React.useState(null) // null | { col, dir }
186
+ const sort = sortPair[0]
187
+ const setSort = sortPair[1]
188
+ React.useEffect(() => { setSort(null) }, [meta])
189
+ if (!ok) {
190
+ return React.createElement(PreviewStatus, { state: meta ? { error: 'CSV 预览数据无效。' } : state })
191
+ }
192
+ const label = typeof meta.label === 'string' && meta.label !== '' ? meta.label : 'CSV 预览'
193
+
194
+ const headerClick = (ci) => {
195
+ setSort((prev) => (prev !== null && prev.col === ci ? (prev.dir === 1 ? { col: ci, dir: -1 } : null) : { col: ci, dir: 1 }))
196
+ }
197
+
198
+ const thead = React.createElement('thead', null,
199
+ React.createElement('tr', null, meta.rows[0].map((c, i) => {
200
+ const indicator = sort !== null && sort.col === i ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''
201
+ return React.createElement('th', {
202
+ key: i,
203
+ onClick: () => headerClick(i),
204
+ title: String(c),
205
+ }, String(c) + indicator)
206
+ })))
207
+ const bodyRows = sort !== null ? sortBodyRows(meta, sort.col, sort.dir) : meta.rows.slice(1)
208
+ const tbody = React.createElement('tbody', null, bodyRows.map((r, ri) =>
209
+ React.createElement('tr', { key: ri }, r.map((c, ci) => {
210
+ const s = String(c)
211
+ return React.createElement('td', {
212
+ key: ci,
213
+ className: NUMERIC_RE.test(s) ? 'num' : undefined,
214
+ title: s,
215
+ }, s)
216
+ }))))
217
+ return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
218
+ React.createElement('span', { className: 'dip-label' }, label),
219
+ React.createElement('div', { className: 'dip-table-wrap' },
220
+ React.createElement('table', { className: 'dip-table' }, thead, tbody)),
221
+ React.createElement('span', { className: 'dip-csv-note' }, csvNote(meta)))
222
+ }
223
+
224
+ // ── 服务接入 ─────────────────────────────────────────────────────────────
225
+ const PREVIEWS_KEY = 'csv-and-image-preview'
226
+ const isPreviewTool = name => name === 'preview_image' || name === 'preview_csv'
227
+
228
+ // Replay successful native and PTC previews as dedicated Chat nodes.
229
+ // Their end-of-turn anchors remain outside the collapsible process interval.
230
+ const previewCalls = {
231
+ kind: PREVIEWS_KEY + '/calls',
232
+ target: 'chat',
233
+ match(event) {
234
+ if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
235
+ if (event.type === 'tool/result' && event.surfaceOp === 'append') {
236
+ return { id: String(event.data.message.source.callId), role: 'update' }
237
+ }
238
+ if (event.type === 'tool/ptc-dispatch' && typeof event.data.rootCallId === 'string') {
239
+ return { id: event.data.rootCallId, role: 'update' }
240
+ }
241
+ return null
242
+ },
243
+ start(_context, match) { return { name: match.event.data.name, previews: [] } },
244
+ update(context, match) {
245
+ const { event } = match
246
+ const nested = event.type === 'tool/ptc-dispatch'
247
+ const toolName = nested ? event.data.name : context.state.name
248
+ const result = nested ? event.data : event.data.message.content[0]
249
+ if (!isPreviewTool(toolName) || !result || result.isError) return context.state
250
+ const content = result.content || []
251
+ if (content.some(item => item.type === 'text' && /^preview_(?:image|csv) 失败:/.test(item.text))) return context.state
252
+ const callId = String(nested ? event.data.subCallId : event.data.message.source.callId)
253
+ const preview = { callId, toolName, seq: event.seq,
254
+ block: { kind: 'tool-result', isError: false, content,
255
+ ...!nested && event.data.meta ? { meta: event.data.meta } : {} } }
256
+ return { ...context.state, previews: [...context.state.previews.filter(item => item.callId !== callId), preview] }
257
+ },
258
+ buildViewNode(context) {
259
+ const location = context.start?.location
260
+ if (!context.state || !location?.turn) return null
261
+ const end = location.turn.end
262
+ return { key: context.key, id: context.id, kind: context.kind, target: 'chat', location,
263
+ // Placing this row after turn/end keeps it outside the process interval.
264
+ // A hidden node keeps its identity while the toolview handles live output.
265
+ anchorSeq: end ? end.seq + 0.01 : context.start.event.seq,
266
+ visibility: end && context.state.previews.length ? 'visible' : 'hidden',
267
+ data: { previews: context.state.previews } }
268
+ },
269
+ }
270
+ function ChatPreviews(props) {
271
+ return React.createElement('div', { className: 'dip-root', 'data-dip-turn-previews': true },
272
+ props.node.data.previews.map(preview => React.createElement(preview.toolName === 'preview_image' ? PreviewToolView : CsvToolView,
273
+ { ...props, ...preview, key: props.sessionId + ':' + preview.callId })))
274
+ }
275
+
276
+ const inject = ['slots', 'connection', 'uiConversation']
277
+ function apply(ctx) {
278
+ const slots = ctx.slots
279
+ const connection = ctx.connection
280
+ const bindings = {
281
+ async loadPreview(request, signal) {
282
+ const result = await connection.rpc.call('/api', 'csv-and-image-preview/get', request, signal)
283
+ if (!result.ok) throw new Error(result.error.message)
284
+ const value = result.value
285
+ if (request.toolName === 'preview_image' ? !validImage(value)
286
+ : !value || !Array.isArray(value.rows) || !value.rows.length || !value.rows.every(Array.isArray)) {
287
+ throw new Error('返回的预览数据无效。')
288
+ }
289
+ return result.value
290
+ },
291
+ subscribeConnection: listener => connection.generation.subscribe(listener),
292
+ getConnection: () => connection.generation.getSnapshot(),
293
+ }
294
+ ctx.effect(() => {
295
+ const disposals = [
296
+ ctx.uiConversation.events.register(previewCalls),
297
+ slots.inject('tool.call.toolview', () => slots.register(
298
+ { name: 'tool.call.toolview', key: 'preview_image', inject: () => bindings },
299
+ PreviewToolView,
300
+ )),
301
+ slots.inject('tool.call.toolview', () => slots.register(
302
+ { name: 'tool.call.toolview', key: 'preview_csv', inject: () => bindings },
303
+ CsvToolView,
304
+ )),
305
+ slots.inject('conversation.chat.node', () => slots.register(
306
+ { name: 'conversation.chat.node', key: PREVIEWS_KEY + '/calls', inject: () => bindings },
307
+ ChatPreviews,
308
+ )),
309
+ ]
310
+ return () => { for (const dispose of disposals) dispose() }
311
+ }, 'preview: toolviews')
312
+ }
313
+
314
+ exports.apply = apply
315
+ exports.inject = inject
316
+ return module.exports
317
+ },
318
+ })