@things-factory/ai-assistant 10.1.107 → 10.1.109

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 (84) hide show
  1. package/client/components/assistant-chat.ts +108 -2
  2. package/client/components/assistant-dock.ts +4 -4
  3. package/client/components/assistant-mode.ts +1 -1
  4. package/client/components/entry-review.ts +23 -3
  5. package/client/pages/assistant-workspace-page.ts +82 -2
  6. package/client/utils/attachment-policy.ts +22 -3
  7. package/client/utils/entry-hash.ts +35 -0
  8. package/client/utils/entry-proposal.ts +10 -3
  9. package/client/utils/entry-workspace.ts +70 -0
  10. package/client/utils/pasted-table.ts +145 -0
  11. package/client/utils/spreadsheet-text.ts +125 -0
  12. package/dist-client/components/assistant-chat.d.ts +2 -0
  13. package/dist-client/components/assistant-chat.js +105 -2
  14. package/dist-client/components/assistant-chat.js.map +1 -1
  15. package/dist-client/components/assistant-dock.js +4 -4
  16. package/dist-client/components/assistant-dock.js.map +1 -1
  17. package/dist-client/components/assistant-mode.d.ts +2 -1
  18. package/dist-client/components/assistant-mode.js.map +1 -1
  19. package/dist-client/components/entry-review.d.ts +2 -0
  20. package/dist-client/components/entry-review.js +30 -3
  21. package/dist-client/components/entry-review.js.map +1 -1
  22. package/dist-client/pages/assistant-workspace-page.d.ts +8 -0
  23. package/dist-client/pages/assistant-workspace-page.js +98 -2
  24. package/dist-client/pages/assistant-workspace-page.js.map +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/dist-client/utils/attachment-policy.d.ts +7 -1
  27. package/dist-client/utils/attachment-policy.js +14 -2
  28. package/dist-client/utils/attachment-policy.js.map +1 -1
  29. package/dist-client/utils/entry-hash.d.ts +2 -0
  30. package/dist-client/utils/entry-hash.js +36 -0
  31. package/dist-client/utils/entry-hash.js.map +1 -0
  32. package/dist-client/utils/entry-proposal.d.ts +1 -1
  33. package/dist-client/utils/entry-proposal.js +9 -2
  34. package/dist-client/utils/entry-proposal.js.map +1 -1
  35. package/dist-client/utils/entry-workspace.d.ts +20 -0
  36. package/dist-client/utils/entry-workspace.js +68 -0
  37. package/dist-client/utils/entry-workspace.js.map +1 -0
  38. package/dist-client/utils/pasted-table.d.ts +34 -0
  39. package/dist-client/utils/pasted-table.js +150 -0
  40. package/dist-client/utils/pasted-table.js.map +1 -0
  41. package/dist-client/utils/spreadsheet-text.d.ts +48 -0
  42. package/dist-client/utils/spreadsheet-text.js +110 -0
  43. package/dist-client/utils/spreadsheet-text.js.map +1 -0
  44. package/dist-server/service/assistant-chat-resolver.js +1 -1
  45. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  46. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +1 -1
  47. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  48. package/dist-server/service/chat-session/attachment-policy.d.ts +5 -2
  49. package/dist-server/service/chat-session/attachment-policy.js +10 -1
  50. package/dist-server/service/chat-session/attachment-policy.js.map +1 -1
  51. package/dist-server/service/chat-session/attachment-tools.js +3 -1
  52. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  53. package/dist-server/service/chat-session/session-attachments.d.ts +3 -2
  54. package/dist-server/service/chat-session/session-attachments.js +5 -4
  55. package/dist-server/service/chat-session/session-attachments.js.map +1 -1
  56. package/dist-server/service/data-entry/entry-apply.d.ts +2 -0
  57. package/dist-server/service/data-entry/entry-apply.js +4 -0
  58. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  59. package/dist-server/service/data-entry/entry-hash.d.ts +2 -0
  60. package/dist-server/service/data-entry/entry-hash.js +39 -0
  61. package/dist-server/service/data-entry/entry-hash.js.map +1 -0
  62. package/dist-server/service/data-entry/entry-resolver.d.ts +1 -1
  63. package/dist-server/service/data-entry/entry-resolver.js +5 -3
  64. package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
  65. package/dist-server/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +3 -2
  67. package/server/service/assistant-chat-resolver.ts +2 -2
  68. package/server/service/assistant-mode/assistant-mode-resolver.ts +1 -1
  69. package/server/service/chat-session/attachment-policy.ts +18 -2
  70. package/server/service/chat-session/attachment-tools.ts +3 -1
  71. package/server/service/chat-session/session-attachments.ts +5 -4
  72. package/server/service/data-entry/data-entry.test.ts +10 -0
  73. package/server/service/data-entry/entry-apply.ts +5 -0
  74. package/server/service/data-entry/entry-hash.ts +35 -0
  75. package/server/service/data-entry/entry-resolver.ts +4 -2
  76. package/test/entry-card-styles.test.ts +18 -0
  77. package/test/entry-workspace.test.ts +89 -0
  78. package/test/pasted-table.test.ts +147 -0
  79. package/test/spreadsheet-text.test.ts +92 -0
  80. package/translations/en.json +12 -1
  81. package/translations/ja.json +12 -1
  82. package/translations/ko.json +12 -1
  83. package/translations/ms.json +12 -1
  84. package/translations/zh.json +12 -1
