@things-factory/ai-assistant 10.1.90 → 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 (53) hide show
  1. package/client/components/assistant-chat.ts +26 -0
  2. package/client/components/assistant-dock.ts +9 -1
  3. package/client/components/workspace-view.ts +428 -0
  4. package/client/pages/assistant-workspace-page.ts +237 -0
  5. package/client/route.ts +9 -0
  6. package/client/utils/workspace.ts +89 -0
  7. package/dist-client/components/assistant-chat.d.ts +2 -0
  8. package/dist-client/components/assistant-chat.js +27 -0
  9. package/dist-client/components/assistant-chat.js.map +1 -1
  10. package/dist-client/components/assistant-dock.d.ts +1 -0
  11. package/dist-client/components/assistant-dock.js +9 -1
  12. package/dist-client/components/assistant-dock.js.map +1 -1
  13. package/dist-client/components/workspace-view.d.ts +19 -0
  14. package/dist-client/components/workspace-view.js +416 -0
  15. package/dist-client/components/workspace-view.js.map +1 -0
  16. package/dist-client/pages/assistant-workspace-page.d.ts +27 -0
  17. package/dist-client/pages/assistant-workspace-page.js +263 -0
  18. package/dist-client/pages/assistant-workspace-page.js.map +1 -0
  19. package/dist-client/route.d.ts +1 -0
  20. package/dist-client/route.js +10 -0
  21. package/dist-client/route.js.map +1 -0
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/dist-client/utils/workspace.d.ts +40 -0
  24. package/dist-client/utils/workspace.js +68 -0
  25. package/dist-client/utils/workspace.js.map +1 -0
  26. package/dist-server/service/assistant-mode/resolve.d.ts +2 -2
  27. package/dist-server/service/assistant-mode/resolve.js +5 -3
  28. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  29. package/dist-server/service/index.d.ts +2 -0
  30. package/dist-server/service/index.js +3 -0
  31. package/dist-server/service/index.js.map +1 -1
  32. package/dist-server/service/workspace/workspace-blocks.d.ts +24 -0
  33. package/dist-server/service/workspace/workspace-blocks.js +118 -0
  34. package/dist-server/service/workspace/workspace-blocks.js.map +1 -0
  35. package/dist-server/service/workspace/workspace-tools.d.ts +24 -0
  36. package/dist-server/service/workspace/workspace-tools.js +65 -0
  37. package/dist-server/service/workspace/workspace-tools.js.map +1 -0
  38. package/dist-server/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +2 -2
  40. package/server/service/assistant-mode/resolve.test.ts +2 -1
  41. package/server/service/assistant-mode/resolve.ts +5 -3
  42. package/server/service/data-entry/data-entry.test.ts +2 -2
  43. package/server/service/index.ts +3 -0
  44. package/server/service/workspace/workspace-blocks.ts +127 -0
  45. package/server/service/workspace/workspace-tools.ts +66 -0
  46. package/server/service/workspace/workspace.test.ts +58 -0
  47. package/test/workspace.test.ts +54 -0
  48. package/things-factory.config.js +6 -1
  49. package/translations/en.json +21 -1
  50. package/translations/ja.json +21 -1
  51. package/translations/ko.json +21 -1
  52. package/translations/ms.json +21 -1
  53. package/translations/zh.json +21 -1
