@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.
- package/client/components/assistant-chat.ts +45 -5
- package/client/components/assistant-dock.ts +30 -2
- package/client/components/entry-review.ts +246 -0
- package/client/components/workspace-view.ts +428 -0
- package/client/pages/assistant-workspace-page.ts +237 -0
- package/client/route.ts +9 -0
- package/client/utils/assistant-proposals.ts +11 -1
- package/client/utils/entry-proposal.ts +103 -0
- package/client/utils/workspace.ts +89 -0
- package/dist-client/components/assistant-chat.d.ts +3 -0
- package/dist-client/components/assistant-chat.js +46 -5
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +1 -0
- package/dist-client/components/assistant-dock.js +25 -2
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/entry-review.d.ts +9 -0
- package/dist-client/components/entry-review.js +247 -0
- package/dist-client/components/entry-review.js.map +1 -0
- package/dist-client/components/workspace-view.d.ts +19 -0
- package/dist-client/components/workspace-view.js +416 -0
- package/dist-client/components/workspace-view.js.map +1 -0
- package/dist-client/pages/assistant-workspace-page.d.ts +27 -0
- package/dist-client/pages/assistant-workspace-page.js +263 -0
- package/dist-client/pages/assistant-workspace-page.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +10 -0
- package/dist-client/route.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-proposals.d.ts +1 -0
- package/dist-client/utils/assistant-proposals.js +9 -1
- package/dist-client/utils/assistant-proposals.js.map +1 -1
- package/dist-client/utils/entry-proposal.d.ts +55 -0
- package/dist-client/utils/entry-proposal.js +52 -0
- package/dist-client/utils/entry-proposal.js.map +1 -0
- package/dist-client/utils/workspace.d.ts +40 -0
- package/dist-client/utils/workspace.js +68 -0
- package/dist-client/utils/workspace.js.map +1 -0
- package/dist-server/service/assistant-mode/registry.js +13 -0
- package/dist-server/service/assistant-mode/registry.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +11 -0
- package/dist-server/service/assistant-mode/resolve.js +15 -3
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +7 -1
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.d.ts +46 -0
- package/dist-server/service/data-entry/entry-apply.js +102 -0
- package/dist-server/service/data-entry/entry-apply.js.map +1 -0
- package/dist-server/service/data-entry/entry-resolver.d.ts +4 -0
- package/dist-server/service/data-entry/entry-resolver.js +80 -0
- package/dist-server/service/data-entry/entry-resolver.js.map +1 -0
- package/dist-server/service/data-entry/entry-sources.d.ts +1 -0
- package/dist-server/service/data-entry/entry-sources.js +41 -0
- package/dist-server/service/data-entry/entry-sources.js.map +1 -0
- package/dist-server/service/data-entry/entry-target.d.ts +157 -0
- package/dist-server/service/data-entry/entry-target.js +343 -0
- package/dist-server/service/data-entry/entry-target.js.map +1 -0
- package/dist-server/service/data-entry/entry-tools.d.ts +43 -0
- package/dist-server/service/data-entry/entry-tools.js +185 -0
- package/dist-server/service/data-entry/entry-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +6 -1
- package/dist-server/service/index.js +13 -2
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/workspace/workspace-blocks.d.ts +24 -0
- package/dist-server/service/workspace/workspace-blocks.js +118 -0
- package/dist-server/service/workspace/workspace-blocks.js.map +1 -0
- package/dist-server/service/workspace/workspace-tools.d.ts +24 -0
- package/dist-server/service/workspace/workspace-tools.js +65 -0
- package/dist-server/service/workspace/workspace-tools.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/assistant-mode/registry.ts +13 -0
- package/server/service/assistant-mode/resolve.test.ts +2 -1
- package/server/service/assistant-mode/resolve.ts +15 -3
- package/server/service/assistant-mode/types.ts +7 -1
- package/server/service/data-entry/data-entry.test.ts +311 -0
- package/server/service/data-entry/entry-apply.ts +134 -0
- package/server/service/data-entry/entry-resolver.ts +74 -0
- package/server/service/data-entry/entry-sources.ts +35 -0
- package/server/service/data-entry/entry-target.ts +442 -0
- package/server/service/data-entry/entry-tools.ts +174 -0
- package/server/service/index.ts +10 -1
- package/server/service/workspace/workspace-blocks.ts +127 -0
- package/server/service/workspace/workspace-tools.ts +66 -0
- package/server/service/workspace/workspace.test.ts +58 -0
- package/test/entry-proposal.test.ts +67 -0
- package/test/open-doors.test.ts +2 -1
- package/test/workspace.test.ts +54 -0
- package/things-factory.config.js +6 -1
- package/translations/en.json +56 -1
- package/translations/ja.json +56 -1
- package/translations/ko.json +56 -1
- package/translations/ms.json +56 -1
- 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
|
+
}
|
package/client/route.ts
ADDED
|
@@ -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)
|