@things-factory/ai-assistant 10.1.88 → 10.1.91

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.
Files changed (93) hide show
  1. package/client/components/assistant-chat.ts +45 -5
  2. package/client/components/assistant-dock.ts +30 -2
  3. package/client/components/entry-review.ts +246 -0
  4. package/client/components/workspace-view.ts +428 -0
  5. package/client/pages/assistant-workspace-page.ts +237 -0
  6. package/client/route.ts +9 -0
  7. package/client/utils/assistant-proposals.ts +11 -1
  8. package/client/utils/entry-proposal.ts +103 -0
  9. package/client/utils/workspace.ts +89 -0
  10. package/dist-client/components/assistant-chat.d.ts +3 -0
  11. package/dist-client/components/assistant-chat.js +46 -5
  12. package/dist-client/components/assistant-chat.js.map +1 -1
  13. package/dist-client/components/assistant-dock.d.ts +1 -0
  14. package/dist-client/components/assistant-dock.js +25 -2
  15. package/dist-client/components/assistant-dock.js.map +1 -1
  16. package/dist-client/components/entry-review.d.ts +9 -0
  17. package/dist-client/components/entry-review.js +247 -0
  18. package/dist-client/components/entry-review.js.map +1 -0
  19. package/dist-client/components/workspace-view.d.ts +19 -0
  20. package/dist-client/components/workspace-view.js +416 -0
  21. package/dist-client/components/workspace-view.js.map +1 -0
  22. package/dist-client/pages/assistant-workspace-page.d.ts +27 -0
  23. package/dist-client/pages/assistant-workspace-page.js +263 -0
  24. package/dist-client/pages/assistant-workspace-page.js.map +1 -0
  25. package/dist-client/route.d.ts +1 -0
  26. package/dist-client/route.js +10 -0
  27. package/dist-client/route.js.map +1 -0
  28. package/dist-client/tsconfig.tsbuildinfo +1 -1
  29. package/dist-client/utils/assistant-proposals.d.ts +1 -0
  30. package/dist-client/utils/assistant-proposals.js +9 -1
  31. package/dist-client/utils/assistant-proposals.js.map +1 -1
  32. package/dist-client/utils/entry-proposal.d.ts +55 -0
  33. package/dist-client/utils/entry-proposal.js +52 -0
  34. package/dist-client/utils/entry-proposal.js.map +1 -0
  35. package/dist-client/utils/workspace.d.ts +40 -0
  36. package/dist-client/utils/workspace.js +68 -0
  37. package/dist-client/utils/workspace.js.map +1 -0
  38. package/dist-server/service/assistant-mode/registry.js +13 -0
  39. package/dist-server/service/assistant-mode/registry.js.map +1 -1
  40. package/dist-server/service/assistant-mode/resolve.d.ts +11 -0
  41. package/dist-server/service/assistant-mode/resolve.js +15 -3
  42. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  43. package/dist-server/service/assistant-mode/types.d.ts +7 -1
  44. package/dist-server/service/assistant-mode/types.js.map +1 -1
  45. package/dist-server/service/data-entry/entry-apply.d.ts +46 -0
  46. package/dist-server/service/data-entry/entry-apply.js +102 -0
  47. package/dist-server/service/data-entry/entry-apply.js.map +1 -0
  48. package/dist-server/service/data-entry/entry-resolver.d.ts +4 -0
  49. package/dist-server/service/data-entry/entry-resolver.js +80 -0
  50. package/dist-server/service/data-entry/entry-resolver.js.map +1 -0
  51. package/dist-server/service/data-entry/entry-sources.d.ts +1 -0
  52. package/dist-server/service/data-entry/entry-sources.js +41 -0
  53. package/dist-server/service/data-entry/entry-sources.js.map +1 -0
  54. package/dist-server/service/data-entry/entry-target.d.ts +157 -0
  55. package/dist-server/service/data-entry/entry-target.js +343 -0
  56. package/dist-server/service/data-entry/entry-target.js.map +1 -0
  57. package/dist-server/service/data-entry/entry-tools.d.ts +43 -0
  58. package/dist-server/service/data-entry/entry-tools.js +185 -0
  59. package/dist-server/service/data-entry/entry-tools.js.map +1 -0
  60. package/dist-server/service/index.d.ts +6 -1
  61. package/dist-server/service/index.js +13 -2
  62. package/dist-server/service/index.js.map +1 -1
  63. package/dist-server/service/workspace/workspace-blocks.d.ts +24 -0
  64. package/dist-server/service/workspace/workspace-blocks.js +118 -0
  65. package/dist-server/service/workspace/workspace-blocks.js.map +1 -0
  66. package/dist-server/service/workspace/workspace-tools.d.ts +24 -0
  67. package/dist-server/service/workspace/workspace-tools.js +65 -0
  68. package/dist-server/service/workspace/workspace-tools.js.map +1 -0
  69. package/dist-server/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +2 -2
  71. package/server/service/assistant-mode/registry.ts +13 -0
  72. package/server/service/assistant-mode/resolve.test.ts +2 -1
  73. package/server/service/assistant-mode/resolve.ts +15 -3
  74. package/server/service/assistant-mode/types.ts +7 -1
  75. package/server/service/data-entry/data-entry.test.ts +311 -0
  76. package/server/service/data-entry/entry-apply.ts +134 -0
  77. package/server/service/data-entry/entry-resolver.ts +74 -0
  78. package/server/service/data-entry/entry-sources.ts +35 -0
  79. package/server/service/data-entry/entry-target.ts +442 -0
  80. package/server/service/data-entry/entry-tools.ts +174 -0
  81. package/server/service/index.ts +10 -1
  82. package/server/service/workspace/workspace-blocks.ts +127 -0
  83. package/server/service/workspace/workspace-tools.ts +66 -0
  84. package/server/service/workspace/workspace.test.ts +58 -0
  85. package/test/entry-proposal.test.ts +67 -0
  86. package/test/open-doors.test.ts +2 -1
  87. package/test/workspace.test.ts +54 -0
  88. package/things-factory.config.js +6 -1
  89. package/translations/en.json +56 -1
  90. package/translations/ja.json +56 -1
  91. package/translations/ko.json +56 -1
  92. package/translations/ms.json +56 -1
  93. package/translations/zh.json +56 -1
