@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
@@ -0,0 +1,145 @@
1
+ /*
2
+ * A table pasted into the composer, carried as a file (design/ai/data-entry.md §4-1, design/ai/shared-dock.md §9-5).
3
+ *
4
+ * Rows copied from a spreadsheet or a web page are what an AI 입력 is made from most often. Pasted as words they
5
+ * reach the model as something the person said, and the entry card cannot check a row against them nor read a
6
+ * printed total. Carried as a CSV file they are a source like an attached sheet: each row is found on its line.
7
+ *
8
+ * - The HTML of the clipboard is read first when it holds a table - a spreadsheet and a browser both put one there,
9
+ * and it keeps a cell that holds a line break or a tab whole. A web page whose words lie mostly outside the table
10
+ * is not a table paste; it is left as words.
11
+ * - Otherwise the plain text is a table when its lines are tab-separated (a spreadsheet's own text).
12
+ * - The file has the shape of a read sheet: a line naming it, then every row led by its own number.
13
+ * - The limit is on the text, as for a sheet - a table over it is refused, never cut.
14
+ *
15
+ * Pure: the screen hands in the clipboard's strings.
16
+ */
17
+ import { textCharsOf, type AttachmentPolicy } from './attachment-policy.js'
18
+ import { csvField } from './spreadsheet-text.js'
19
+
20
+ export interface PastedTable {
21
+ rows: string[][]
22
+ columns: number
23
+ }
24
+
25
+ /** Words outside the table beyond this are a page that holds a table, not a table. */
26
+ const WORDS_OUTSIDE_A_TABLE = 20
27
+
28
+ const ENTITIES: Record<string, string> = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'" }
29
+
30
+ function decodeEntities(text: string): string {
31
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*|#39);/gi, (whole, name: string) => {
32
+ const lower = name.toLowerCase()
33
+ if (lower in ENTITIES) return ENTITIES[lower]
34
+ if (lower.startsWith('#x')) return String.fromCodePoint(parseInt(lower.slice(2), 16))
35
+ if (lower.startsWith('#')) return String.fromCodePoint(parseInt(lower.slice(1), 10))
36
+ return whole
37
+ })
38
+ }
39
+
40
+ /** What a piece of HTML shows as words - a line break and a cell's own breaks become one space. */
41
+ function wordsOf(html: string): string {
42
+ return decodeEntities(html.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, ''))
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+ }
46
+
47
+ /** What the clipboard's HTML shows, without what is never shown - comments, styles, scripts, the head. */
48
+ function shownHtml(html: string): string {
49
+ return html
50
+ .replace(/<!--[\s\S]*?-->/g, '')
51
+ .replace(/<(style|script|head)\b[\s\S]*?<\/\1\s*>/gi, '')
52
+ }
53
+
54
+ /** The rows of the tables in the clipboard's HTML, or null when it holds none or is mostly not a table. */
55
+ export function tableOfHtml(html: string | null | undefined): string[][] | null {
56
+ if (!html || !/<table\b/i.test(html)) return null
57
+ const shown = shownHtml(html)
58
+ const outside = wordsOf(shown.replace(/<table\b[\s\S]*?<\/table\s*>/gi, ' '))
59
+ if (outside.replace(/\s/g, '').length > WORDS_OUTSIDE_A_TABLE) return null
60
+
61
+ const rows: string[][] = []
62
+ for (const part of shown.split(/<tr\b[^>]*>/i).slice(1)) {
63
+ const body = part.split(/<\/table\s*>/i)[0]
64
+ const row: string[] = []
65
+ for (const cell of body.matchAll(/<(td|th)\b([^>]*)>([\s\S]*?)(?=<\/?(?:td|th|tr)\b|$)/gi)) {
66
+ row.push(wordsOf(cell[3]))
67
+ const span = Number(/\bcolspan\s*=\s*["']?(\d+)/i.exec(cell[2])?.[1] ?? 1)
68
+ for (let more = 1; more < Math.min(span, 100); more++) row.push('')
69
+ }
70
+ if (row.some(text => text !== '')) rows.push(row)
71
+ }
72
+ return rows.length ? rows : null
73
+ }
74
+
75
+ /** The rows of a spreadsheet's plain text - tab-separated lines, a cell with a break or a tab in quotes. */
76
+ export function tableOfText(text: string | null | undefined): string[][] | null {
77
+ if (!text || !text.includes('\t')) return null
78
+ const rows: string[][] = []
79
+ let row: string[] = []
80
+ let cell = ''
81
+ let quoted = false
82
+ for (let at = 0; at < text.length; at++) {
83
+ const c = text[at]
84
+ if (quoted) {
85
+ if (c === '"' && text[at + 1] === '"') (cell += '"'), at++
86
+ else if (c === '"') quoted = false
87
+ else cell += c
88
+ } else if (c === '"' && cell === '') quoted = true
89
+ else if (c === '\t') row.push(cell), (cell = '')
90
+ else if (c === '\n' || c === '\r') {
91
+ if (c === '\r' && text[at + 1] === '\n') at++
92
+ row.push(cell), rows.push(row), (row = []), (cell = '')
93
+ } else cell += c
94
+ }
95
+ if (cell !== '' || row.length) row.push(cell), rows.push(row)
96
+ const kept = rows.map(one => one.map(text => text.replace(/\s+/g, ' ').trim())).filter(one => one.some(text => text !== ''))
97
+ /* One tab in a sentence is not a table - most lines must be split into columns. */
98
+ if (kept.filter(one => one.length > 1).length < Math.max(2, Math.ceil(kept.length / 2))) return null
99
+ return kept
100
+ }
101
+
102
+ /** The table a paste holds, or null for words - at least two rows of at least two columns. */
103
+ export function pastedTable(clipboard: { html?: string | null; text?: string | null }): PastedTable | null {
104
+ const fromHtml = tableOfHtml(clipboard.html)
105
+ /* A page the HTML shows to be mostly words stays words - its plain text would read the table out of it anyway. */
106
+ if (!fromHtml && clipboard.html && /<table\b/i.test(clipboard.html)) return null
107
+ for (const rows of [fromHtml, tableOfText(clipboard.text)]) {
108
+ if (!rows) continue
109
+ const trimmed = rows.map(row => {
110
+ let cells = row
111
+ while (cells.length && cells[cells.length - 1] === '') cells = cells.slice(0, -1)
112
+ return cells
113
+ })
114
+ const columns = Math.max(...trimmed.map(row => row.length))
115
+ if (trimmed.length >= 2 && columns >= 2) return { rows: trimmed, columns }
116
+ }
117
+ return null
118
+ }
119
+
120
+ /** The table as the text of its file: a line naming it, then every row led by its own number. */
121
+ export function pastedTableText(table: PastedTable, name: string): string {
122
+ const lines = table.rows.map((row, i) => [String(i + 1), ...row].map(csvField).join(','))
123
+ return [`# ${name} · a table the person pasted · the first column is the row number in the pasted table`, ...lines].join('\n') + '\n'
124
+ }
125
+
126
+ export type PasteRefusal = { code: 'attachment-paste-too-long'; rows: number; maxChars: number }
127
+
128
+ /**
129
+ * What a paste becomes in a conversation under this policy: a table file, a refusal, or null - the paste stays words
130
+ * (no table in it, or a conversation that takes no text files).
131
+ */
132
+ export function pastedTableFile(
133
+ clipboard: { html?: string | null; text?: string | null },
134
+ policy: AttachmentPolicy | null | undefined,
135
+ nameOf: (rows: number) => string
136
+ ): { name: string; text: string } | { refusal: PasteRefusal } | null {
137
+ if (!policy?.kinds?.some(kind => kind === 'text' || kind === 'spreadsheet')) return null
138
+ const table = pastedTable(clipboard)
139
+ if (!table) return null
140
+ const name = nameOf(table.rows.length)
141
+ const text = pastedTableText(table, name)
142
+ const maxChars = textCharsOf(policy)
143
+ if (text.length > maxChars) return { refusal: { code: 'attachment-paste-too-long', rows: table.rows.length, maxChars } }
144
+ return { name: `${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`, text }
145
+ }
@@ -0,0 +1,125 @@
1
+ /*
2
+ * A spreadsheet read into text the conversation can carry (design/ai/shared-dock.md §9-5).
3
+ *
4
+ * The door takes images and text only; a mode that opens 'spreadsheet' has the screen read each .xlsx sheet into
5
+ * CSV before sending, so the model reads the sheet and the entry card can name where a row came from.
6
+ *
7
+ * - Read with exceljs. The old binary .xls is not read - it is refused by name (exceljs cannot open it, and the
8
+ * npm SheetJS that can has unfixed flaws; a file a person uploads is not opened with it).
9
+ * - A cell is its value - a formula as its computed result, a date as ISO, rich text as its text.
10
+ * - Every line carries the sheet's own row number. Empty rows are left out, never renumbered, so "sheet · row" on a
11
+ * card is the row the person sees in Excel.
12
+ * - The limit is on the text after reading - a small .xlsx can be a long text. A sheet over it is refused, never cut:
13
+ * a cut ledger is read as the whole ledger.
14
+ */
15
+
16
+ export type SpreadsheetKind = 'xlsx' | 'xls'
17
+
18
+ const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
19
+ const XLS_TYPE = 'application/vnd.ms-excel'
20
+
21
+ /** A spreadsheet by its type, or by its name when the browser did not know the type. */
22
+ export function spreadsheetKindOf(file: { type?: string; name?: string }): SpreadsheetKind | null {
23
+ const type = (file.type || '').toLowerCase()
24
+ const name = (file.name || '').toLowerCase()
25
+ if (type === XLSX_TYPE || name.endsWith('.xlsx')) return 'xlsx'
26
+ if (type === XLS_TYPE || name.endsWith('.xls')) return 'xls'
27
+ return null
28
+ }
29
+
30
+ /** The picker's `accept` for spreadsheets. */
31
+ export const SPREADSHEET_ACCEPT = ['.xlsx', XLSX_TYPE]
32
+
33
+ const pad = (n: number) => String(n).padStart(2, '0')
34
+
35
+ /** One cell as text - what Excel shows as the value, not how it is stored. */
36
+ export function cellText(value: unknown): string {
37
+ if (value === null || value === undefined) return ''
38
+ if (value instanceof Date) {
39
+ if (isNaN(value.getTime())) return ''
40
+ const day = `${value.getUTCFullYear()}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())}`
41
+ const h = value.getUTCHours()
42
+ const m = value.getUTCMinutes()
43
+ const s = value.getUTCSeconds()
44
+ return h || m || s ? `${day}T${pad(h)}:${pad(m)}:${pad(s)}` : day
45
+ }
46
+ if (typeof value === 'object') {
47
+ const v = value as any
48
+ if ('result' in v) return cellText(v.result)
49
+ if ('formula' in v || 'sharedFormula' in v) return ''
50
+ if (Array.isArray(v.richText)) return v.richText.map((part: any) => part?.text ?? '').join('')
51
+ if ('text' in v) return cellText(v.text)
52
+ if ('error' in v) return String(v.error)
53
+ return ''
54
+ }
55
+ return String(value)
56
+ }
57
+
58
+ /** One CSV field - quoted when it holds a comma, a quote or a line break. */
59
+ export const csvField = (text: string) => (/[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text)
60
+
61
+ export interface SheetRow {
62
+ /** The row number in the sheet - 1 for Excel's first row. */
63
+ number: number
64
+ cells: unknown[]
65
+ }
66
+
67
+ /** One sheet as CSV: a line naming the file and sheet, then every non-empty row led by its own row number. */
68
+ export function sheetText(fileName: string, sheetName: string, rows: SheetRow[]): string {
69
+ const lines = rows
70
+ .map(row => ({ number: row.number, cells: row.cells.map(cellText) }))
71
+ .filter(row => row.cells.some(cell => cell.trim() !== ''))
72
+ .map(row => {
73
+ let cells = row.cells
74
+ while (cells.length && cells[cells.length - 1] === '') cells = cells.slice(0, -1)
75
+ return [String(row.number), ...cells].map(csvField).join(',')
76
+ })
77
+ return [`# ${fileName} · sheet "${sheetName}" · the first column is the sheet's own row number`, ...lines].join('\n') + '\n'
78
+ }
79
+
80
+ export interface SheetFile {
81
+ name: string
82
+ text: string
83
+ }
84
+
85
+ export type SpreadsheetRefusal = { code: 'attachment-xls-not-read'; name: string } | { code: 'attachment-sheet-too-long'; name: string; maxChars: number } | { code: 'attachment-sheet-unreadable'; name: string }
86
+ | { code: 'attachment-sheet-empty'; name: string }
87
+
88
+ /** The file name a sheet travels under - "거래내역.xlsx" sheet "9월" → "거래내역 - 9월.csv". */
89
+ export function sheetFileName(fileName: string, sheetName: string): string {
90
+ return `${fileName.replace(/\.xlsx?$/i, '')} - ${sheetName.replace(/[\\/:*?"<>|]/g, '_')}.csv`
91
+ }
92
+
93
+ /**
94
+ * Every non-empty sheet of an .xlsx as text, or why not. `load` is exceljs's Workbook - passed in so the screen
95
+ * loads the library only when a spreadsheet is attached.
96
+ */
97
+ export async function readSpreadsheet(
98
+ data: ArrayBuffer | Uint8Array,
99
+ fileName: string,
100
+ maxChars: number,
101
+ Workbook: new () => any
102
+ ): Promise<{ files: SheetFile[] } | { refusal: SpreadsheetRefusal }> {
103
+ const workbook = new Workbook()
104
+ try {
105
+ await workbook.xlsx.load(data)
106
+ } catch {
107
+ return { refusal: { code: 'attachment-sheet-unreadable', name: fileName } }
108
+ }
109
+ const files: SheetFile[] = []
110
+ for (const sheet of workbook.worksheets) {
111
+ const rows: SheetRow[] = []
112
+ sheet.eachRow({ includeEmpty: false }, (row: any, number: number) => {
113
+ const values = Array.isArray(row.values) ? row.values.slice(1) : []
114
+ rows.push({ number, cells: values })
115
+ })
116
+ if (!rows.length) continue
117
+ const text = sheetText(fileName, sheet.name, rows)
118
+ const name = sheetFileName(fileName, sheet.name)
119
+ if (text.length > maxChars) return { refusal: { code: 'attachment-sheet-too-long', name: `${fileName} · ${sheet.name}`, maxChars } }
120
+ files.push({ name, text })
121
+ }
122
+ /* A workbook with nothing in it is said, not dropped: the person would think it was sent. */
123
+ if (!files.length) return { refusal: { code: 'attachment-sheet-empty', name: fileName } }
124
+ return { files }
125
+ }
@@ -234,6 +234,8 @@ export declare class OxAssistantChat extends LitElement {
234
234
  /** A refusal in the reader's words - the same keys the door uses. */
235
235
  private attachmentRefusalText;
236
236
  private onAttachFiles;
237
+ /** Each .xlsx replaced by one CSV file per sheet; any other file passes as it is. */
238
+ private readSpreadsheets;
237
239
  private readFileAsBase64;
238
240
  private removeAttachment;
239
241
  private onComposerPaste;
@@ -27,7 +27,10 @@ import { i18next } from '@operato/i18n';
27
27
  import { ScrollbarStyles } from '@operato/styles';
28
28
  import gql from 'graphql-tag';
29
29
  import { renderMarkdown, injectMentionRefids, injectMentionUserIds, stripMentionRefids } from './markdown.js';
30
- import { acceptOf, chatAttachmentUrl, refuseAttachments } from '../utils/attachment-policy.js';
30
+ import { acceptOf, chatAttachmentUrl, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js';
31
+ import { pastedTableFile } from '../utils/pasted-table.js';
32
+ import { readSpreadsheet, spreadsheetKindOf } from '../utils/spreadsheet-text.js';
33
+ import { entryReviewAddress, offersWorkspaceReview } from '../utils/entry-workspace.js';
31
34
  import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js';
32
35
  import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js';
33
36
  import './mention-popup.js';
@@ -449,7 +452,16 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
449
452
  async onAttachFiles(files) {
450
453
  if (!files || files.length === 0)
451
454
  return;
452
- const list = Array.from(files);
455
+ let list = Array.from(files);
456
+ if (this.usesAttachmentPolicy && this.modeView?.attachments?.kinds?.includes('spreadsheet')) {
457
+ /* A spreadsheet travels as the text of its sheets (§9-5) - read here, refused by name when it cannot be. */
458
+ const read = await this.readSpreadsheets(list);
459
+ if ('refusal' in read) {
460
+ this.errorMessage = String(i18next.t(`ai-assistant.text.${read.refusal.code}`, { ...read.refusal, defaultValue: read.refusal.code }));
461
+ return;
462
+ }
463
+ list = read.files;
464
+ }
453
465
  if (this.usesAttachmentPolicy) {
454
466
  /* Said before anything is sent - never cut silently (§9-1). */
455
467
  const refusal = refuseAttachments(this.modeView?.attachments, [...this.attachments, ...list].map(f => ({ name: f.name, type: f.type, size: f.size })));
@@ -481,6 +493,30 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
481
493
  }
482
494
  this.attachments = [...this.attachments, ...newAttachments];
483
495
  }
496
+ /** Each .xlsx replaced by one CSV file per sheet; any other file passes as it is. */
497
+ async readSpreadsheets(list) {
498
+ const out = [];
499
+ let Workbook;
500
+ for (const file of list) {
501
+ const kind = spreadsheetKindOf(file);
502
+ if (!kind) {
503
+ out.push(file);
504
+ continue;
505
+ }
506
+ if (kind === 'xls')
507
+ return { refusal: { code: 'attachment-xls-not-read', name: file.name } };
508
+ if (!Workbook) {
509
+ const mod = await import('exceljs');
510
+ Workbook = (mod.default ?? mod).Workbook;
511
+ }
512
+ const read = await readSpreadsheet(await file.arrayBuffer(), file.name, textCharsOf(this.modeView?.attachments), Workbook);
513
+ if ('refusal' in read)
514
+ return read;
515
+ for (const sheet of read.files)
516
+ out.push(new File([sheet.text], sheet.name, { type: 'text/csv' }));
517
+ }
518
+ return { files: out };
519
+ }
484
520
  readFileAsBase64(file) {
485
521
  return new Promise((resolve, reject) => {
486
522
  const reader = new FileReader();
@@ -500,6 +536,22 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
500
536
  const items = e.clipboardData?.items;
501
537
  if (!items || items.length === 0)
502
538
  return;
539
+ /*
540
+ Rows copied from a spreadsheet or a web page travel as a file, so the entry card can check each row against
541
+ its line. A spreadsheet also puts a picture of the cells on the clipboard - with the table taken, it is left.
542
+ */
543
+ if (this.usesAttachmentPolicy) {
544
+ const pasted = pastedTableFile({ html: e.clipboardData?.getData('text/html'), text: e.clipboardData?.getData('text/plain') }, this.modeView?.attachments, rows => String(i18next.t('ai-assistant.text.pasted-table-name', { rows, defaultValue: 'pasted table ({rows} rows)' })));
545
+ if (pasted) {
546
+ e.preventDefault();
547
+ if ('refusal' in pasted) {
548
+ this.errorMessage = String(i18next.t(`ai-assistant.text.${pasted.refusal.code}`, { ...pasted.refusal, defaultValue: pasted.refusal.code }));
549
+ return;
550
+ }
551
+ this.onAttachFiles([new File([pasted.text], pasted.name, { type: 'text/csv' })]);
552
+ return;
553
+ }
554
+ }
503
555
  const files = [];
504
556
  for (let i = 0; i < items.length; i++) {
505
557
  const item = items[i];
@@ -1619,6 +1671,42 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
1619
1671
  color: inherit;
1620
1672
  }
1621
1673
 
1674
+ /* The entry card's way to the large review - a quiet action under its table, never a second run button. */
1675
+ .workspace-review {
1676
+ display: flex;
1677
+ align-items: center;
1678
+ gap: 5px;
1679
+ margin: 6px 0 0 auto;
1680
+ padding: 5px 11px 5px 9px;
1681
+ border: 1px solid transparent;
1682
+ border-radius: 999px;
1683
+ background: transparent;
1684
+ color: var(--md-sys-color-primary, #0f766e);
1685
+ font-family: inherit;
1686
+ font-size: 12px;
1687
+ font-weight: 600;
1688
+ letter-spacing: 0.01em;
1689
+ cursor: pointer;
1690
+ transition:
1691
+ background-color 0.14s ease-in-out,
1692
+ border-color 0.14s ease-in-out;
1693
+ }
1694
+ .workspace-review md-icon {
1695
+ --md-icon-size: 15px;
1696
+ color: inherit;
1697
+ }
1698
+ .workspace-review:hover {
1699
+ background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 8%, transparent);
1700
+ border-color: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 24%, transparent);
1701
+ }
1702
+ .workspace-review:active {
1703
+ background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 14%, transparent);
1704
+ }
1705
+ .workspace-review:focus-visible {
1706
+ outline: 2px solid var(--md-sys-color-primary, #0f766e);
1707
+ outline-offset: 1px;
1708
+ }
1709
+
1622
1710
  /* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
1623
1711
  .grounding-warning {
1624
1712
  display: flex;
@@ -2899,6 +2987,12 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
2899
2987
  `;
2900
2988
  }
2901
2989
  onKeyDown(e) {
2990
+ /*
2991
+ The Enter that ends a Korean (or Japanese, Chinese) syllable belongs to the input method - sending on it
2992
+ sends the line without its last syllable, which then lands in the emptied box.
2993
+ */
2994
+ if (e.isComposing || e.keyCode === 229)
2995
+ return;
2902
2996
  // Mention popup 활성 시 — 화살표/Enter/Tab/Esc 가로채서 popup 네비게이션
2903
2997
  if (this.mentionOpen) {
2904
2998
  if (e.key === 'ArrowDown') {
@@ -3576,6 +3670,15 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
3576
3670
  ` : nothing}
3577
3671
  </div>
3578
3672
  ${entry ? html `<ox-assistant-entry-review .proposal=${p}></ox-assistant-entry-review>` : nothing}
3673
+ ${entry && this.sessionId && offersWorkspaceReview(p)
3674
+ ? html `<button
3675
+ class="workspace-review"
3676
+ @click=${() => this.dispatchEvent(new CustomEvent('assistant-open-page', { detail: { href: entryReviewAddress(this.sessionId, key) }, bubbles: true, composed: true }))}
3677
+ >
3678
+ <md-icon>open_in_full</md-icon>
3679
+ <span>${i18next.t('ai-assistant.entry.review-in-workspace', { defaultValue: 'review in the workspace' })}</span>
3680
+ </button>`
3681
+ : nothing}
3579
3682
  ${rawChoices.length ? html `
3580
3683
  <div class="proposal-choices" role="group" aria-label=${i18next.t('ai-assistant.text.proposal-choices', { defaultValue: 'follow-up choices' })}>
3581
3684
  ${rawChoices.map((c) => html `