@@ -32,7 +32,10 @@ import {
32
32
  injectMentionUserIds,
33
33
  stripMentionRefids
34
34
  } from './markdown.js'
35
- import { acceptOf, chatAttachmentUrl, refuseAttachments } from '../utils/attachment-policy.js'
35
+ import { acceptOf, chatAttachmentUrl, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js'
36
+ import { pastedTableFile } from '../utils/pasted-table.js'
37
+ import { readSpreadsheet, spreadsheetKindOf, type SpreadsheetRefusal } from '../utils/spreadsheet-text.js'
38
+ import { entryReviewAddress, offersWorkspaceReview } from '../utils/entry-workspace.js'
36
39
  import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js'
37
40
  import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js'
38
41
  import './mention-popup.js'
@@ -639,7 +642,16 @@ export class OxAssistantChat extends LitElement {
639
642
 
640
643
  private async onAttachFiles(files: FileList | File[] | null | undefined) {
641
644
  if (!files || files.length === 0) return
642
- const list = Array.from(files)
645
+ let list = Array.from(files)
646
+ if (this.usesAttachmentPolicy && this.modeView?.attachments?.kinds?.includes('spreadsheet')) {
647
+ /* A spreadsheet travels as the text of its sheets (§9-5) - read here, refused by name when it cannot be. */
648
+ const read = await this.readSpreadsheets(list)
649
+ if ('refusal' in read) {
650
+ this.errorMessage = String(i18next.t(`ai-assistant.text.${read.refusal.code}`, { ...(read.refusal as any), defaultValue: read.refusal.code }))
651
+ return
652
+ }
653
+ list = read.files
654
+ }
643
655
  if (this.usesAttachmentPolicy) {
644
656
  /* Said before anything is sent - never cut silently (§9-1). */
645
657
  const refusal = refuseAttachments(this.modeView?.attachments, [...this.attachments, ...list].map(f => ({ name: f.name, type: f.type, size: f.size })))
@@ -673,6 +685,28 @@ export class OxAssistantChat extends LitElement {
673
685
  this.attachments = [...this.attachments, ...newAttachments]
674
686
  }
675
687
 
688
+ /** Each .xlsx replaced by one CSV file per sheet; any other file passes as it is. */
689
+ private async readSpreadsheets(list: File[]): Promise<{ files: File[] } | { refusal: SpreadsheetRefusal }> {
690
+ const out: File[] = []
691
+ let Workbook: any
692
+ for (const file of list) {
693
+ const kind = spreadsheetKindOf(file)
694
+ if (!kind) {
695
+ out.push(file)
696
+ continue
697
+ }
698
+ if (kind === 'xls') return { refusal: { code: 'attachment-xls-not-read', name: file.name } }
699
+ if (!Workbook) {
700
+ const mod: any = await import('exceljs')
701
+ Workbook = (mod.default ?? mod).Workbook
702
+ }
703
+ const read = await readSpreadsheet(await file.arrayBuffer(), file.name, textCharsOf(this.modeView?.attachments), Workbook)
704
+ if ('refusal' in read) return read
705
+ for (const sheet of read.files) out.push(new File([sheet.text], sheet.name, { type: 'text/csv' }))
706
+ }
707
+ return { files: out }
708
+ }
709
+
676
710
  private readFileAsBase64(file: File): Promise<string> {
677
711
  return new Promise((resolve, reject) => {
678
712
  const reader = new FileReader()
@@ -694,6 +728,27 @@ export class OxAssistantChat extends LitElement {
694
728
  const items = e.clipboardData?.items
695
729
  if (!items || items.length === 0) return
696
730
 
731
+ /*
732
+ Rows copied from a spreadsheet or a web page travel as a file, so the entry card can check each row against
733
+ its line. A spreadsheet also puts a picture of the cells on the clipboard - with the table taken, it is left.
734
+ */
735
+ if (this.usesAttachmentPolicy) {
736
+ const pasted = pastedTableFile(
737
+ { html: e.clipboardData?.getData('text/html'), text: e.clipboardData?.getData('text/plain') },
738
+ this.modeView?.attachments,
739
+ rows => String(i18next.t('ai-assistant.text.pasted-table-name', { rows, defaultValue: 'pasted table ({rows} rows)' }))
740
+ )
741
+ if (pasted) {
742
+ e.preventDefault()
743
+ if ('refusal' in pasted) {
744
+ this.errorMessage = String(i18next.t(`ai-assistant.text.${pasted.refusal.code}`, { ...pasted.refusal, defaultValue: pasted.refusal.code }))
745
+ return
746
+ }
747
+ this.onAttachFiles([new File([pasted.text], pasted.name, { type: 'text/csv' })])
748
+ return
749
+ }
750
+ }
751
+
697
752
  const files: File[] = []
698
753
  for (let i = 0; i < items.length; i++) {
699
754
  const item = items[i]
@@ -1964,6 +2019,42 @@ export class OxAssistantChat extends LitElement {
1964
2019
  color: inherit;
1965
2020
  }
1966
2021
 
2022
+ /* The entry card's way to the large review - a quiet action under its table, never a second run button. */
2023
+ .workspace-review {
2024
+ display: flex;
2025
+ align-items: center;
2026
+ gap: 5px;
2027
+ margin: 6px 0 0 auto;
2028
+ padding: 5px 11px 5px 9px;
2029
+ border: 1px solid transparent;
2030
+ border-radius: 999px;
2031
+ background: transparent;
2032
+ color: var(--md-sys-color-primary, #0f766e);
2033
+ font-family: inherit;
2034
+ font-size: 12px;
2035
+ font-weight: 600;
2036
+ letter-spacing: 0.01em;
2037
+ cursor: pointer;
2038
+ transition:
2039
+ background-color 0.14s ease-in-out,
2040
+ border-color 0.14s ease-in-out;
2041
+ }
2042
+ .workspace-review md-icon {
2043
+ --md-icon-size: 15px;
2044
+ color: inherit;
2045
+ }
2046
+ .workspace-review:hover {
2047
+ background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 8%, transparent);
2048
+ border-color: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 24%, transparent);
2049
+ }
2050
+ .workspace-review:active {
2051
+ background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 14%, transparent);
2052
+ }
2053
+ .workspace-review:focus-visible {
2054
+ outline: 2px solid var(--md-sys-color-primary, #0f766e);
2055
+ outline-offset: 1px;
2056
+ }
2057
+
1967
2058
  /* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
1968
2059
  .grounding-warning {
1969
2060
  display: flex;
@@ -3250,6 +3341,11 @@ export class OxAssistantChat extends LitElement {
3250
3341
  }
3251
3342
 
3252
3343
  private onKeyDown(e: KeyboardEvent) {
3344
+ /*
3345
+ The Enter that ends a Korean (or Japanese, Chinese) syllable belongs to the input method - sending on it
3346
+ sends the line without its last syllable, which then lands in the emptied box.
3347
+ */
3348
+ if (e.isComposing || e.keyCode === 229) return
3253
3349
  // Mention popup 활성 시 — 화살표/Enter/Tab/Esc 가로채서 popup 네비게이션
3254
3350
  if (this.mentionOpen) {
3255
3351
  if (e.key === 'ArrowDown') {
@@ -4021,6 +4117,16 @@ export class OxAssistantChat extends LitElement {
4021
4117
  ` : nothing}
4022
4118
  </div>
4023
4119
  ${entry ? html`<ox-assistant-entry-review .proposal=${p}></ox-assistant-entry-review>` : nothing}
4120
+ ${entry && this.sessionId && offersWorkspaceReview(p)
4121
+ ? html`<button
4122
+ class="workspace-review"
4123
+ @click=${() =>
4124
+ this.dispatchEvent(new CustomEvent('assistant-open-page', { detail: { href: entryReviewAddress(this.sessionId!, key) }, bubbles: true, composed: true }))}
4125
+ >
4126
+ <md-icon>open_in_full</md-icon>
4127
+ <span>${i18next.t('ai-assistant.entry.review-in-workspace', { defaultValue: 'review in the workspace' })}</span>
4128
+ </button>`
4129
+ : nothing}
4024
4130
  ${rawChoices.length ? html`
4025
4131
  <div class="proposal-choices" role="group" aria-label=${i18next.t('ai-assistant.text.proposal-choices', { defaultValue: 'follow-up choices' })}>
4026
4132
  ${rawChoices.map((c: any) => html`
@@ -42,14 +42,14 @@ const t = (key: string, defaultValue: string, options: Record<string, unknown> =
42
42
  /* Pressing a data entry card asks the server to enter the rows it stored - the screen sends none (design/ai/data-entry.md §4). */
43
43
  setEntryProposalHandler(
44
44
  entryProposalHandler(
45
- async (sessionId, proposalKey) => {
45
+ async (sessionId, proposalKey, reviewedHash) => {
46
46
  const result = await client.mutate({
47
47
  mutation: gql`
48
- mutation ($id: String!, $key: String!) {
49
- applyEntryProposal(sessionId: $id, proposalKey: $key)
48
+ mutation ($id: String!, $key: String!, $reviewed: String) {
49
+ applyEntryProposal(sessionId: $id, proposalKey: $key, reviewedHash: $reviewed)
50
50
  }
51
51
  `,
52
- variables: { id: sessionId, key: proposalKey }
52
+ variables: { id: sessionId, key: proposalKey, reviewed: reviewedHash ?? null }
53
53
  })
54
54
  if (result.errors?.length) throw new Error(result.errors[0].message)
55
55
  return result.data?.applyEntryProposal as EntryApplyAnswer
@@ -54,7 +54,7 @@ export interface AssistantModeView {
54
54
  footerNotice?: string
55
55
  stagedNotice?: string
56
56
  /** What this conversation accepts as attachments; absent - none (design/ai/shared-dock.md §9-1). */
57
- attachments?: { kinds: ('image' | 'text')[]; maxBytes?: number; maxFiles?: number }
57
+ attachments?: { kinds: ('image' | 'text' | 'spreadsheet')[]; maxBytes?: number; maxFiles?: number; maxChars?: number }
58
58
  }
59
59
 
60
60
  /** True when these two describe different conversations, so the old one should be closed. */
@@ -178,6 +178,24 @@ export class AssistantEntryReview extends LitElement {
178
178
  color: var(--md-sys-color-primary);
179
179
  }
180
180
 
181
+ /* In the AI 작업판: every row, the page's height, the reading size of a page (design/ai/workspace.md §6-1). */
182
+ :host([wide]) {
183
+ font-size: 13px;
184
+ }
185
+
186
+ :host([wide]) .scroll {
187
+ max-height: calc(100vh - 260px);
188
+ }
189
+
190
+ :host([wide]) th,
191
+ :host([wide]) td {
192
+ padding: 6px 10px;
193
+ }
194
+
195
+ :host([wide]) .source {
196
+ max-width: 320px;
197
+ }
198
+
181
199
  .more,
182
200
  .check-failed {
183
201
  margin-top: 4px;
@@ -187,6 +205,8 @@ export class AssistantEntryReview extends LitElement {
187
205
  ]
188
206
 
189
207
  @property({ attribute: false }) proposal: any
208
+ /** The 작업판's review - every row is drawn, not the first fifty. */
209
+ @property({ type: Boolean, reflect: true }) wide = false
190
210
 
191
211
  /** The sum of each totalled field, beside the total the material prints - a missing row shows here without reading every row. */
192
212
  private renderTotals(totals: EntryTotalView[], columns: { name: string; labelKey?: string }[]) {
@@ -236,7 +256,7 @@ export class AssistantEntryReview extends LitElement {
236
256
  </tr>
237
257
  </thead>
238
258
  <tbody>
239
- ${rows.slice(0, SHOWN).map(row => {
259
+ ${rows.slice(0, this.wide ? rows.length : SHOWN).map(row => {
240
260
  const blocked = (row.issues ?? []).some((issue: EntryIssueView) => issue.blocking)
241
261
  const whole = entryCellIssues(row)
242
262
  return html`<tr class=${blocked ? 'blocked' : ''}>
@@ -286,7 +306,7 @@ export class AssistantEntryReview extends LitElement {
286
306
  </tr>
287
307
  </thead>
288
308
  <tbody>
289
- ${rows.slice(0, SHOWN).map(row => {
309
+ ${rows.slice(0, this.wide ? rows.length : SHOWN).map(row => {
290
310
  const blocked = (row.issues ?? []).some(issue => issue.blocking)
291
311
  const whole = entryCellIssues(row)
292
312
  return html`
@@ -319,7 +339,7 @@ export class AssistantEntryReview extends LitElement {
319
339
  </table>
320
340
  </div>
321
341
  ${this.renderTotals(p.totals ?? [], columns)}
322
- ${rows.length > SHOWN ? html`<div class="more">${t('more-rows', '{count} more rows are not shown - they are entered too.', { count: rows.length - SHOWN })}</div>` : nothing}
342
+ ${!this.wide && rows.length > SHOWN ? html`<div class="more">${t('more-rows', '{count} more rows are not shown - they are entered too.', { count: rows.length - SHOWN })}</div>` : nothing}
323
343
  ${p.checkFailed ? html`<div class="check-failed">${t('check-failed', 'the application could not check these rows; they are checked again when entered.')}</div>` : nothing}
324
344
  `
325
345
  }
@@ -1,13 +1,15 @@
1
1
  /*
2
2
  * The AI 작업판 page (operato-application/design/ai/workspace.md).
3
3
  *
4
- * `assistant-workspace?session=<id>&name=<name>[&version=<n>]`. It reads the conversation through its own
4
+ * `assistant-workspace?session=<id>&name=<name>[&version=<n>]`, or `?session=<id>&entry=<proposal key>` to review a
5
+ * 입력안 wide (§6-1) - read only; the card in the dock is where it is entered. It reads the conversation through its own
5
6
  * door (`chatMessages`), so a private conversation shows nothing to someone outside it and a version behind a
6
7
  * door the reader cannot pass is not among the versions (ADR-0073). A new version arriving while the page is
7
8
  * open is announced, never swapped in under the reader.
8
9
  */
9
10
  import '@material/web/icon/icon.js'
10
11
  import '../components/workspace-view.js'
12
+ import '../components/entry-review.js'
11
13
 
12
14
  import gql from 'graphql-tag'
13
15
  import { css, html, nothing } from 'lit'
@@ -20,6 +22,8 @@ import { ScrollbarStyles } from '@operato/styles'
20
22
 
21
23
  import { startSessionActivityFeed, subscribeSessionActivity } from '../utils/assistant-activity.js'
22
24
  import { workspaceAddress, workspaceVersions, type WorkspaceVersion } from '../utils/workspace.js'
25
+ import { entryForReview, rememberReviewed } from '../utils/entry-workspace.js'
26
+ import { entryTitle } from '../utils/entry-proposal.js'
23
27
 
24
28
  const MESSAGES = gql`
25
29
  query ($sessionId: String!, $limit: Int, $offset: Int) {
@@ -55,6 +59,11 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
55
59
  margin: 0 auto;
56
60
  }
57
61
 
62
+ /* A 입력안 is as wide as its columns - the reason it left the dock. */
63
+ .page.entry {
64
+ max-width: none;
65
+ }
66
+
58
67
  header {
59
68
  display: flex;
60
69
  align-items: flex-start;
@@ -124,10 +133,14 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
124
133
  @state() private failure = ''
125
134
  /** A version that arrived after the page was drawn - announced, not shown until asked. */
126
135
  @state() private newer = 0
136
+ /** Reviewing a 입력안: its key, what was found, and whether a newer card replaced it. */
137
+ @state() private entryKey = ''
138
+ @state() private entry?: { proposal?: any; createdAt?: string; superseded: boolean }
127
139
 
128
140
  private unsubscribe?: () => void
129
141
 
130
142
  get context() {
143
+ if (this.entryKey) return { title: this.entry?.proposal ? entryTitle(this.entry.proposal, (key, options) => String(i18next.t(key, options))) : t('title', 'AI workspace') }
131
144
  const shown = this.shown
132
145
  return { title: shown?.workspace.title || t('title', 'AI workspace') }
133
146
  }
@@ -141,7 +154,7 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
141
154
  super.connectedCallback()
142
155
  startSessionActivityFeed()
143
156
  this.unsubscribe = subscribeSessionActivity(activity => {
144
- if (activity.sessionId === this.sessionId && activity.kind === 'message') void this.load(true)
157
+ if (activity.sessionId === this.sessionId && activity.kind === 'message') void (this.entryKey ? this.loadEntry() : this.load(true))
145
158
  })
146
159
  }
147
160
 
@@ -155,6 +168,16 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
155
168
  const sessionId = String(params.session || '')
156
169
  const name = String(params.name || '')
157
170
  const wanted = Number(params.version) || 0
171
+ const entryKey = String(params.entry || '')
172
+ if (entryKey) {
173
+ const changedEntry = sessionId !== this.sessionId || entryKey !== this.entryKey
174
+ this.sessionId = sessionId
175
+ this.entryKey = entryKey
176
+ this.name = ''
177
+ if (changedEntry) await this.loadEntry()
178
+ return
179
+ }
180
+ this.entryKey = ''
158
181
  const changed = sessionId !== this.sessionId || name !== this.name
159
182
  this.sessionId = sessionId
160
183
  this.name = name
@@ -189,12 +212,69 @@ export class AssistantWorkspacePage extends localize(i18next)(PageView) {
189
212
  }
190
213
  }
191
214
 
215
+ private async readMessages(): Promise<any[]> {
216
+ const messages: any[] = []
217
+ for (let offset = 0; offset < MOST; offset += PAGE) {
218
+ const result = await client.query({ query: MESSAGES, variables: { sessionId: this.sessionId, limit: PAGE, offset }, fetchPolicy: 'no-cache' })
219
+ if (result.errors?.length) throw new Error(result.errors[0].message)
220
+ const page = result.data?.chatMessages ?? []
221
+ messages.push(...page)
222
+ if (page.length < PAGE) break
223
+ }
224
+ return messages
225
+ }
226
+
227
+ /** The 입력안 under review - remembered as shown, so the card enters only this one (§6-1). */
228
+ private async loadEntry() {
229
+ if (!this.sessionId || !this.entryKey) return
230
+ this.loading = !this.entry
231
+ this.failure = ''
232
+ try {
233
+ const found = entryForReview(await this.readMessages(), this.entryKey)
234
+ this.entry = found
235
+ if (found.proposal && !found.superseded) rememberReviewed(found.proposal)
236
+ } catch (error: any) {
237
+ this.failure = error?.message || String(error)
238
+ } finally {
239
+ this.loading = false
240
+ }
241
+ }
242
+
243
+ private renderEntry() {
244
+ const found = this.entry
245
+ if (this.loading && !found) return html`<div class="empty">${t('loading', 'loading the workspace…')}</div>`
246
+ if (this.failure) return html`<div class="empty">${t('unreadable-page', 'could not read this workspace: {reason}', { reason: this.failure })}</div>`
247
+ if (!found?.proposal) return html`<div class="empty">${t('entry-not-found', 'there is no proposal here that you can see.')}</div>`
248
+ const p = found.proposal
249
+ return html`<div class="page entry">
250
+ ${found.superseded
251
+ ? html`<div class="notice" role="status">
252
+ <md-icon>update</md-icon>
253
+ <span>${t('entry-superseded', 'a newer card replaced this one in the conversation - review that one.')}</span>
254
+ </div>`
255
+ : html`<div class="notice" role="note">
256
+ <md-icon>touch_app</md-icon>
257
+ <span>${t('entry-press-in-dock', 'this is a review. Enter the rows with the button on the card in the AI dock.')}</span>
258
+ </div>`}
259
+ <header>
260
+ <div class="what">
261
+ <h1>${entryTitle(p, (key, options) => String(i18next.t(key, options)))}</h1>
262
+ ${p.reason || found.createdAt
263
+ ? html`<div class="summary">${[p.reason, found.createdAt ? new Date(found.createdAt).toLocaleString(i18next.language || undefined) : ''].filter(Boolean).join(' · ')}</div>`
264
+ : nothing}
265
+ </div>
266
+ </header>
267
+ <ox-assistant-entry-review wide .proposal=${p}></ox-assistant-entry-review>
268
+ </div>`
269
+ }
270
+
192
271
  private go(version?: number) {
193
272
  this.newer = 0
194
273
  navigate(workspaceAddress(this.sessionId, this.name, version))
195
274
  }
196
275
 
197
276
  render() {
277
+ if (this.entryKey) return this.renderEntry()
198
278
  const shown = this.shown
199
279
  if (this.loading && !shown) return html`<div class="empty">${t('loading', 'loading the workspace…')}</div>`
200
280
  if (this.failure) return html`<div class="empty">${t('unreadable-page', 'could not read this workspace: {reason}', { reason: this.failure })}</div>`
@@ -6,16 +6,24 @@
6
6
  * the authority. `test/attachment-policy-agree.test.ts` runs both copies over one table so they cannot drift.
7
7
  */
8
8
 
9
- export type AttachmentKind = 'image' | 'text'
9
+ /*
10
+ * 'spreadsheet' is a kind a mode opens, never a file the door receives: the screen reads each .xlsx sheet into CSV
11
+ * text before sending (client/utils/spreadsheet-text.ts), so a mode that takes spreadsheets takes that text.
12
+ */
13
+ export type AttachmentKind = 'image' | 'text' | 'spreadsheet'
10
14
 
11
15
  export interface AttachmentPolicy {
12
16
  kinds: AttachmentKind[]
13
17
  maxBytes?: number
14
18
  maxFiles?: number
19
+ /** Per text file, the characters the model reads - a sheet read into text longer than this is refused, not cut. */
20
+ maxChars?: number
15
21
  }
16
22
 
17
23
  export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024
18
24
  export const DEFAULT_MAX_FILES = 5
25
+ /** How much of a text file reaches the model - the server's TEXT_CHARS_FOR_MODEL. */
26
+ export const TEXT_CHARS_FOR_MODEL = 20_000
19
27
 
20
28
  const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']
21
29
  const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']
@@ -35,6 +43,16 @@ export type AttachmentRefusal =
35
43
  | { code: 'attachment-too-large'; name: string; maxBytes: number }
36
44
  | { code: 'too-many-attachments'; maxFiles: number }
37
45
 
46
+ /** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */
47
+ function takes(policy: AttachmentPolicy, kind: AttachmentKind): boolean {
48
+ return policy.kinds.includes(kind) || (kind === 'text' && policy.kinds.includes('spreadsheet'))
49
+ }
50
+
51
+ /** The characters of a text file the model reads under this policy. */
52
+ export function textCharsOf(policy: AttachmentPolicy | null | undefined): number {
53
+ return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL
54
+ }
55
+
38
56
  export function refuseAttachments(
39
57
  policy: AttachmentPolicy | null | undefined,
40
58
  files: { name?: string; type?: string; size?: number }[]
@@ -48,7 +66,7 @@ export function refuseAttachments(
48
66
  const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES
49
67
  for (const file of files) {
50
68
  const kind = attachmentKindOf(file)
51
- if (!kind || !policy.kinds.includes(kind)) {
69
+ if (!kind || !takes(policy, kind)) {
52
70
  return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }
53
71
  }
54
72
  if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }
@@ -60,7 +78,8 @@ export function refuseAttachments(
60
78
  export function acceptOf(policy: AttachmentPolicy | null | undefined): string {
61
79
  const accept: string[] = []
62
80
  if (policy?.kinds?.includes('image')) accept.push('image/*')
63
- if (policy?.kinds?.includes('text')) accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES)
81
+ if (policy?.kinds?.includes('text') || policy?.kinds?.includes('spreadsheet')) accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES)
82
+ if (policy?.kinds?.includes('spreadsheet')) accept.push('.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
64
83
  return accept.join(',')
65
84
  }
66
85
 
@@ -0,0 +1,35 @@
1
+ /*
2
+ * One 입력안's fingerprint - what the AI 작업판 showed and what the card enters are the same proposal
3
+ * (operato-application/design/ai/workspace.md §6-1). The screen's copy and the server's are one rule;
4
+ * `test/entry-hash-agree.test.ts` runs both over one table.
5
+ *
6
+ * It covers what is entered: the key, the target, create or change, and each row's index, record and values.
7
+ */
8
+
9
+ const canonical = (value: unknown): string => {
10
+ if (value === null || value === undefined) return 'null'
11
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
12
+ if (typeof value === 'object')
13
+ return `{${Object.keys(value as object)
14
+ .filter(name => (value as any)[name] !== undefined)
15
+ .sort()
16
+ .map(name => `${JSON.stringify(name)}:${canonical((value as any)[name])}`)
17
+ .join(',')}}`
18
+ return JSON.stringify(value)
19
+ }
20
+
21
+ /** FNV-1a over the canonical text - a fingerprint to compare, not a secret. */
22
+ export function entryProposalHash(proposal: any): string {
23
+ const text = canonical({
24
+ key: proposal?.key,
25
+ target: proposal?.target,
26
+ op: proposal?.op ?? 'create',
27
+ rows: (proposal?.rows ?? []).map((row: any) => ({ index: row?.index, id: row?.id, values: row?.values ?? {} }))
28
+ })
29
+ let hash = 0x811c9dc5
30
+ for (let i = 0; i < text.length; i++) {
31
+ hash ^= text.charCodeAt(i)
32
+ hash = Math.imul(hash, 0x01000193) >>> 0
33
+ }
34
+ return `${hash.toString(16).padStart(8, '0')}-${text.length.toString(16)}`
35
+ }
@@ -6,6 +6,7 @@
6
6
  * screen sends no rows of its own.
7
7
  */
8
8
  import type { ProposalContext, ProposalOutcome } from './assistant-proposals.js'
9
+ import { reviewedHashOf, reviewStillHolds } from './entry-workspace.js'
9
10
 
10
11
  export interface EntryIssueView {
11
12
  field?: string
@@ -93,12 +94,18 @@ export function entryOutcomeNote(answer: EntryApplyAnswer, t: (key: string, opti
93
94
 
94
95
  /** The handler every entry card runs - built with the mutation so the rules are tested without a server. */
95
96
  export function entryProposalHandler(
96
- apply: (sessionId: string, proposalKey: string) => Promise<EntryApplyAnswer>,
97
- t: (key: string, options?: any) => string
97
+ apply: (sessionId: string, proposalKey: string, reviewedHash?: string) => Promise<EntryApplyAnswer>,
98
+ t: (key: string, options?: any) => string,
99
+ reviewedOf: (key: string) => string | undefined = reviewedHashOf
98
100
  ) {
99
101
  return async (proposal: any, context: ProposalContext): Promise<ProposalOutcome> => {
100
102
  if (!context.sessionId || !proposal?.key) return { verdict: 'unavailable', errorCode: 'no-conversation' }
101
- const answer = { ...(await apply(context.sessionId, proposal.key)), op: proposal.op }
103
+ /* Reviewed in the 작업판: entered only as it was shown there (design/ai/workspace.md §6-1) - here, and again at the server. */
104
+ const reviewed = reviewedOf(proposal.key)
105
+ if (!reviewStillHolds(proposal, reviewed)) return { verdict: 'refused', errorCode: 'review-stale', note: t('ai-assistant.entry.review-stale', { defaultValue: 'this proposal changed after it was reviewed in the workspace - review it again before entering.' }) } as any
106
+ const answer = { ...(await apply(context.sessionId, proposal.key, reviewed)), op: proposal.op }
107
+ if (answer.errorCode === 'review-stale')
108
+ return { verdict: 'refused', errorCode: 'review-stale', applied: 0, missed: 0, note: t('ai-assistant.entry.review-stale', { defaultValue: 'this proposal changed after it was reviewed in the workspace - review it again before entering.' }) }
102
109
  return {
103
110
  verdict: answer.verdict,
104
111
  ...(answer.errorCode ? { errorCode: answer.errorCode } : {}),
@@ -0,0 +1,70 @@
1
+ /*
2
+ * Reviewing a 입력안 in the AI 작업판 (operato-application/design/ai/workspace.md §6-1). Pure but for the
3
+ * session store - tested without a browser.
4
+ *
5
+ * The page is read-only: every column and every row, sorted as stored, issues marked. The one place that enters
6
+ * is the dock's card. What the page showed is remembered by its fingerprint, and the card sends it - a proposal
7
+ * that changed in between is refused, and the person reviews it again.
8
+ */
9
+ import { entryProposalHash } from './entry-hash.js'
10
+
11
+ /** A card wide or long enough not to be read in the dock. */
12
+ export const WORKSPACE_ROWS = 20
13
+ export const WORKSPACE_COLUMNS = 6
14
+
15
+ export function offersWorkspaceReview(proposal: any): boolean {
16
+ return (proposal?.rows?.length ?? 0) > WORKSPACE_ROWS || (proposal?.columns?.length ?? 0) > WORKSPACE_COLUMNS
17
+ }
18
+
19
+ export function entryReviewAddress(sessionId: string, proposalKey: string): string {
20
+ return `assistant-workspace?${new URLSearchParams({ session: sessionId, entry: proposalKey }).toString()}`
21
+ }
22
+
23
+ /** The proposal of that key among the conversation's messages, and whether a newer card of the same record replaced it. */
24
+ export function entryForReview(messages: { id: string; createdAt?: string; proposalsJson?: any[] | null }[], key: string): { proposal?: any; createdAt?: string; superseded: boolean } {
25
+ let at = -1
26
+ let proposal: any
27
+ let createdAt: string | undefined
28
+ messages.forEach((message, i) => {
29
+ for (const one of message.proposalsJson ?? []) if (one?.kind === 'entry' && one?.key === key) (at = i), (proposal = one), (createdAt = message.createdAt)
30
+ })
31
+ if (!proposal) return { superseded: false }
32
+ const superseded = messages
33
+ .slice(at + 1)
34
+ .some(message => (message.proposalsJson ?? []).some(one => one?.kind === 'entry' && one?.target === proposal.target && (one?.op ?? 'create') === (proposal.op ?? 'create')))
35
+ return { proposal, createdAt, superseded }
36
+ }
37
+
38
+ const STORE = 'ai-assistant.entry-reviewed:'
39
+
40
+ /** Remembers what the page showed for this key. A browser that keeps nothing keeps nothing - the card then sends no fingerprint. */
41
+ export function rememberReviewed(proposal: any, store: Pick<Storage, 'setItem'> | undefined = safeSession()): string {
42
+ const hash = entryProposalHash(proposal)
43
+ try {
44
+ store?.setItem(STORE + proposal.key, hash)
45
+ } catch {
46
+ /* private window or blocked storage */
47
+ }
48
+ return hash
49
+ }
50
+
51
+ export function reviewedHashOf(key: string, store: Pick<Storage, 'getItem'> | undefined = safeSession()): string | undefined {
52
+ try {
53
+ return store?.getItem(STORE + key) ?? undefined
54
+ } catch {
55
+ return undefined
56
+ }
57
+ }
58
+
59
+ /** Whether the card may be pressed: not reviewed, or reviewed as it stands now. */
60
+ export function reviewStillHolds(proposal: any, reviewed: string | undefined): boolean {
61
+ return !reviewed || reviewed === entryProposalHash(proposal)
62
+ }
63
+
64
+ function safeSession(): Storage | undefined {
65
+ try {
66
+ return typeof sessionStorage === 'undefined' ? undefined : sessionStorage
67
+ } catch {
68
+ return undefined
69
+ }
70
+ }