@things-factory/ai-assistant 10.1.108 → 10.1.110
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 +299 -19
- package/client/utils/composer-frame.ts +49 -0
- package/client/utils/pasted-table.ts +2 -2
- package/dist-client/components/assistant-chat.d.ts +18 -0
- package/dist-client/components/assistant-chat.js +301 -19
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/composer-frame.d.ts +28 -0
- package/dist-client/utils/composer-frame.js +46 -0
- package/dist-client/utils/composer-frame.js.map +1 -0
- package/dist-client/utils/pasted-table.d.ts +1 -0
- package/dist-client/utils/pasted-table.js +1 -1
- package/dist-client/utils/pasted-table.js.map +1 -1
- package/package.json +2 -2
- package/test/composer-frame.test.ts +109 -0
- package/test/entry-card-styles.test.ts +18 -0
- package/test/pasted-table.test.ts +8 -1
- package/translations/en.json +7 -1
- package/translations/ja.json +7 -1
- package/translations/ko.json +7 -1
- package/translations/ms.json +7 -1
- package/translations/zh.json +7 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The part of the chat's height the small box may take before it scrolls. */
|
|
2
|
+
export declare const COMPOSER_GROWTH = 0.4;
|
|
3
|
+
/** The box's height for its content, both border-box pixels. */
|
|
4
|
+
export declare function composerHeight(content: number, chat: number, resting: number): {
|
|
5
|
+
height: number;
|
|
6
|
+
scrolls: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type ComposerKey = {
|
|
9
|
+
key: string;
|
|
10
|
+
shiftKey?: boolean;
|
|
11
|
+
ctrlKey?: boolean;
|
|
12
|
+
metaKey?: boolean;
|
|
13
|
+
altKey?: boolean;
|
|
14
|
+
};
|
|
15
|
+
/** What a key does in the box: send, close the wide box, or nothing of ours (the textarea keeps it). */
|
|
16
|
+
export declare function composerKeyAction(e: ComposerKey, wide: boolean): 'send' | 'narrow' | null;
|
|
17
|
+
/** The shortcut the wide box's send button names, in the keys of this machine. */
|
|
18
|
+
export declare function sendShortcut(platform: string | undefined): string;
|
|
19
|
+
/**
|
|
20
|
+
* A pasted table given back as words: what the clipboard held as text, or - when it held only HTML - the rows with
|
|
21
|
+
* a tab between cells, as a spreadsheet copies them.
|
|
22
|
+
*/
|
|
23
|
+
export declare function tableAsWords(table: {
|
|
24
|
+
rows: string[][];
|
|
25
|
+
text?: string | null;
|
|
26
|
+
}): string;
|
|
27
|
+
/** The words appended to what is already written - on a line of their own. */
|
|
28
|
+
export declare function appendWords(written: string, words: string): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The composer's frame (operato-application/design/ai/shared-dock.md §10) - how tall it stands, what Enter does,
|
|
3
|
+
* and what a pasted table gives back as words. Pure: the chat hands in its measures and the key.
|
|
4
|
+
*
|
|
5
|
+
* - The box grows with its text up to 40% of the chat's height, then scrolls. It never stands under its resting
|
|
6
|
+
* height, and on a chat too short for 40% to hold that height the resting height wins.
|
|
7
|
+
* - The wide box (넓게 쓰기) covers the conversation inside the dock; the page beside it stays where it was.
|
|
8
|
+
* - In the small box Enter sends and Shift+Enter breaks the line, as in every chat. In the wide box Enter breaks
|
|
9
|
+
* the line - long writing is made of lines - and Ctrl/Cmd+Enter sends. Ctrl/Cmd+Enter sends in both.
|
|
10
|
+
*/
|
|
11
|
+
/** The part of the chat's height the small box may take before it scrolls. */
|
|
12
|
+
export const COMPOSER_GROWTH = 0.4;
|
|
13
|
+
/** The box's height for its content, both border-box pixels. */
|
|
14
|
+
export function composerHeight(content, chat, resting) {
|
|
15
|
+
const cap = Math.max(resting, Math.floor(chat * COMPOSER_GROWTH));
|
|
16
|
+
return { height: Math.min(Math.max(content, resting), cap), scrolls: content > cap };
|
|
17
|
+
}
|
|
18
|
+
/** What a key does in the box: send, close the wide box, or nothing of ours (the textarea keeps it). */
|
|
19
|
+
export function composerKeyAction(e, wide) {
|
|
20
|
+
if (e.key === 'Escape')
|
|
21
|
+
return wide ? 'narrow' : null;
|
|
22
|
+
if (e.key !== 'Enter' || e.altKey)
|
|
23
|
+
return null;
|
|
24
|
+
if (e.ctrlKey || e.metaKey)
|
|
25
|
+
return 'send';
|
|
26
|
+
if (wide || e.shiftKey)
|
|
27
|
+
return null;
|
|
28
|
+
return 'send';
|
|
29
|
+
}
|
|
30
|
+
/** The shortcut the wide box's send button names, in the keys of this machine. */
|
|
31
|
+
export function sendShortcut(platform) {
|
|
32
|
+
return /mac|iphone|ipad/i.test(platform ?? '') ? '⌘ Enter' : 'Ctrl+Enter';
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A pasted table given back as words: what the clipboard held as text, or - when it held only HTML - the rows with
|
|
36
|
+
* a tab between cells, as a spreadsheet copies them.
|
|
37
|
+
*/
|
|
38
|
+
export function tableAsWords(table) {
|
|
39
|
+
const text = (table.text ?? '').replace(/\r\n?/g, '\n').replace(/\n+$/, '');
|
|
40
|
+
return text.trim() ? text : table.rows.map(row => row.join('\t')).join('\n');
|
|
41
|
+
}
|
|
42
|
+
/** The words appended to what is already written - on a line of their own. */
|
|
43
|
+
export function appendWords(written, words) {
|
|
44
|
+
return written.trim() ? `${written.replace(/\s+$/, '')}\n${words}` : words;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=composer-frame.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"composer-frame.js","sourceRoot":"","sources":["../../client/utils/composer-frame.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,8EAA8E;AAC9E,MAAM,CAAC,MAAM,eAAe,GAAG,GAAG,CAAA;AAElC,gEAAgE;AAChE,MAAM,UAAU,cAAc,CAAC,OAAe,EAAE,IAAY,EAAE,OAAe;IAC3E,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,eAAe,CAAC,CAAC,CAAA;IACjE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,OAAO,GAAG,GAAG,EAAE,CAAA;AACtF,CAAC;AAID,wGAAwG;AACxG,MAAM,UAAU,iBAAiB,CAAC,CAAc,EAAE,IAAa;IAC7D,IAAI,CAAC,CAAC,GAAG,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAA;IACrD,IAAI,CAAC,CAAC,GAAG,KAAK,OAAO,IAAI,CAAC,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IAC9C,IAAI,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,OAAO;QAAE,OAAO,MAAM,CAAA;IACzC,IAAI,IAAI,IAAI,CAAC,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAA;IACnC,OAAO,MAAM,CAAA;AACf,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,YAAY,CAAC,QAA4B;IACvD,OAAO,kBAAkB,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,YAAY,CAAA;AAC3E,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,KAAiD;IAC5E,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IAC3E,OAAO,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AAC9E,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,WAAW,CAAC,OAAe,EAAE,KAAa;IACxD,OAAO,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAA;AAC5E,CAAC","sourcesContent":["/*\n * The composer's frame (operato-application/design/ai/shared-dock.md §10) - how tall it stands, what Enter does,\n * and what a pasted table gives back as words. Pure: the chat hands in its measures and the key.\n *\n * - The box grows with its text up to 40% of the chat's height, then scrolls. It never stands under its resting\n * height, and on a chat too short for 40% to hold that height the resting height wins.\n * - The wide box (넓게 쓰기) covers the conversation inside the dock; the page beside it stays where it was.\n * - In the small box Enter sends and Shift+Enter breaks the line, as in every chat. In the wide box Enter breaks\n * the line - long writing is made of lines - and Ctrl/Cmd+Enter sends. Ctrl/Cmd+Enter sends in both.\n */\n\n/** The part of the chat's height the small box may take before it scrolls. */\nexport const COMPOSER_GROWTH = 0.4\n\n/** The box's height for its content, both border-box pixels. */\nexport function composerHeight(content: number, chat: number, resting: number): { height: number; scrolls: boolean } {\n const cap = Math.max(resting, Math.floor(chat * COMPOSER_GROWTH))\n return { height: Math.min(Math.max(content, resting), cap), scrolls: content > cap }\n}\n\nexport type ComposerKey = { key: string; shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }\n\n/** What a key does in the box: send, close the wide box, or nothing of ours (the textarea keeps it). */\nexport function composerKeyAction(e: ComposerKey, wide: boolean): 'send' | 'narrow' | null {\n if (e.key === 'Escape') return wide ? 'narrow' : null\n if (e.key !== 'Enter' || e.altKey) return null\n if (e.ctrlKey || e.metaKey) return 'send'\n if (wide || e.shiftKey) return null\n return 'send'\n}\n\n/** The shortcut the wide box's send button names, in the keys of this machine. */\nexport function sendShortcut(platform: string | undefined): string {\n return /mac|iphone|ipad/i.test(platform ?? '') ? '⌘ Enter' : 'Ctrl+Enter'\n}\n\n/**\n * A pasted table given back as words: what the clipboard held as text, or - when it held only HTML - the rows with\n * a tab between cells, as a spreadsheet copies them.\n */\nexport function tableAsWords(table: { rows: string[][]; text?: string | null }): string {\n const text = (table.text ?? '').replace(/\\r\\n?/g, '\\n').replace(/\\n+$/, '')\n return text.trim() ? text : table.rows.map(row => row.join('\\t')).join('\\n')\n}\n\n/** The words appended to what is already written - on a line of their own. */\nexport function appendWords(written: string, words: string): string {\n return written.trim() ? `${written.replace(/\\s+$/, '')}\\n${words}` : words\n}\n"]}
|
|
@@ -145,6 +145,6 @@ export function pastedTableFile(clipboard, policy, nameOf) {
|
|
|
145
145
|
const maxChars = textCharsOf(policy);
|
|
146
146
|
if (text.length > maxChars)
|
|
147
147
|
return { refusal: { code: 'attachment-paste-too-long', rows: table.rows.length, maxChars } };
|
|
148
|
-
return { name: `${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`, text };
|
|
148
|
+
return { name: `${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`, text, table };
|
|
149
149
|
}
|
|
150
150
|
//# sourceMappingURL=pasted-table.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pasted-table.js","sourceRoot":"","sources":["../../client/utils/pasted-table.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * A table pasted into the composer, carried as a file (design/ai/data-entry.md §4-1, design/ai/shared-dock.md §9-5).\n *\n * Rows copied from a spreadsheet or a web page are what an AI 입력 is made from most often. Pasted as words they\n * reach the model as something the person said, and the entry card cannot check a row against them nor read a\n * printed total. Carried as a CSV file they are a source like an attached sheet: each row is found on its line.\n *\n * - The HTML of the clipboard is read first when it holds a table - a spreadsheet and a browser both put one there,\n * and it keeps a cell that holds a line break or a tab whole. A web page whose words lie mostly outside the table\n * is not a table paste; it is left as words.\n * - Otherwise the plain text is a table when its lines are tab-separated (a spreadsheet's own text).\n * - The file has the shape of a read sheet: a line naming it, then every row led by its own number.\n * - The limit is on the text, as for a sheet - a table over it is refused, never cut.\n *\n * Pure: the screen hands in the clipboard's strings.\n */\nimport { textCharsOf, type AttachmentPolicy } from './attachment-policy.js'\nimport { csvField } from './spreadsheet-text.js'\n\nexport interface PastedTable {\n rows: string[][]\n columns: number\n}\n\n/** Words outside the table beyond this are a page that holds a table, not a table. */\nconst WORDS_OUTSIDE_A_TABLE = 20\n\nconst ENTITIES: Record<string, string> = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '\"', apos: \"'\", '#39': \"'\" }\n\nfunction decodeEntities(text: string): string {\n return text.replace(/&(#x[0-9a-f]+|#\\d+|[a-z]+\\d*|#39);/gi, (whole, name: string) => {\n const lower = name.toLowerCase()\n if (lower in ENTITIES) return ENTITIES[lower]\n if (lower.startsWith('#x')) return String.fromCodePoint(parseInt(lower.slice(2), 16))\n if (lower.startsWith('#')) return String.fromCodePoint(parseInt(lower.slice(1), 10))\n return whole\n })\n}\n\n/** What a piece of HTML shows as words - a line break and a cell's own breaks become one space. */\nfunction wordsOf(html: string): string {\n return decodeEntities(html.replace(/<br\\s*\\/?>/gi, ' ').replace(/<[^>]*>/g, ''))\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\n/** What the clipboard's HTML shows, without what is never shown - comments, styles, scripts, the head. */\nfunction shownHtml(html: string): string {\n return html\n .replace(/<!--[\\s\\S]*?-->/g, '')\n .replace(/<(style|script|head)\\b[\\s\\S]*?<\\/\\1\\s*>/gi, '')\n}\n\n/** The rows of the tables in the clipboard's HTML, or null when it holds none or is mostly not a table. */\nexport function tableOfHtml(html: string | null | undefined): string[][] | null {\n if (!html || !/<table\\b/i.test(html)) return null\n const shown = shownHtml(html)\n const outside = wordsOf(shown.replace(/<table\\b[\\s\\S]*?<\\/table\\s*>/gi, ' '))\n if (outside.replace(/\\s/g, '').length > WORDS_OUTSIDE_A_TABLE) return null\n\n const rows: string[][] = []\n for (const part of shown.split(/<tr\\b[^>]*>/i).slice(1)) {\n const body = part.split(/<\\/table\\s*>/i)[0]\n const row: string[] = []\n for (const cell of body.matchAll(/<(td|th)\\b([^>]*)>([\\s\\S]*?)(?=<\\/?(?:td|th|tr)\\b|$)/gi)) {\n row.push(wordsOf(cell[3]))\n const span = Number(/\\bcolspan\\s*=\\s*[\"']?(\\d+)/i.exec(cell[2])?.[1] ?? 1)\n for (let more = 1; more < Math.min(span, 100); more++) row.push('')\n }\n if (row.some(text => text !== '')) rows.push(row)\n }\n return rows.length ? rows : null\n}\n\n/** The rows of a spreadsheet's plain text - tab-separated lines, a cell with a break or a tab in quotes. */\nexport function tableOfText(text: string | null | undefined): string[][] | null {\n if (!text || !text.includes('\\t')) return null\n const rows: string[][] = []\n let row: string[] = []\n let cell = ''\n let quoted = false\n for (let at = 0; at < text.length; at++) {\n const c = text[at]\n if (quoted) {\n if (c === '\"' && text[at + 1] === '\"') (cell += '\"'), at++\n else if (c === '\"') quoted = false\n else cell += c\n } else if (c === '\"' && cell === '') quoted = true\n else if (c === '\\t') row.push(cell), (cell = '')\n else if (c === '\\n' || c === '\\r') {\n if (c === '\\r' && text[at + 1] === '\\n') at++\n row.push(cell), rows.push(row), (row = []), (cell = '')\n } else cell += c\n }\n if (cell !== '' || row.length) row.push(cell), rows.push(row)\n const kept = rows.map(one => one.map(text => text.replace(/\\s+/g, ' ').trim())).filter(one => one.some(text => text !== ''))\n /* One tab in a sentence is not a table - most lines must be split into columns. */\n if (kept.filter(one => one.length > 1).length < Math.max(2, Math.ceil(kept.length / 2))) return null\n return kept\n}\n\n/** The table a paste holds, or null for words - at least two rows of at least two columns. */\nexport function pastedTable(clipboard: { html?: string | null; text?: string | null }): PastedTable | null {\n const fromHtml = tableOfHtml(clipboard.html)\n /* A page the HTML shows to be mostly words stays words - its plain text would read the table out of it anyway. */\n if (!fromHtml && clipboard.html && /<table\\b/i.test(clipboard.html)) return null\n for (const rows of [fromHtml, tableOfText(clipboard.text)]) {\n if (!rows) continue\n const trimmed = rows.map(row => {\n let cells = row\n while (cells.length && cells[cells.length - 1] === '') cells = cells.slice(0, -1)\n return cells\n })\n const columns = Math.max(...trimmed.map(row => row.length))\n if (trimmed.length >= 2 && columns >= 2) return { rows: trimmed, columns }\n }\n return null\n}\n\n/** The table as the text of its file: a line naming it, then every row led by its own number. */\nexport function pastedTableText(table: PastedTable, name: string): string {\n const lines = table.rows.map((row, i) => [String(i + 1), ...row].map(csvField).join(','))\n return [`# ${name} · a table the person pasted · the first column is the row number in the pasted table`, ...lines].join('\\n') + '\\n'\n}\n\nexport type PasteRefusal = { code: 'attachment-paste-too-long'; rows: number; maxChars: number }\n\n/**\n * What a paste becomes in a conversation under this policy: a table file, a refusal, or null - the paste stays words\n * (no table in it, or a conversation that takes no text files).\n */\nexport function pastedTableFile(\n clipboard: { html?: string | null; text?: string | null },\n policy: AttachmentPolicy | null | undefined,\n nameOf: (rows: number) => string\n): { name: string; text: string } | { refusal: PasteRefusal } | null {\n if (!policy?.kinds?.some(kind => kind === 'text' || kind === 'spreadsheet')) return null\n const table = pastedTable(clipboard)\n if (!table) return null\n const name = nameOf(table.rows.length)\n const text = pastedTableText(table, name)\n const maxChars = textCharsOf(policy)\n if (text.length > maxChars) return { refusal: { code: 'attachment-paste-too-long', rows: table.rows.length, maxChars } }\n return { name: `${name.replace(/[\\\\/:*?\"<>|]/g, '_')}.csv`, text }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"pasted-table.js","sourceRoot":"","sources":["../../client/utils/pasted-table.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * A table pasted into the composer, carried as a file (design/ai/data-entry.md §4-1, design/ai/shared-dock.md §9-5).\n *\n * Rows copied from a spreadsheet or a web page are what an AI 입력 is made from most often. Pasted as words they\n * reach the model as something the person said, and the entry card cannot check a row against them nor read a\n * printed total. Carried as a CSV file they are a source like an attached sheet: each row is found on its line.\n *\n * - The HTML of the clipboard is read first when it holds a table - a spreadsheet and a browser both put one there,\n * and it keeps a cell that holds a line break or a tab whole. A web page whose words lie mostly outside the table\n * is not a table paste; it is left as words.\n * - Otherwise the plain text is a table when its lines are tab-separated (a spreadsheet's own text).\n * - The file has the shape of a read sheet: a line naming it, then every row led by its own number.\n * - The limit is on the text, as for a sheet - a table over it is refused, never cut.\n *\n * Pure: the screen hands in the clipboard's strings.\n */\nimport { textCharsOf, type AttachmentPolicy } from './attachment-policy.js'\nimport { csvField } from './spreadsheet-text.js'\n\nexport interface PastedTable {\n rows: string[][]\n columns: number\n}\n\n/** Words outside the table beyond this are a page that holds a table, not a table. */\nconst WORDS_OUTSIDE_A_TABLE = 20\n\nconst ENTITIES: Record<string, string> = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '\"', apos: \"'\", '#39': \"'\" }\n\nfunction decodeEntities(text: string): string {\n return text.replace(/&(#x[0-9a-f]+|#\\d+|[a-z]+\\d*|#39);/gi, (whole, name: string) => {\n const lower = name.toLowerCase()\n if (lower in ENTITIES) return ENTITIES[lower]\n if (lower.startsWith('#x')) return String.fromCodePoint(parseInt(lower.slice(2), 16))\n if (lower.startsWith('#')) return String.fromCodePoint(parseInt(lower.slice(1), 10))\n return whole\n })\n}\n\n/** What a piece of HTML shows as words - a line break and a cell's own breaks become one space. */\nfunction wordsOf(html: string): string {\n return decodeEntities(html.replace(/<br\\s*\\/?>/gi, ' ').replace(/<[^>]*>/g, ''))\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\n/** What the clipboard's HTML shows, without what is never shown - comments, styles, scripts, the head. */\nfunction shownHtml(html: string): string {\n return html\n .replace(/<!--[\\s\\S]*?-->/g, '')\n .replace(/<(style|script|head)\\b[\\s\\S]*?<\\/\\1\\s*>/gi, '')\n}\n\n/** The rows of the tables in the clipboard's HTML, or null when it holds none or is mostly not a table. */\nexport function tableOfHtml(html: string | null | undefined): string[][] | null {\n if (!html || !/<table\\b/i.test(html)) return null\n const shown = shownHtml(html)\n const outside = wordsOf(shown.replace(/<table\\b[\\s\\S]*?<\\/table\\s*>/gi, ' '))\n if (outside.replace(/\\s/g, '').length > WORDS_OUTSIDE_A_TABLE) return null\n\n const rows: string[][] = []\n for (const part of shown.split(/<tr\\b[^>]*>/i).slice(1)) {\n const body = part.split(/<\\/table\\s*>/i)[0]\n const row: string[] = []\n for (const cell of body.matchAll(/<(td|th)\\b([^>]*)>([\\s\\S]*?)(?=<\\/?(?:td|th|tr)\\b|$)/gi)) {\n row.push(wordsOf(cell[3]))\n const span = Number(/\\bcolspan\\s*=\\s*[\"']?(\\d+)/i.exec(cell[2])?.[1] ?? 1)\n for (let more = 1; more < Math.min(span, 100); more++) row.push('')\n }\n if (row.some(text => text !== '')) rows.push(row)\n }\n return rows.length ? rows : null\n}\n\n/** The rows of a spreadsheet's plain text - tab-separated lines, a cell with a break or a tab in quotes. */\nexport function tableOfText(text: string | null | undefined): string[][] | null {\n if (!text || !text.includes('\\t')) return null\n const rows: string[][] = []\n let row: string[] = []\n let cell = ''\n let quoted = false\n for (let at = 0; at < text.length; at++) {\n const c = text[at]\n if (quoted) {\n if (c === '\"' && text[at + 1] === '\"') (cell += '\"'), at++\n else if (c === '\"') quoted = false\n else cell += c\n } else if (c === '\"' && cell === '') quoted = true\n else if (c === '\\t') row.push(cell), (cell = '')\n else if (c === '\\n' || c === '\\r') {\n if (c === '\\r' && text[at + 1] === '\\n') at++\n row.push(cell), rows.push(row), (row = []), (cell = '')\n } else cell += c\n }\n if (cell !== '' || row.length) row.push(cell), rows.push(row)\n const kept = rows.map(one => one.map(text => text.replace(/\\s+/g, ' ').trim())).filter(one => one.some(text => text !== ''))\n /* One tab in a sentence is not a table - most lines must be split into columns. */\n if (kept.filter(one => one.length > 1).length < Math.max(2, Math.ceil(kept.length / 2))) return null\n return kept\n}\n\n/** The table a paste holds, or null for words - at least two rows of at least two columns. */\nexport function pastedTable(clipboard: { html?: string | null; text?: string | null }): PastedTable | null {\n const fromHtml = tableOfHtml(clipboard.html)\n /* A page the HTML shows to be mostly words stays words - its plain text would read the table out of it anyway. */\n if (!fromHtml && clipboard.html && /<table\\b/i.test(clipboard.html)) return null\n for (const rows of [fromHtml, tableOfText(clipboard.text)]) {\n if (!rows) continue\n const trimmed = rows.map(row => {\n let cells = row\n while (cells.length && cells[cells.length - 1] === '') cells = cells.slice(0, -1)\n return cells\n })\n const columns = Math.max(...trimmed.map(row => row.length))\n if (trimmed.length >= 2 && columns >= 2) return { rows: trimmed, columns }\n }\n return null\n}\n\n/** The table as the text of its file: a line naming it, then every row led by its own number. */\nexport function pastedTableText(table: PastedTable, name: string): string {\n const lines = table.rows.map((row, i) => [String(i + 1), ...row].map(csvField).join(','))\n return [`# ${name} · a table the person pasted · the first column is the row number in the pasted table`, ...lines].join('\\n') + '\\n'\n}\n\nexport type PasteRefusal = { code: 'attachment-paste-too-long'; rows: number; maxChars: number }\n\n/**\n * What a paste becomes in a conversation under this policy: a table file, a refusal, or null - the paste stays words\n * (no table in it, or a conversation that takes no text files).\n */\nexport function pastedTableFile(\n clipboard: { html?: string | null; text?: string | null },\n policy: AttachmentPolicy | null | undefined,\n nameOf: (rows: number) => string\n): { name: string; text: string; table: PastedTable } | { refusal: PasteRefusal } | null {\n if (!policy?.kinds?.some(kind => kind === 'text' || kind === 'spreadsheet')) return null\n const table = pastedTable(clipboard)\n if (!table) return null\n const name = nameOf(table.rows.length)\n const text = pastedTableText(table, name)\n const maxChars = textCharsOf(policy)\n if (text.length > maxChars) return { refusal: { code: 'attachment-paste-too-long', rows: table.rows.length, maxChars } }\n return { name: `${name.replace(/[\\\\/:*?\"<>|]/g, '_')}.csv`, text, table }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.110",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -52,5 +52,5 @@
|
|
|
52
52
|
"copyfiles": "^2.4.1",
|
|
53
53
|
"rimraf": "^5.0.0"
|
|
54
54
|
},
|
|
55
|
-
"gitHead": "
|
|
55
|
+
"gitHead": "b68feb6eccbef44af2a6365f384bd852dd70a8f9"
|
|
56
56
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The composer's frame (client/utils/composer-frame.ts, design/ai/shared-dock.md §10): it grows with its text and
|
|
3
|
+
* stops at 40% of the chat, Enter means a line in the wide box, and a pasted table can go back to words.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from 'fs'
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
|
|
8
|
+
import { appendWords, composerHeight, composerKeyAction, sendShortcut, tableAsWords } from '../client/utils/composer-frame'
|
|
9
|
+
|
|
10
|
+
const chat = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
|
|
11
|
+
|
|
12
|
+
describe('the composer grows with its text', () => {
|
|
13
|
+
it('★ one line stands at the resting height', () => {
|
|
14
|
+
expect(composerHeight(35, 714, 54)).toEqual({ height: 54, scrolls: false })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('★ twelve lines show whole - the box was three lines high and hid the rest', () => {
|
|
18
|
+
expect(composerHeight(221, 714, 54)).toEqual({ height: 221, scrolls: false })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('★ past 40% of the chat it stops and scrolls', () => {
|
|
22
|
+
expect(composerHeight(900, 714, 54)).toEqual({ height: 285, scrolls: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('a chat too short for 40% to hold the resting height keeps the resting height', () => {
|
|
26
|
+
expect(composerHeight(200, 100, 54)).toEqual({ height: 54, scrolls: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('★ the chat sizes the box from the rule, and the dead 140px ceiling is gone', () => {
|
|
30
|
+
expect(chat).toMatch(/composerHeight\(/)
|
|
31
|
+
expect(chat).not.toMatch(/max-height: 140px/)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('Enter', () => {
|
|
36
|
+
const enter = { key: 'Enter' }
|
|
37
|
+
|
|
38
|
+
it('★ in the small box Enter sends and Shift+Enter breaks the line', () => {
|
|
39
|
+
expect(composerKeyAction(enter, false)).toBe('send')
|
|
40
|
+
expect(composerKeyAction({ ...enter, shiftKey: true }, false)).toBeNull()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('★ in the wide box Enter breaks the line', () => {
|
|
44
|
+
expect(composerKeyAction(enter, true)).toBeNull()
|
|
45
|
+
expect(composerKeyAction({ ...enter, shiftKey: true }, true)).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('★ Ctrl/Cmd+Enter sends in both boxes', () => {
|
|
49
|
+
for (const wide of [false, true]) {
|
|
50
|
+
expect(composerKeyAction({ ...enter, ctrlKey: true }, wide)).toBe('send')
|
|
51
|
+
expect(composerKeyAction({ ...enter, metaKey: true }, wide)).toBe('send')
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('Escape closes the wide box and leaves the small one alone', () => {
|
|
56
|
+
expect(composerKeyAction({ key: 'Escape' }, true)).toBe('narrow')
|
|
57
|
+
expect(composerKeyAction({ key: 'Escape' }, false)).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('other keys are the textarea\'s', () => {
|
|
61
|
+
expect(composerKeyAction({ key: 'a' }, true)).toBeNull()
|
|
62
|
+
expect(composerKeyAction({ ...enter, altKey: true }, false)).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('the shortcut is named in this machine\'s keys', () => {
|
|
66
|
+
expect(sendShortcut('MacIntel')).toBe('⌘ Enter')
|
|
67
|
+
expect(sendShortcut('Win32')).toBe('Ctrl+Enter')
|
|
68
|
+
expect(sendShortcut(undefined)).toBe('Ctrl+Enter')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('★ the chat decides the key by the rule', () => {
|
|
72
|
+
expect(chat).toMatch(/composerKeyAction\(e, this\.composerWide\)/)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('a pasted table back as words', () => {
|
|
77
|
+
const rows = [
|
|
78
|
+
['이용일', '가맹점'],
|
|
79
|
+
['2026-09-12', '카카오T']
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
it('★ the clipboard\'s own text comes back as it was copied', () => {
|
|
83
|
+
expect(tableAsWords({ rows, text: '이용일\t가맹점\r\n2026-09-12\t카카오T\r\n' })).toBe('이용일\t가맹점\n2026-09-12\t카카오T')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('★ a clipboard that held only HTML gives the rows tab-separated', () => {
|
|
87
|
+
expect(tableAsWords({ rows, text: '' })).toBe('이용일\t가맹점\n2026-09-12\t카카오T')
|
|
88
|
+
expect(tableAsWords({ rows })).toBe('이용일\t가맹점\n2026-09-12\t카카오T')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('the words go on a line of their own after what is written', () => {
|
|
92
|
+
expect(appendWords('', 'a\tb')).toBe('a\tb')
|
|
93
|
+
expect(appendWords('경비로 올려 줘 \n', 'a\tb')).toBe('경비로 올려 줘\na\tb')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('the pasted table chip', () => {
|
|
98
|
+
it('★ the paste hands its rows and its words to the chip', () => {
|
|
99
|
+
expect(chat).toMatch(/onAttachFiles\(\[new File\(\[pasted\.text\], pasted\.name, \{ type: 'text\/csv' \}\)\], \{ \.\.\.pasted\.table, text: words \}\)/)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('★ the chip names rows × columns, opens the rows, and gives them back as words', () => {
|
|
103
|
+
const chip = chat.slice(chat.indexOf('${att.table'), chat.indexOf('formatFileSize(att.size)'))
|
|
104
|
+
expect(chip).toMatch(/table_chart/)
|
|
105
|
+
expect(chip).toMatch(/this\.tableShape\(att\.table\)/)
|
|
106
|
+
expect(chip).toMatch(/this\.previewAttachmentId = /)
|
|
107
|
+
expect(chat).toMatch(/@click=\$\{\(\) => this\.tableToWords\(att\.id\)\}/)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The entry card's way to the workspace review is styled where it stands - its class once borrowed the follow-up
|
|
3
|
+
* chips' rule, which only applies inside `.proposal-choices`, and the button fell back to the browser's own look.
|
|
4
|
+
* The chat's wiring is read as text: importing it pulls the shell.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
|
|
9
|
+
const source = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
|
|
10
|
+
|
|
11
|
+
describe('the entry card', () => {
|
|
12
|
+
it('★ the workspace review button has a rule of its own, not one scoped to the follow-up chips', () => {
|
|
13
|
+
const button = source.slice(source.indexOf('offersWorkspaceReview(p)'), source.indexOf('review-in-workspace'))
|
|
14
|
+
const name = /class="([\w-]+)"/.exec(button)?.[1]
|
|
15
|
+
expect(name).toBe('workspace-review')
|
|
16
|
+
expect(source).toMatch(new RegExp(`\\n \\.${name} \\{`))
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -118,7 +118,7 @@ describe('a pasted table', () => {
|
|
|
118
118
|
const nameOf = (rows: number) => `붙여넣은 표 ${rows}행`
|
|
119
119
|
|
|
120
120
|
it('★ a conversation that takes text gets a CSV file - one that takes none keeps the paste as words', () => {
|
|
121
|
-
expect(pastedTableFile({ text: EXCEL_TEXT }, { kinds: ['image', 'text'] }, nameOf)).toEqual({ name: '붙여넣은 표 4행.csv', text: pastedTableText(pastedTable({ text: EXCEL_TEXT })!, '붙여넣은 표 4행') })
|
|
121
|
+
expect(pastedTableFile({ text: EXCEL_TEXT }, { kinds: ['image', 'text'] }, nameOf)).toEqual({ name: '붙여넣은 표 4행.csv', text: pastedTableText(pastedTable({ text: EXCEL_TEXT })!, '붙여넣은 표 4행'), table: pastedTable({ text: EXCEL_TEXT }) })
|
|
122
122
|
expect(pastedTableFile({ text: EXCEL_TEXT }, { kinds: ['spreadsheet'] }, nameOf)).toMatchObject({ name: '붙여넣은 표 4행.csv' })
|
|
123
123
|
expect(pastedTableFile({ text: EXCEL_TEXT }, { kinds: ['image'] }, nameOf)).toBeNull()
|
|
124
124
|
expect(pastedTableFile({ text: EXCEL_TEXT }, null, nameOf)).toBeNull()
|
|
@@ -136,5 +136,12 @@ describe('a pasted table', () => {
|
|
|
136
136
|
expect(paste.indexOf('pastedTableFile(')).toBeLessThan(paste.indexOf("item.kind === 'file'"))
|
|
137
137
|
expect(paste).toMatch(/e\.preventDefault\(\)[\s\S]*?return\n/)
|
|
138
138
|
})
|
|
139
|
+
|
|
140
|
+
it('★ the Enter that ends a syllable belongs to the input method - the composer does not send on it', () => {
|
|
141
|
+
const source = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
|
|
142
|
+
const keys = source.slice(source.indexOf('private onKeyDown('), source.indexOf('this.send()', source.indexOf('private onKeyDown(')))
|
|
143
|
+
expect(keys).toMatch(/if \(e\.isComposing \|\| e\.keyCode === 229\) return/)
|
|
144
|
+
expect(keys.indexOf('e.isComposing')).toBeLessThan(keys.indexOf("e.key === 'Enter'"))
|
|
145
|
+
})
|
|
139
146
|
})
|
|
140
147
|
|
package/translations/en.json
CHANGED
|
@@ -289,5 +289,11 @@
|
|
|
289
289
|
"ai-assistant.entry.review-stale": "this proposal changed after it was reviewed in the workspace - review it again before entering.",
|
|
290
290
|
"ai-assistant.workspace.entry-not-found": "there is no proposal here that you can see.",
|
|
291
291
|
"ai-assistant.workspace.entry-superseded": "a newer card replaced this one in the conversation - review that one.",
|
|
292
|
-
"ai-assistant.workspace.entry-press-in-dock": "this is a review. Enter the rows with the button on the card in the AI dock."
|
|
292
|
+
"ai-assistant.workspace.entry-press-in-dock": "this is a review. Enter the rows with the button on the card in the AI dock.",
|
|
293
|
+
"ai-assistant.button.write-wide": "Write wide",
|
|
294
|
+
"ai-assistant.button.write-narrow": "Narrow",
|
|
295
|
+
"ai-assistant.text.send-shortcut": "{key} to send",
|
|
296
|
+
"ai-assistant.text.send-tooltip-wide": "Send ({key})",
|
|
297
|
+
"ai-assistant.text.table-shape": "{rows} rows × {columns} columns",
|
|
298
|
+
"ai-assistant.button.table-to-words": "Put in as words"
|
|
293
299
|
}
|
package/translations/ja.json
CHANGED
|
@@ -289,5 +289,11 @@
|
|
|
289
289
|
"ai-assistant.entry.review-stale": "作業ボードで確認した後に入力案が変わりました。もう一度確認してから入力してください。",
|
|
290
290
|
"ai-assistant.workspace.entry-not-found": "表示できる入力案はありません。",
|
|
291
291
|
"ai-assistant.workspace.entry-superseded": "会話の中で新しいカードがこのカードに置き換わりました。そちらを確認してください。",
|
|
292
|
-
"ai-assistant.workspace.entry-press-in-dock": "これは確認画面です。入力は AI ドックのカードのボタンで行います。"
|
|
292
|
+
"ai-assistant.workspace.entry-press-in-dock": "これは確認画面です。入力は AI ドックのカードのボタンで行います。",
|
|
293
|
+
"ai-assistant.button.write-wide": "広く書く",
|
|
294
|
+
"ai-assistant.button.write-narrow": "縮める",
|
|
295
|
+
"ai-assistant.text.send-shortcut": "{key} で送信",
|
|
296
|
+
"ai-assistant.text.send-tooltip-wide": "送信 ({key})",
|
|
297
|
+
"ai-assistant.text.table-shape": "{rows}行 × {columns}列",
|
|
298
|
+
"ai-assistant.button.table-to-words": "文字で入れる"
|
|
293
299
|
}
|
package/translations/ko.json
CHANGED
|
@@ -289,5 +289,11 @@
|
|
|
289
289
|
"ai-assistant.entry.review-stale": "작업판에서 검토한 뒤 입력안이 바뀌었습니다. 다시 검토한 뒤 입력하십시오.",
|
|
290
290
|
"ai-assistant.workspace.entry-not-found": "볼 수 있는 입력안이 없습니다.",
|
|
291
291
|
"ai-assistant.workspace.entry-superseded": "대화에서 더 새로운 카드가 이 카드를 대신했습니다. 그 카드를 검토하십시오.",
|
|
292
|
-
"ai-assistant.workspace.entry-press-in-dock": "검토 화면입니다. 입력은 AI 도크의 카드에 있는 단추로 합니다."
|
|
292
|
+
"ai-assistant.workspace.entry-press-in-dock": "검토 화면입니다. 입력은 AI 도크의 카드에 있는 단추로 합니다.",
|
|
293
|
+
"ai-assistant.button.write-wide": "넓게 쓰기",
|
|
294
|
+
"ai-assistant.button.write-narrow": "줄이기",
|
|
295
|
+
"ai-assistant.text.send-shortcut": "{key} 보내기",
|
|
296
|
+
"ai-assistant.text.send-tooltip-wide": "전송 ({key})",
|
|
297
|
+
"ai-assistant.text.table-shape": "{rows}행 × {columns}열",
|
|
298
|
+
"ai-assistant.button.table-to-words": "글로 넣기"
|
|
293
299
|
}
|
package/translations/ms.json
CHANGED
|
@@ -289,5 +289,11 @@
|
|
|
289
289
|
"ai-assistant.entry.review-stale": "cadangan ini berubah selepas disemak di papan kerja - semak semula sebelum memasukkan.",
|
|
290
290
|
"ai-assistant.workspace.entry-not-found": "tiada cadangan di sini yang boleh anda lihat.",
|
|
291
291
|
"ai-assistant.workspace.entry-superseded": "kad yang lebih baharu telah menggantikan kad ini dalam perbualan - semak kad itu.",
|
|
292
|
-
"ai-assistant.workspace.entry-press-in-dock": "ini ialah semakan. Masukkan baris dengan butang pada kad di dok AI."
|
|
292
|
+
"ai-assistant.workspace.entry-press-in-dock": "ini ialah semakan. Masukkan baris dengan butang pada kad di dok AI.",
|
|
293
|
+
"ai-assistant.button.write-wide": "Tulis luas",
|
|
294
|
+
"ai-assistant.button.write-narrow": "Kecilkan",
|
|
295
|
+
"ai-assistant.text.send-shortcut": "{key} untuk hantar",
|
|
296
|
+
"ai-assistant.text.send-tooltip-wide": "Hantar ({key})",
|
|
297
|
+
"ai-assistant.text.table-shape": "{rows} baris × {columns} lajur",
|
|
298
|
+
"ai-assistant.button.table-to-words": "Masukkan sebagai teks"
|
|
293
299
|
}
|
package/translations/zh.json
CHANGED
|
@@ -289,5 +289,11 @@
|
|
|
289
289
|
"ai-assistant.entry.review-stale": "在工作板审阅后输入方案已变更。请重新审阅后再输入。",
|
|
290
290
|
"ai-assistant.workspace.entry-not-found": "这里没有您可查看的输入方案。",
|
|
291
291
|
"ai-assistant.workspace.entry-superseded": "对话中较新的卡片已取代此卡片,请审阅那一张。",
|
|
292
|
-
"ai-assistant.workspace.entry-press-in-dock": "这是审阅页面。请用 AI 停靠栏卡片上的按钮输入。"
|
|
292
|
+
"ai-assistant.workspace.entry-press-in-dock": "这是审阅页面。请用 AI 停靠栏卡片上的按钮输入。",
|
|
293
|
+
"ai-assistant.button.write-wide": "展开书写",
|
|
294
|
+
"ai-assistant.button.write-narrow": "收起",
|
|
295
|
+
"ai-assistant.text.send-shortcut": "{key} 发送",
|
|
296
|
+
"ai-assistant.text.send-tooltip-wide": "发送 ({key})",
|
|
297
|
+
"ai-assistant.text.table-shape": "{rows}行 × {columns}列",
|
|
298
|
+
"ai-assistant.button.table-to-words": "作为文字放入"
|
|
293
299
|
}
|