@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.
- package/client/components/assistant-chat.ts +108 -2
- package/client/components/assistant-dock.ts +4 -4
- package/client/components/assistant-mode.ts +1 -1
- package/client/components/entry-review.ts +23 -3
- package/client/pages/assistant-workspace-page.ts +82 -2
- package/client/utils/attachment-policy.ts +22 -3
- package/client/utils/entry-hash.ts +35 -0
- package/client/utils/entry-proposal.ts +10 -3
- package/client/utils/entry-workspace.ts +70 -0
- package/client/utils/pasted-table.ts +145 -0
- package/client/utils/spreadsheet-text.ts +125 -0
- package/dist-client/components/assistant-chat.d.ts +2 -0
- package/dist-client/components/assistant-chat.js +105 -2
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.js +4 -4
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +2 -1
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/entry-review.d.ts +2 -0
- package/dist-client/components/entry-review.js +30 -3
- package/dist-client/components/entry-review.js.map +1 -1
- package/dist-client/pages/assistant-workspace-page.d.ts +8 -0
- package/dist-client/pages/assistant-workspace-page.js +98 -2
- package/dist-client/pages/assistant-workspace-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/attachment-policy.d.ts +7 -1
- package/dist-client/utils/attachment-policy.js +14 -2
- package/dist-client/utils/attachment-policy.js.map +1 -1
- package/dist-client/utils/entry-hash.d.ts +2 -0
- package/dist-client/utils/entry-hash.js +36 -0
- package/dist-client/utils/entry-hash.js.map +1 -0
- package/dist-client/utils/entry-proposal.d.ts +1 -1
- package/dist-client/utils/entry-proposal.js +9 -2
- package/dist-client/utils/entry-proposal.js.map +1 -1
- package/dist-client/utils/entry-workspace.d.ts +20 -0
- package/dist-client/utils/entry-workspace.js +68 -0
- package/dist-client/utils/entry-workspace.js.map +1 -0
- package/dist-client/utils/pasted-table.d.ts +34 -0
- package/dist-client/utils/pasted-table.js +150 -0
- package/dist-client/utils/pasted-table.js.map +1 -0
- package/dist-client/utils/spreadsheet-text.d.ts +48 -0
- package/dist-client/utils/spreadsheet-text.js +110 -0
- package/dist-client/utils/spreadsheet-text.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.js +1 -1
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
- package/dist-server/service/chat-session/attachment-policy.d.ts +5 -2
- package/dist-server/service/chat-session/attachment-policy.js +10 -1
- package/dist-server/service/chat-session/attachment-policy.js.map +1 -1
- package/dist-server/service/chat-session/attachment-tools.js +3 -1
- package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
- package/dist-server/service/chat-session/session-attachments.d.ts +3 -2
- package/dist-server/service/chat-session/session-attachments.js +5 -4
- package/dist-server/service/chat-session/session-attachments.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.d.ts +2 -0
- package/dist-server/service/data-entry/entry-apply.js +4 -0
- package/dist-server/service/data-entry/entry-apply.js.map +1 -1
- package/dist-server/service/data-entry/entry-hash.d.ts +2 -0
- package/dist-server/service/data-entry/entry-hash.js +39 -0
- package/dist-server/service/data-entry/entry-hash.js.map +1 -0
- package/dist-server/service/data-entry/entry-resolver.d.ts +1 -1
- package/dist-server/service/data-entry/entry-resolver.js +5 -3
- package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/server/service/assistant-chat-resolver.ts +2 -2
- package/server/service/assistant-mode/assistant-mode-resolver.ts +1 -1
- package/server/service/chat-session/attachment-policy.ts +18 -2
- package/server/service/chat-session/attachment-tools.ts +3 -1
- package/server/service/chat-session/session-attachments.ts +5 -4
- package/server/service/data-entry/data-entry.test.ts +10 -0
- package/server/service/data-entry/entry-apply.ts +5 -0
- package/server/service/data-entry/entry-hash.ts +35 -0
- package/server/service/data-entry/entry-resolver.ts +4 -2
- package/test/entry-card-styles.test.ts +18 -0
- package/test/entry-workspace.test.ts +89 -0
- package/test/pasted-table.test.ts +147 -0
- package/test/spreadsheet-text.test.ts +92 -0
- package/translations/en.json +12 -1
- package/translations/ja.json +12 -1
- package/translations/ko.json +12 -1
- package/translations/ms.json +12 -1
- 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
|
-
|
|
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 `
|