@@ -0,0 +1,428 @@
1
+ /*
2
+ * The blocks of an AI 작업판, drawn (operato-application/design/ai/workspace.md §3).
3
+ *
4
+ * Every kind is drawn here - the model writes no HTML. Charts use the eight validated categorical colours in
5
+ * a fixed order (dataviz reference palette, light and dark each checked); three of the light ones sit under
6
+ * 3:1 against the surface, so every chart carries its legend (two series or more) and a table view.
7
+ */
8
+ import '@material/web/icon/icon.js'
9
+
10
+ import { LitElement, css, html, nothing, svg, type TemplateResult } from 'lit'
11
+ import { customElement, property, state } from 'lit/decorators.js'
12
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js'
13
+
14
+ import { i18next } from '@operato/i18n'
15
+
16
+ import { renderMarkdown } from './markdown.js'
17
+ import { sortRows, tableCsv, type WorkspaceBlockView, type WorkspaceView } from '../utils/workspace.js'
18
+
19
+ const LIGHT = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948']
20
+ const DARK = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767']
21
+
22
+ const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
23
+ String(i18next.t(`ai-assistant.workspace.${key}`, { defaultValue, ...options }))
24
+
25
+ const shown = (value: unknown) =>
26
+ value === null || value === undefined ? '' : typeof value === 'number' ? value.toLocaleString(i18next.language || undefined) : String(value)
27
+
28
+ /** Four round steps from zero to above the largest value. */
29
+ function ticksFor(max: number): number[] {
30
+ if (!(max > 0)) return [0, 1]
31
+ const raw = max / 4
32
+ const power = 10 ** Math.floor(Math.log10(raw))
33
+ const step = [1, 2, 2.5, 5, 10].map(one => one * power).find(one => one >= raw) ?? raw
34
+ return Array.from({ length: Math.ceil(max / step) + 1 }, (_, i) => Math.round(i * step * 1e6) / 1e6)
35
+ }
36
+
37
+ @customElement('ox-assistant-workspace-view')
38
+ export class AssistantWorkspaceView extends LitElement {
39
+ static styles = css`
40
+ :host {
41
+ display: block;
42
+ color: var(--md-sys-color-on-surface);
43
+ font-size: 14px;
44
+ line-height: 1.5;
45
+ }
46
+
47
+ section {
48
+ margin: 0 0 20px;
49
+ }
50
+
51
+ h1,
52
+ h2,
53
+ h3 {
54
+ margin: 24px 0 8px;
55
+ line-height: 1.3;
56
+ }
57
+
58
+ h1 {
59
+ font-size: 22px;
60
+ }
61
+
62
+ h2 {
63
+ font-size: 18px;
64
+ }
65
+
66
+ h3 {
67
+ font-size: 15px;
68
+ }
69
+
70
+ .metrics {
71
+ display: grid;
72
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
73
+ gap: 10px;
74
+ }
75
+
76
+ .metric {
77
+ padding: 12px 14px;
78
+ border: 1px solid var(--md-sys-color-outline-variant);
79
+ border-radius: 10px;
80
+ background: var(--md-sys-color-surface-container-lowest);
81
+ }
82
+
83
+ .metric .label {
84
+ color: var(--md-sys-color-on-surface-variant);
85
+ font-size: 12px;
86
+ }
87
+
88
+ .metric .value {
89
+ font-size: 22px;
90
+ font-weight: 650;
91
+ font-variant-numeric: tabular-nums;
92
+ }
93
+
94
+ .metric .note {
95
+ color: var(--md-sys-color-on-surface-variant);
96
+ font-size: 12px;
97
+ }
98
+
99
+ .bar {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: 8px;
103
+ margin-bottom: 6px;
104
+ color: var(--md-sys-color-on-surface-variant);
105
+ font-size: 12px;
106
+ }
107
+
108
+ .bar .caption {
109
+ flex: 1;
110
+ }
111
+
112
+ .bar button {
113
+ display: inline-flex;
114
+ align-items: center;
115
+ gap: 4px;
116
+ padding: 3px 8px;
117
+ border: 1px solid var(--md-sys-color-outline-variant);
118
+ border-radius: 6px;
119
+ background: transparent;
120
+ color: inherit;
121
+ font: inherit;
122
+ cursor: pointer;
123
+ }
124
+
125
+ .bar md-icon {
126
+ --md-icon-size: 16px;
127
+ }
128
+
129
+ .scroll {
130
+ max-height: 560px;
131
+ overflow: auto;
132
+ border: 1px solid var(--md-sys-color-outline-variant);
133
+ border-radius: 8px;
134
+ }
135
+
136
+ table {
137
+ width: 100%;
138
+ border-collapse: collapse;
139
+ font-size: 13px;
140
+ }
141
+
142
+ th,
143
+ td {
144
+ padding: 6px 10px;
145
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
146
+ text-align: start;
147
+ white-space: nowrap;
148
+ }
149
+
150
+ th {
151
+ position: sticky;
152
+ top: 0;
153
+ background: var(--md-sys-color-surface-container);
154
+ font-weight: 600;
155
+ }
156
+
157
+ th button {
158
+ border: 0;
159
+ padding: 0;
160
+ background: none;
161
+ color: inherit;
162
+ font: inherit;
163
+ cursor: pointer;
164
+ }
165
+
166
+ td.num,
167
+ th.num {
168
+ text-align: end;
169
+ font-variant-numeric: tabular-nums;
170
+ }
171
+
172
+ .chart svg {
173
+ width: 100%;
174
+ height: auto;
175
+ display: block;
176
+ }
177
+
178
+ .chart .grid {
179
+ stroke: var(--md-sys-color-outline-variant);
180
+ stroke-width: 1;
181
+ }
182
+
183
+ .chart .tick {
184
+ fill: var(--md-sys-color-on-surface-variant);
185
+ font-size: 11px;
186
+ }
187
+
188
+ .legend {
189
+ display: flex;
190
+ flex-wrap: wrap;
191
+ gap: 4px 14px;
192
+ margin-top: 6px;
193
+ font-size: 12px;
194
+ }
195
+
196
+ .legend .swatch {
197
+ display: inline-block;
198
+ width: 10px;
199
+ height: 10px;
200
+ margin-inline-end: 5px;
201
+ border-radius: 3px;
202
+ vertical-align: -1px;
203
+ }
204
+
205
+ .steps {
206
+ list-style: none;
207
+ margin: 0;
208
+ padding: 0;
209
+ }
210
+
211
+ .steps li {
212
+ display: flex;
213
+ gap: 8px;
214
+ padding: 4px 0;
215
+ }
216
+
217
+ .steps md-icon {
218
+ --md-icon-size: 18px;
219
+ }
220
+
221
+ .steps .done md-icon {
222
+ color: var(--md-sys-color-primary);
223
+ }
224
+
225
+ .steps .failed md-icon {
226
+ color: var(--md-sys-color-error);
227
+ }
228
+
229
+ .steps .note {
230
+ display: block;
231
+ color: var(--md-sys-color-on-surface-variant);
232
+ font-size: 12px;
233
+ }
234
+
235
+ .unreadable,
236
+ .cut {
237
+ color: var(--md-sys-color-on-surface-variant);
238
+ font-size: 12px;
239
+ font-style: italic;
240
+ }
241
+
242
+ .markdown :first-child {
243
+ margin-top: 0;
244
+ }
245
+ `
246
+
247
+ @property({ attribute: false }) workspace?: WorkspaceView
248
+
249
+ @state() private sorts: Record<number, { column: string; direction: 'asc' | 'desc' }> = {}
250
+ @state() private asTable = new Set<number>()
251
+ @state() private dark = false
252
+
253
+ private themeWatch?: MutationObserver
254
+
255
+ connectedCallback() {
256
+ super.connectedCallback()
257
+ this.dark = document.body.classList.contains('dark')
258
+ this.themeWatch = new MutationObserver(() => (this.dark = document.body.classList.contains('dark')))
259
+ this.themeWatch.observe(document.body, { attributes: true, attributeFilter: ['class'] })
260
+ }
261
+
262
+ disconnectedCallback() {
263
+ this.themeWatch?.disconnect()
264
+ super.disconnectedCallback()
265
+ }
266
+
267
+ render() {
268
+ const blocks = this.workspace?.blocks ?? []
269
+ return html`${blocks.map((block, i) => this.block(block, i))}`
270
+ }
271
+
272
+ private block(block: WorkspaceBlockView, i: number): TemplateResult {
273
+ switch (block.kind) {
274
+ case 'heading':
275
+ return block.level === 1 ? html`<h1>${block.text}</h1>` : block.level === 3 ? html`<h3>${block.text}</h3>` : html`<h2>${block.text}</h2>`
276
+ case 'text':
277
+ return html`<section class="markdown">${unsafeHTML(renderMarkdown(block.markdown))}</section>`
278
+ case 'metrics':
279
+ return html`<section class="metrics">
280
+ ${(block.items ?? []).map(
281
+ (one: any) => html`<div class="metric">
282
+ <div class="label">${one.label}</div>
283
+ <div class="value">${shown(one.value)}${one.unit ? html` <small>${one.unit}</small>` : nothing}</div>
284
+ ${one.note ? html`<div class="note">${one.note}</div>` : nothing}
285
+ </div>`
286
+ )}
287
+ </section>`
288
+ case 'table':
289
+ return this.table(block, i)
290
+ case 'chart':
291
+ return this.chart(block, i)
292
+ case 'steps':
293
+ return html`<section>
294
+ <ol class="steps">
295
+ ${(block.items ?? []).map(
296
+ (one: any) => html`<li class=${one.state}>
297
+ <md-icon>${{ done: 'check_circle', doing: 'progress_activity', failed: 'error', todo: 'radio_button_unchecked' }[one.state as string] ?? 'radio_button_unchecked'}</md-icon>
298
+ <span>${one.label}${one.note ? html`<span class="note">${one.note}</span>` : nothing}</span>
299
+ </li>`
300
+ )}
301
+ </ol>
302
+ </section>`
303
+ default:
304
+ return html`<section class="unreadable">${t('unreadable', 'a block that cannot be drawn ({reason})', { reason: block.reason ?? block.kind })}</section>`
305
+ }
306
+ }
307
+
308
+ private table(block: WorkspaceBlockView, i: number): TemplateResult {
309
+ const columns: { name: string; label?: string; type?: string }[] = block.columns ?? []
310
+ const sort = this.sorts[i]
311
+ const rows = sort ? sortRows(block.rows ?? [], sort.column, sort.direction, i18next.language) : block.rows ?? []
312
+ const toggle = (column: string) =>
313
+ (this.sorts = { ...this.sorts, [i]: { column, direction: sort?.column === column && sort.direction === 'asc' ? 'desc' : 'asc' } })
314
+ return html`<section>
315
+ <div class="bar">
316
+ <span class="caption">${block.caption ?? ''} · ${t('rows', '{count} rows', { count: rows.length })}</span>
317
+ <button @click=${() => this.download(block.caption || this.workspace?.title || 'table', columns, rows)}>
318
+ <md-icon>download</md-icon>${t('csv', 'CSV')}
319
+ </button>
320
+ </div>
321
+ ${this.tableOf(columns, rows, sort, toggle)}
322
+ ${block.cut ? html`<div class="cut">${t('cut', '{count} more rows were left out over the limit.', { count: block.cut })}</div>` : nothing}
323
+ </section>`
324
+ }
325
+
326
+ private tableOf(columns: { name: string; label?: string; type?: string }[], rows: Record<string, any>[], sort?: { column: string; direction: string }, toggle?: (column: string) => void) {
327
+ return html`<div class="scroll">
328
+ <table>
329
+ <thead>
330
+ <tr>
331
+ ${columns.map(
332
+ column => html`<th class=${column.type === 'number' ? 'num' : ''} aria-sort=${sort?.column === column.name ? (sort.direction === 'asc' ? 'ascending' : 'descending') : 'none'}>
333
+ ${toggle
334
+ ? html`<button @click=${() => toggle(column.name)}>${column.label || column.name}${sort?.column === column.name ? (sort.direction === 'asc' ? ' ▲' : ' ▼') : ''}</button>`
335
+ : column.label || column.name}
336
+ </th>`
337
+ )}
338
+ </tr>
339
+ </thead>
340
+ <tbody>
341
+ ${rows.map(row => html`<tr>${columns.map(column => html`<td class=${column.type === 'number' || typeof row[column.name] === 'number' ? 'num' : ''}>${shown(row[column.name])}</td>`)}</tr>`)}
342
+ </tbody>
343
+ </table>
344
+ </div>`
345
+ }
346
+
347
+ private chart(block: WorkspaceBlockView, i: number): TemplateResult {
348
+ const colours = this.dark ? DARK : LIGHT
349
+ const x: string[] = block.x ?? []
350
+ const series: { name: string; other?: boolean; values: (number | null)[] }[] = block.series ?? []
351
+ const name = (one: { name: string; other?: boolean }) => (one.other ? t('other', 'other') : one.name)
352
+ const showingTable = this.asTable.has(i)
353
+ const flip = () => {
354
+ const next = new Set(this.asTable)
355
+ next.has(i) ? next.delete(i) : next.add(i)
356
+ this.asTable = next
357
+ }
358
+
359
+ const bar = html`<div class="bar">
360
+ <span class="caption">${block.caption ?? ''}${block.unit ? ` (${block.unit})` : ''}</span>
361
+ <button @click=${flip}><md-icon>${showingTable ? 'bar_chart' : 'table_rows'}</md-icon>${showingTable ? t('as-chart', 'chart') : t('as-table', 'table')}</button>
362
+ </div>`
363
+
364
+ if (showingTable) {
365
+ const columns = [{ name: '_x', label: '' }, ...series.map((one, s) => ({ name: `s${s}`, label: name(one), type: 'number' }))]
366
+ const rows = x.map((label, j) => Object.fromEntries([['_x', label], ...series.map((one, s) => [`s${s}`, one.values[j]])]))
367
+ return html`<section class="chart">${bar}${this.tableOf(columns, rows)}</section>`
368
+ }
369
+
370
+ const W = 640
371
+ const H = 240
372
+ const left = 56
373
+ const bottom = 28
374
+ const plotW = W - left - 8
375
+ const plotH = H - bottom - 10
376
+ const values = series.flatMap(one => one.values).filter((v): v is number => typeof v === 'number')
377
+ const min = Math.min(0, ...values)
378
+ const ticks = ticksFor(Math.max(...values, 0) - min).map(tick => tick + min)
379
+ const top = ticks[ticks.length - 1] || 1
380
+ const y = (v: number) => 10 + plotH - ((v - min) / (top - min || 1)) * plotH
381
+ const band = plotW / Math.max(1, x.length)
382
+ const everyNth = Math.max(1, Math.ceil(x.length / 12))
383
+
384
+ const marks =
385
+ block.chart === 'line'
386
+ ? series.map((one, s) => {
387
+ const points = one.values.map((v, j) => (typeof v === 'number' ? [left + band * (j + 0.5), y(v)] : null))
388
+ const path = points.reduce((d, p, j) => (p ? `${d}${d && points[j - 1] ? 'L' : 'M'}${p[0]},${p[1]}` : d), '')
389
+ return svg`<path d=${path} fill="none" stroke=${colours[s % 8]} stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></path>
390
+ ${points.map((p, j) =>
391
+ p ? svg`<circle cx=${p[0]} cy=${p[1]} r="4" fill=${colours[s % 8]} stroke="var(--md-sys-color-surface)" stroke-width="2"><title>${name(one)} · ${x[j]}: ${shown(one.values[j])}</title></circle>` : nothing
392
+ )}`
393
+ })
394
+ : series.map((one, s) => {
395
+ const inner = band * 0.7
396
+ const width = Math.max(2, inner / series.length - 2)
397
+ return one.values.map((v, j) => {
398
+ if (typeof v !== 'number') return nothing
399
+ const x0 = left + band * j + (band - inner) / 2 + s * (width + 2)
400
+ const y0 = Math.min(y(v), y(0))
401
+ const h = Math.max(1, Math.abs(y(v) - y(0)))
402
+ return svg`<rect x=${x0} y=${y0} width=${width} height=${h} rx="3" fill=${colours[s % 8]}><title>${name(one)} · ${x[j]}: ${shown(v)}</title></rect>`
403
+ })
404
+ })
405
+
406
+ return html`<section class="chart" role="img" aria-label=${block.caption || series.map(name).join(', ')}>
407
+ ${bar}
408
+ <svg viewBox="0 0 ${W} ${H}">
409
+ ${ticks.map(tick => svg`<line class="grid" x1=${left} x2=${W - 8} y1=${y(tick)} y2=${y(tick)}></line><text class="tick" x=${left - 6} y=${y(tick) + 4} text-anchor="end">${shown(tick)}</text>`)}
410
+ ${marks}
411
+ ${x.map((label, j) => (j % everyNth ? nothing : svg`<text class="tick" x=${left + band * (j + 0.5)} y=${H - 8} text-anchor="middle">${label}</text>`))}
412
+ </svg>
413
+ ${series.length > 1
414
+ ? html`<div class="legend">${series.map((one, s) => html`<span><span class="swatch" style="background:${colours[s % 8]}"></span>${name(one)}</span>`)}</div>`
415
+ : nothing}
416
+ </section>`
417
+ }
418
+
419
+ private download(title: string, columns: { name: string; label?: string }[], rows: Record<string, any>[]) {
420
+ const blob = new Blob([tableCsv(columns, rows)], { type: 'text/csv;charset=utf-8' })
421
+ const url = URL.createObjectURL(blob)
422
+ const link = document.createElement('a')
423
+ link.href = url
424
+ link.download = `${title.replace(/[\\/:*?"<>|]+/g, '-').slice(0, 80)}.csv`
425
+ link.click()
426
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
427
+ }
428
+ }
@@ -0,0 +1,237 @@
1
+ /*
2
+ * The AI 작업판 page (operato-application/design/ai/workspace.md).
3
+ *
4
+ * `assistant-workspace?session=<id>&name=<name>[&version=<n>]`. It reads the conversation through its own
5
+ * door (`chatMessages`), so a private conversation shows nothing to someone outside it and a version behind a
6
+ * door the reader cannot pass is not among the versions (ADR-0073). A new version arriving while the page is
7
+ * open is announced, never swapped in under the reader.
8
+ */
9
+ import '@material/web/icon/icon.js'
10
+ import '../components/workspace-view.js'
11
+
12
+ import gql from 'graphql-tag'
13
+ import { css, html, nothing } from 'lit'
14
+ import { customElement, state } from 'lit/decorators.js'
15
+
16
+ import { client } from '@operato/graphql'
17
+ import { i18next, localize } from '@operato/i18n'
18
+ import { navigate, PageView } from '@operato/shell'
19
+ import { ScrollbarStyles } from '@operato/styles'
20
+
21
+ import { startSessionActivityFeed, subscribeSessionActivity } from '../utils/assistant-activity.js'
22
+ import { workspaceAddress, workspaceVersions, type WorkspaceVersion } from '../utils/workspace.js'
23
+
24
+ const MESSAGES = gql`
25
+ query ($sessionId: String!, $limit: Int, $offset: Int) {
26
+ chatMessages(sessionId: $sessionId, limit: $limit, offset: $offset) {
27
+ id
28
+ createdAt
29
+ proposalsJson
30
+ }
31
+ }
32
+ `
33
+
34
+ const PAGE = 200
35
+ const MOST = 5000
36
+
37
+ const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
38
+ String(i18next.t(`ai-assistant.workspace.${key}`, { defaultValue, ...options }))
39
+
40
+ @customElement('assistant-workspace-page')
41
+ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
42
+ static styles = [
43
+ ScrollbarStyles,
44
+ css`
45
+ :host {
46
+ display: block;
47
+ overflow: auto;
48
+ padding: 20px 24px 40px;
49
+ box-sizing: border-box;
50
+ background: var(--md-sys-color-surface);
51
+ }
52
+
53
+ .page {
54
+ max-width: 1100px;
55
+ margin: 0 auto;
56
+ }
57
+
58
+ header {
59
+ display: flex;
60
+ align-items: flex-start;
61
+ gap: 12px;
62
+ margin-bottom: 16px;
63
+ }
64
+
65
+ header .what {
66
+ flex: 1;
67
+ min-width: 0;
68
+ }
69
+
70
+ header h1 {
71
+ margin: 0;
72
+ font-size: 24px;
73
+ line-height: 1.3;
74
+ }
75
+
76
+ header .summary {
77
+ margin-top: 4px;
78
+ color: var(--md-sys-color-on-surface-variant);
79
+ }
80
+
81
+ header select {
82
+ padding: 4px 8px;
83
+ border: 1px solid var(--md-sys-color-outline-variant);
84
+ border-radius: 6px;
85
+ background: var(--md-sys-color-surface-container-lowest);
86
+ color: inherit;
87
+ font: inherit;
88
+ }
89
+
90
+ .notice {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 10px;
94
+ margin-bottom: 14px;
95
+ padding: 8px 12px;
96
+ border-radius: 8px;
97
+ background: var(--md-sys-color-secondary-container);
98
+ color: var(--md-sys-color-on-secondary-container);
99
+ }
100
+
101
+ .notice button {
102
+ padding: 3px 10px;
103
+ border: 1px solid currentColor;
104
+ border-radius: 6px;
105
+ background: transparent;
106
+ color: inherit;
107
+ font: inherit;
108
+ cursor: pointer;
109
+ }
110
+
111
+ .empty {
112
+ padding: 40px 0;
113
+ color: var(--md-sys-color-on-surface-variant);
114
+ text-align: center;
115
+ }
116
+ `
117
+ ]
118
+
119
+ @state() private sessionId = ''
120
+ @state() private name = ''
121
+ @state() private wanted = 0
122
+ @state() private versions: WorkspaceVersion[] = []
123
+ @state() private loading = false
124
+ @state() private failure = ''
125
+ /** A version that arrived after the page was drawn - announced, not shown until asked. */
126
+ @state() private newer = 0
127
+
128
+ private unsubscribe?: () => void
129
+
130
+ get context() {
131
+ const shown = this.shown
132
+ return { title: shown?.workspace.title || t('title', 'AI workspace') }
133
+ }
134
+
135
+ private get shown(): WorkspaceVersion | undefined {
136
+ if (!this.versions.length) return undefined
137
+ return this.versions.find(one => one.version === this.wanted) ?? this.versions[this.versions.length - 1]
138
+ }
139
+
140
+ connectedCallback() {
141
+ super.connectedCallback()
142
+ startSessionActivityFeed()
143
+ this.unsubscribe = subscribeSessionActivity(activity => {
144
+ if (activity.sessionId === this.sessionId && activity.kind === 'message') void this.load(true)
145
+ })
146
+ }
147
+
148
+ disconnectedCallback() {
149
+ this.unsubscribe?.()
150
+ super.disconnectedCallback()
151
+ }
152
+
153
+ async pageUpdated(_changes: any, lifecycle: any) {
154
+ const params = lifecycle?.params ?? {}
155
+ const sessionId = String(params.session || '')
156
+ const name = String(params.name || '')
157
+ const wanted = Number(params.version) || 0
158
+ const changed = sessionId !== this.sessionId || name !== this.name
159
+ this.sessionId = sessionId
160
+ this.name = name
161
+ this.wanted = wanted
162
+ if (changed) {
163
+ this.newer = 0
164
+ await this.load(false)
165
+ }
166
+ }
167
+
168
+ private async load(arriving: boolean) {
169
+ if (!this.sessionId || !this.name) return
170
+ const before = this.versions.length
171
+ if (!arriving) this.loading = true
172
+ this.failure = ''
173
+ try {
174
+ const messages: any[] = []
175
+ for (let offset = 0; offset < MOST; offset += PAGE) {
176
+ const result = await client.query({ query: MESSAGES, variables: { sessionId: this.sessionId, limit: PAGE, offset }, fetchPolicy: 'no-cache' })
177
+ if (result.errors?.length) throw new Error(result.errors[0].message)
178
+ const page = result.data?.chatMessages ?? []
179
+ messages.push(...page)
180
+ if (page.length < PAGE) break
181
+ }
182
+ const versions = workspaceVersions(messages, this.name)
183
+ this.versions = versions
184
+ if (arriving && versions.length > before && before > 0) this.newer = versions[versions.length - 1].version
185
+ } catch (error: any) {
186
+ this.failure = error?.message || String(error)
187
+ } finally {
188
+ this.loading = false
189
+ }
190
+ }
191
+
192
+ private go(version?: number) {
193
+ this.newer = 0
194
+ navigate(workspaceAddress(this.sessionId, this.name, version))
195
+ }
196
+
197
+ render() {
198
+ const shown = this.shown
199
+ if (this.loading && !shown) return html`<div class="empty">${t('loading', 'loading the workspace…')}</div>`
200
+ if (this.failure) return html`<div class="empty">${t('unreadable-page', 'could not read this workspace: {reason}', { reason: this.failure })}</div>`
201
+ /*
202
+ * Nothing to show says only what is true: this reader sees no such workspace. Whether it never existed, is in
203
+ * a private conversation or sits behind a door is not said - saying which would itself tell.
204
+ */
205
+ if (!shown) return html`<div class="empty">${t('not-found', 'there is no workspace here that you can see.')}</div>`
206
+
207
+ const latest = this.versions[this.versions.length - 1].version
208
+ return html`<div class="page">
209
+ ${this.newer && this.newer !== shown.version
210
+ ? html`<div class="notice" role="status">
211
+ <md-icon>update</md-icon>
212
+ <span>${t('newer', 'a newer version ({version}) is ready.', { version: this.newer })}</span>
213
+ <button @click=${() => this.go(this.newer)}>${t('show-newer', 'show it')}</button>
214
+ </div>`
215
+ : nothing}
216
+ <header>
217
+ <div class="what">
218
+ <h1>${shown.workspace.title}</h1>
219
+ ${shown.workspace.summary ? html`<div class="summary">${shown.workspace.summary}</div>` : nothing}
220
+ </div>
221
+ ${this.versions.length > 1
222
+ ? html`<select aria-label=${t('versions', 'versions')} @change=${(e: Event) => this.go(Number((e.target as HTMLSelectElement).value))}>
223
+ ${this.versions
224
+ .slice()
225
+ .reverse()
226
+ .map(
227
+ one => html`<option value=${one.version} ?selected=${one.version === shown.version}>
228
+ ${t('version', 'version {version} of {of}', { version: one.version, of: latest })}${one.createdAt ? ` · ${new Date(one.createdAt).toLocaleString(i18next.language || undefined)}` : ''}
229
+ </option>`
230
+ )}
231
+ </select>`
232
+ : nothing}
233
+ </header>
234
+ <ox-assistant-workspace-view .workspace=${shown.workspace}></ox-assistant-workspace-view>
235
+ </div>`
236
+ }
237
+ }
@@ -0,0 +1,9 @@
1
+ /* The pages ai-assistant brings to every application that installs it. */
2
+ export default function route(page: string) {
3
+ switch (page) {
4
+ /* The AI 작업판 - results too large for the dock (design/ai/workspace.md). */
5
+ case 'assistant-workspace':
6
+ import('./pages/assistant-workspace-page.js')
7
+ return page
8
+ }
9
+ }
@@ -37,9 +37,19 @@ export function proposalHandlerOf(tool: string | undefined): ProposalHandler | u
37
37
  return tool ? handlers.get(tool) : undefined
38
38
  }
39
39
 
40
+ /*
41
+ * Data entry cards are the framework's, whatever record they enter (design/ai/data-entry.md §5): one
42
+ * handler asks the server to enter the stored rows. The dock sets it; no app registers one per record.
43
+ */
44
+ let entryHandler: ProposalHandler | undefined
45
+
46
+ export function setEntryProposalHandler(handler: ProposalHandler): void {
47
+ entryHandler = handler
48
+ }
49
+
40
50
  /** Runs the proposal - never throws; a failure is a `refused` outcome with its message as the code. */
41
51
  export async function runProposal(proposal: any, context: ProposalContext): Promise<ProposalOutcome> {
42
- const handler = proposalHandlerOf(proposal?.tool)
52
+ const handler = proposal?.kind === 'entry' && entryHandler ? entryHandler : proposalHandlerOf(proposal?.tool)
43
53
  if (!handler) return { verdict: 'unavailable', errorCode: 'unknown-proposal-kind' }
44
54
  try {
45
55
  const outcome = await handler(proposal, context)