@@ -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
+ }
@@ -0,0 +1,89 @@
1
+ /*
2
+ * The AI 작업판 on the screen - which versions a workspace has, and the table's sort and CSV
3
+ * (operato-application/design/ai/workspace.md). Pure - tested without a browser.
4
+ */
5
+
6
+ export interface WorkspaceBlockView {
7
+ kind: string
8
+ [key: string]: any
9
+ }
10
+
11
+ export interface WorkspaceView {
12
+ name: string
13
+ title: string
14
+ summary?: string
15
+ blocks: WorkspaceBlockView[]
16
+ }
17
+
18
+ export interface WorkspaceVersion {
19
+ /** 1-based, in the order the conversation made them. */
20
+ version: number
21
+ messageId: string
22
+ createdAt?: string
23
+ workspace: WorkspaceView
24
+ }
25
+
26
+ export function isWorkspaceProposal(proposal: any): boolean {
27
+ return proposal?.kind === 'workspace' && !!proposal?.workspace?.name
28
+ }
29
+
30
+ /**
31
+ * Every version of the named workspace in a conversation, oldest first. A message the reader may not see
32
+ * comes back redacted without its proposals, so a version behind a door is simply not among them.
33
+ */
34
+ export function workspaceVersions(messages: { id: string; createdAt?: string; proposalsJson?: any[] | null }[], name: string): WorkspaceVersion[] {
35
+ const versions: WorkspaceVersion[] = []
36
+ for (const message of messages) {
37
+ for (const proposal of message.proposalsJson ?? []) {
38
+ if (isWorkspaceProposal(proposal) && proposal.workspace.name === name) {
39
+ versions.push({ version: versions.length + 1, messageId: message.id, createdAt: message.createdAt, workspace: proposal.workspace })
40
+ }
41
+ }
42
+ }
43
+ return versions
44
+ }
45
+
46
+ /** The version of a card within its conversation - "2 of 3" on the card and on the page. */
47
+ export function versionOfCard(proposalsInOrder: any[][], name: string, key: string): { version: number; of: number } {
48
+ let version = 0
49
+ let of = 0
50
+ for (const list of proposalsInOrder)
51
+ for (const one of list ?? []) {
52
+ if (!isWorkspaceProposal(one) || one.workspace.name !== name) continue
53
+ of += 1
54
+ if (one.key === key) version = of
55
+ }
56
+ return { version, of }
57
+ }
58
+
59
+ export function workspaceAddress(sessionId: string, name: string, version?: number): string {
60
+ const query = new URLSearchParams({ session: sessionId, name })
61
+ if (version) query.set('version', String(version))
62
+ return `assistant-workspace?${query.toString()}`
63
+ }
64
+
65
+ /** Sorts by one column - numbers as numbers, empty last, the rest by the reader's collation. */
66
+ export function sortRows(rows: Record<string, any>[], column: string, direction: 'asc' | 'desc', locale?: string): Record<string, any>[] {
67
+ const factor = direction === 'asc' ? 1 : -1
68
+ const collator = new Intl.Collator(locale, { numeric: true })
69
+ return [...rows].sort((a, b) => {
70
+ const x = a[column]
71
+ const y = b[column]
72
+ const emptyX = x === null || x === undefined || x === ''
73
+ const emptyY = y === null || y === undefined || y === ''
74
+ if (emptyX || emptyY) return emptyX === emptyY ? 0 : emptyX ? 1 : -1
75
+ if (typeof x === 'number' && typeof y === 'number') return (x - y) * factor
76
+ return collator.compare(String(x), String(y)) * factor
77
+ })
78
+ }
79
+
80
+ const csvCell = (value: unknown) => {
81
+ const text = value === null || value === undefined ? '' : String(value)
82
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
83
+ }
84
+
85
+ /** The table as CSV - the heading row in the column labels, a BOM so a spreadsheet reads Korean. */
86
+ export function tableCsv(columns: { name: string; label?: string }[], rows: Record<string, any>[]): string {
87
+ const lines = [columns.map(column => csvCell(column.label || column.name)).join(','), ...rows.map(row => columns.map(column => csvCell(row[column.name])).join(','))]
88
+ return '' + lines.join('\r\n')
89
+ }
@@ -540,6 +540,8 @@ export declare class OxAssistantChat extends LitElement {
540
540
  * 실행 결과는 호스트가 **대화에 기록**한다 — 누가 무엇을 실행했는지가 협의 이력에 남아야 한다.
541
541
  */
542
542
  private renderProposals;
543
+ /** The AI 작업판 card - what it holds, which version, and the way to open it. The person opens it; the model does not. */
544
+ private renderWorkspaceCard;
543
545
  /**
544
546
  * 눌린 카드가 말하는 것 — **호스트가 한 일**이고, 아직 모르면 모른다고 말한다.
545
547
  *
@@ -49,6 +49,7 @@ import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState }
49
49
  import { availableTriggers } from './chat-triggers.js';
50
50
  import './entry-review.js';
51
51
  import { entryReadyCount, isEntryProposal, latestEntryKeys } from '../utils/entry-proposal.js';
52
+ import { isWorkspaceProposal, versionOfCard, workspaceAddress } from '../utils/workspace.js';
52
53
  function formatFileSize(bytes) {
53
54
  if (!bytes || bytes < 1024)
54
55
  return `${bytes || 0} B`;
@@ -3508,6 +3509,9 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
3508
3509
  <div class="proposals">
3509
3510
  ${items.map(p => {
3510
3511
  const key = this.proposalKey(p);
3512
+ /* A workspace card opens the page; it runs nothing (design/ai/workspace.md §4). */
3513
+ if (isWorkspaceProposal(p))
3514
+ return this.renderWorkspaceCard(p, key);
3511
3515
  const card = proposalCardState(key, { outcomes, requested, sentHere: this.sentProposals.has(key) });
3512
3516
  const entry = isEntryProposal(p);
3513
3517
  const replaced = entry && latestEntry?.get(p.target) !== key && card.state === 'ready';
@@ -3588,6 +3592,29 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
3588
3592
  </div>
3589
3593
  `;
3590
3594
  }
3595
+ /** The AI 작업판 card - what it holds, which version, and the way to open it. The person opens it; the model does not. */
3596
+ renderWorkspaceCard(p, key) {
3597
+ const { version, of } = versionOfCard(this.lines.map(one => one.proposals ?? []), p.workspace.name, key);
3598
+ const contents = Object.entries(p.contents ?? {})
3599
+ .map(([kind, count]) => String(i18next.t(`ai-assistant.workspace.contains-${kind}`, { defaultValue: `${kind} {count}`, count: Number(count) })))
3600
+ .join(' · ');
3601
+ const open = () => {
3602
+ if (!this.sessionId)
3603
+ return;
3604
+ /* The page is the application's; navigate is the shell's, reached by an event so this component stays standalone. */
3605
+ this.dispatchEvent(new CustomEvent('assistant-open-page', { detail: { href: workspaceAddress(this.sessionId, p.workspace.name, version) }, bubbles: true, composed: true }));
3606
+ };
3607
+ return html `
3608
+ <div class="proposal workspace">
3609
+ <md-icon>${p.icon || 'dashboard'}</md-icon>
3610
+ <span class="pbody">
3611
+ <span class="plabel">${p.workspace.title}</span>
3612
+ <span class="preason">${[contents, of > 1 ? i18next.t('ai-assistant.workspace.version', { defaultValue: 'version {version} of {of}', version, of }) : ''].filter(Boolean).join(' · ')}</span>
3613
+ </span>
3614
+ <button class="run" ?disabled=${!this.sessionId} @click=${open}>${i18next.t('ai-assistant.workspace.open', { defaultValue: 'open in workspace' })}</button>
3615
+ </div>
3616
+ `;
3617
+ }
3591
3618
  /**
3592
3619
  * 눌린 카드가 말하는 것 — **호스트가 한 일**이고, 아직 모르면 모른다고 말한다.
3593
3620
  *