@things-factory/ai-assistant 10.1.226 → 10.1.227
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 +50 -2
- package/client/components/assistant-dock.ts +1 -1
- package/client/components/assistant-mode.ts +1 -1
- package/client/components/entry-review.ts +2 -2
- package/client/components/workspace-view.ts +1 -1
- package/client/index.ts +1 -1
- package/client/pages/assistant-workspace-page.ts +1 -1
- package/client/route.ts +1 -1
- package/client/utils/attachment-policy.ts +15 -8
- package/client/utils/document-text.ts +332 -0
- package/client/utils/entry-hash.ts +1 -1
- package/client/utils/entry-proposal.ts +1 -1
- package/client/utils/entry-workspace.ts +1 -1
- package/client/utils/pdf-pages.ts +38 -0
- package/client/utils/workspace.ts +1 -1
- package/dist-client/components/assistant-chat.d.ts +3 -1
- package/dist-client/components/assistant-chat.js +48 -2
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.js +1 -1
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +1 -1
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/entry-review.d.ts +1 -1
- package/dist-client/components/entry-review.js +2 -2
- package/dist-client/components/entry-review.js.map +1 -1
- package/dist-client/components/workspace-view.js +1 -1
- package/dist-client/components/workspace-view.js.map +1 -1
- package/dist-client/index.js +1 -1
- package/dist-client/index.js.map +1 -1
- package/dist-client/pages/assistant-workspace-page.js +1 -1
- package/dist-client/pages/assistant-workspace-page.js.map +1 -1
- package/dist-client/route.js +1 -1
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/attachment-policy.d.ts +2 -2
- package/dist-client/utils/attachment-policy.js +9 -5
- package/dist-client/utils/attachment-policy.js.map +1 -1
- package/dist-client/utils/document-text.d.ts +118 -0
- package/dist-client/utils/document-text.js +320 -0
- package/dist-client/utils/document-text.js.map +1 -0
- package/dist-client/utils/entry-hash.js +1 -1
- package/dist-client/utils/entry-hash.js.map +1 -1
- package/dist-client/utils/entry-proposal.js +1 -1
- package/dist-client/utils/entry-proposal.js.map +1 -1
- package/dist-client/utils/entry-workspace.js +1 -1
- package/dist-client/utils/entry-workspace.js.map +1 -1
- package/dist-client/utils/pdf-pages.d.ts +6 -0
- package/dist-client/utils/pdf-pages.js +34 -0
- package/dist-client/utils/pdf-pages.js.map +1 -0
- package/dist-client/utils/workspace.js +1 -1
- package/dist-client/utils/workspace.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/assistant-mode/mode-categories.d.ts +3 -3
- package/dist-server/service/assistant-mode/mode-categories.js +4 -4
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/chat-session/attachment-policy.d.ts +1 -1
- package/dist-server/service/chat-session/attachment-policy.js +2 -2
- package/dist-server/service/chat-session/attachment-policy.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.d.ts +1 -1
- package/dist-server/service/data-entry/entry-apply.js +1 -1
- package/dist-server/service/data-entry/entry-apply.js.map +1 -1
- package/dist-server/service/data-entry/entry-hash.js +1 -1
- package/dist-server/service/data-entry/entry-hash.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +1 -1
- package/dist-server/service/data-read/read-target.js +2 -2
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.js +5 -5
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/service/index.js +1 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/people/index.js +1 -1
- package/dist-server/service/people/index.js.map +1 -1
- package/dist-server/service/workspace/workspace-blocks.js +1 -1
- package/dist-server/service/workspace/workspace-blocks.js.map +1 -1
- package/dist-server/service/workspace/workspace-tools.js +1 -1
- package/dist-server/service/workspace/workspace-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +11 -9
- package/server/service/assistant-mode/assistant-mode-resolver.ts +1 -1
- package/server/service/assistant-mode/mode-categories.ts +4 -4
- package/server/service/assistant-mode/mode-front.test.ts +1 -1
- package/server/service/assistant-mode/resolve.test.ts +1 -1
- package/server/service/chat-session/attachment-policy.ts +7 -5
- package/server/service/data-entry/entry-apply.ts +2 -2
- package/server/service/data-entry/entry-hash.ts +1 -1
- package/server/service/data-read/read-target.ts +2 -2
- package/server/service/data-read/read-tools.ts +5 -5
- package/server/service/data-read/read-words.test.ts +1 -1
- package/server/service/index.ts +1 -1
- package/server/service/people/index.ts +1 -1
- package/server/service/unattended-run/run-unattended.test.ts +1 -1
- package/server/service/workspace/workspace-blocks.ts +1 -1
- package/server/service/workspace/workspace-tools.ts +1 -1
- package/server/service/workspace/workspace.test.ts +1 -1
- package/test/attachment-policy-agree.test.ts +4 -1
- package/test/document-text.test.ts +175 -0
- package/test/entry-workspace.test.ts +1 -1
- package/test/workspace.test.ts +1 -1
- package/translations/en.json +6 -0
- package/translations/es.json +6 -0
- package/translations/ja.json +6 -0
- package/translations/ko.json +16 -10
- package/translations/ms.json +6 -0
- package/translations/zh.json +6 -0
|
@@ -39,12 +39,14 @@ import {
|
|
|
39
39
|
injectMentionUserIds,
|
|
40
40
|
stripMentionRefids
|
|
41
41
|
} from './markdown.js'
|
|
42
|
-
import { acceptOf, chatAttachmentUrl, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js'
|
|
42
|
+
import { acceptOf, chatAttachmentUrl, limitsOf, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js'
|
|
43
43
|
import { fileUploadLimits } from '@operato/utils/file-acceptance.js'
|
|
44
44
|
import '@operato/input/ox-input-file.js'
|
|
45
45
|
import { pastedTableFile } from '../utils/pasted-table.js'
|
|
46
46
|
import { appendWords, composerHeight, composerKeyAction, composerSuggestion, sendShortcut, tableAsWords } from '../utils/composer-frame.js'
|
|
47
47
|
import { readSpreadsheet, spreadsheetKindOf, type SpreadsheetRefusal } from '../utils/spreadsheet-text.js'
|
|
48
|
+
import { documentKindOf, readDocx, readPdf, readPptx, type DocumentRefusal } from '../utils/document-text.js'
|
|
49
|
+
import { drawPdfPage, loadPdfjs } from '../utils/pdf-pages.js'
|
|
48
50
|
import { entryReviewAddress, offersWorkspaceReview } from '../utils/entry-workspace.js'
|
|
49
51
|
import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js'
|
|
50
52
|
import { agentDetail, offersAgentPicker, pickAgentId, rememberAgent, rememberedAgent, type AgentChoice } from '../utils/agent-choice.js'
|
|
@@ -703,6 +705,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
703
705
|
}
|
|
704
706
|
list = read.files
|
|
705
707
|
}
|
|
708
|
+
if (this.usesAttachmentPolicy && this.modeView?.attachments?.kinds?.includes('document')) {
|
|
709
|
+
/* A PDF, .docx or .pptx travels as its text with its page and slide numbers, a scanned page as an image (§9-6). */
|
|
710
|
+
const read = await this.readDocuments(list)
|
|
711
|
+
if ('refusal' in read) {
|
|
712
|
+
this.errorMessage = String(i18next.t(`ai-assistant.text.${read.refusal.code}`, { ...(read.refusal as any), defaultValue: read.refusal.code }))
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
list = read.files
|
|
716
|
+
}
|
|
706
717
|
if (this.usesAttachmentPolicy) {
|
|
707
718
|
/* Said before anything is sent - never cut silently (§9-1). The one picking rule, the server's upload limit included. */
|
|
708
719
|
const refusal = refuseAttachments(
|
|
@@ -763,6 +774,43 @@ export class OxAssistantChat extends LitElement {
|
|
|
763
774
|
return { files: out }
|
|
764
775
|
}
|
|
765
776
|
|
|
777
|
+
/** Each document replaced by its text, and a PDF's scanned pages by their images; any other file passes as it is. */
|
|
778
|
+
private async readDocuments(list: File[]): Promise<{ files: File[] } | { refusal: DocumentRefusal }> {
|
|
779
|
+
const policy = this.modeView?.attachments
|
|
780
|
+
const maxChars = textCharsOf(policy)
|
|
781
|
+
const out: File[] = []
|
|
782
|
+
for (const file of list) {
|
|
783
|
+
const kind = documentKindOf(file)
|
|
784
|
+
if (!kind) {
|
|
785
|
+
out.push(file)
|
|
786
|
+
continue
|
|
787
|
+
}
|
|
788
|
+
if (kind === 'doc' || kind === 'ppt') return { refusal: { code: 'attachment-old-office-not-read', name: file.name } }
|
|
789
|
+
const data = await file.arrayBuffer()
|
|
790
|
+
let read
|
|
791
|
+
if (kind === 'pdf') {
|
|
792
|
+
const pdfjs = await loadPdfjs()
|
|
793
|
+
let pdf: any
|
|
794
|
+
read = await readPdf(
|
|
795
|
+
async () => (pdf = await pdfjs.getDocument({ data }).promise),
|
|
796
|
+
file.name,
|
|
797
|
+
maxChars,
|
|
798
|
+
/* The text travels as one file; the scans may take the rest of what one message carries. */
|
|
799
|
+
{ allowed: !!policy?.kinds?.includes('image'), max: Math.max(0, limitsOf(policy).maxFiles! - 1) },
|
|
800
|
+
number => drawPdfPage(pdf, number)
|
|
801
|
+
)
|
|
802
|
+
} else {
|
|
803
|
+
const mod: any = await import('jszip')
|
|
804
|
+
const zip = mod.default ?? mod
|
|
805
|
+
read = kind === 'docx' ? await readDocx(data, file.name, maxChars, zip) : await readPptx(data, file.name, maxChars, zip)
|
|
806
|
+
}
|
|
807
|
+
if ('refusal' in read) return { refusal: read.refusal }
|
|
808
|
+
out.push(new File([read.files.text.text], read.files.text.name, { type: 'text/plain' }))
|
|
809
|
+
for (const image of read.files.images) out.push(new File([image.blob], image.name, { type: 'image/jpeg' }))
|
|
810
|
+
}
|
|
811
|
+
return { files: out }
|
|
812
|
+
}
|
|
813
|
+
|
|
766
814
|
private readFileAsBase64(file: File): Promise<string> {
|
|
767
815
|
return new Promise((resolve, reject) => {
|
|
768
816
|
const reader = new FileReader()
|
|
@@ -4690,7 +4738,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
4690
4738
|
`
|
|
4691
4739
|
}
|
|
4692
4740
|
|
|
4693
|
-
/** The AI
|
|
4741
|
+
/** The AI 작업 패널 card - what it holds, which version, and the way to open it. The person opens it; the model does not. */
|
|
4694
4742
|
private renderWorkspaceCard(p: any, key: string) {
|
|
4695
4743
|
const { version, of } = versionOfCard(this.lines.map(one => one.proposals ?? []), p.workspace.name, key)
|
|
4696
4744
|
const contents = Object.entries(p.contents ?? {})
|
|
@@ -331,7 +331,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
331
331
|
private onStartNewSession = () => void this.newConversation()
|
|
332
332
|
|
|
333
333
|
|
|
334
|
-
/* A card asks for a page of this application - the AI
|
|
334
|
+
/* A card asks for a page of this application - the AI 작업 패널. The dock stays open beside it. */
|
|
335
335
|
private onOpenPage = (event: CustomEvent) => {
|
|
336
336
|
const href = event.detail?.href
|
|
337
337
|
if (typeof href === 'string' && href) navigate(href)
|
|
@@ -54,7 +54,7 @@ export interface AssistantModeView {
|
|
|
54
54
|
footerNotice?: string
|
|
55
55
|
stagedNotice?: string
|
|
56
56
|
/** What this conversation accepts as attachments; absent - none (design/ai/shared-dock.md §9-1). */
|
|
57
|
-
attachments?: { kinds: ('image' | 'text' | 'spreadsheet')[]; maxBytes?: number; maxFiles?: number; maxChars?: number }
|
|
57
|
+
attachments?: { kinds: ('image' | 'text' | 'spreadsheet' | 'document')[]; maxBytes?: number; maxFiles?: number; maxChars?: number }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/** True when these two describe different conversations, so the old one should be closed. */
|
|
@@ -178,7 +178,7 @@ export class AssistantEntryReview extends LitElement {
|
|
|
178
178
|
color: var(--md-sys-color-primary);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
/* In the AI
|
|
181
|
+
/* In the AI 작업 패널: every row, the page's height, the reading size of a page (design/ai/workspace.md §6-1). */
|
|
182
182
|
:host([wide]) {
|
|
183
183
|
font-size: 13px;
|
|
184
184
|
}
|
|
@@ -205,7 +205,7 @@ export class AssistantEntryReview extends LitElement {
|
|
|
205
205
|
]
|
|
206
206
|
|
|
207
207
|
@property({ attribute: false }) proposal: any
|
|
208
|
-
/** The
|
|
208
|
+
/** The 작업 패널's review - every row is drawn, not the first fifty. */
|
|
209
209
|
@property({ type: Boolean, reflect: true }) wide = false
|
|
210
210
|
|
|
211
211
|
/** The sum of each totalled field, beside the total the material prints - a missing row shows here without reading every row. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The blocks of an AI
|
|
2
|
+
* The blocks of an AI 작업 패널, drawn (operato-application/design/ai/workspace.md §3).
|
|
3
3
|
*
|
|
4
4
|
* Every kind is drawn here - the model writes no HTML. Charts use the eight validated categorical colours in
|
|
5
5
|
* a fixed order (dataviz reference palette, light and dark each checked); three of the light ones sit under
|
package/client/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ export * from './components/assistant-dock.js'
|
|
|
20
20
|
export * from './setup-assistant-dock.js'
|
|
21
21
|
export * from './utils/assistant-surface.js'
|
|
22
22
|
export * from './utils/assistant-proposals.js'
|
|
23
|
-
/* The AI
|
|
23
|
+
/* The AI 작업 패널's blocks, drawn - also a dashboard card's (ADR-0160 decision 5). */
|
|
24
24
|
export * from './components/workspace-view.js'
|
|
25
25
|
export * from './utils/assistant-ask.js'
|
|
26
26
|
export * from './utils/assistant-selection.js'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The AI
|
|
2
|
+
* The AI 작업 패널 page (operato-application/design/ai/workspace.md).
|
|
3
3
|
*
|
|
4
4
|
* `assistant-workspace?session=<id>&name=<name>[&version=<n>]`, or `?session=<id>&entry=<proposal key>` to review a
|
|
5
5
|
* 입력안 wide (§6-1) - read only; the card in the dock is where it is entered. It reads the conversation through its own
|
package/client/route.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* The pages ai-assistant brings to every application that installs it. */
|
|
2
2
|
export default function route(page: string) {
|
|
3
3
|
switch (page) {
|
|
4
|
-
/* The AI
|
|
4
|
+
/* The AI 작업 패널 - results too large for the dock (design/ai/workspace.md). */
|
|
5
5
|
case 'assistant-workspace':
|
|
6
6
|
import('./pages/assistant-workspace-page.js')
|
|
7
7
|
return page
|
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { narrowLimits, sortPickedFiles, type FileUploadLimits } from '@operato/utils/file-acceptance.js'
|
|
13
13
|
|
|
14
|
+
import { DOCUMENT_ACCEPT } from './document-text.js'
|
|
15
|
+
|
|
14
16
|
/*
|
|
15
|
-
* 'spreadsheet'
|
|
16
|
-
*
|
|
17
|
+
* 'spreadsheet' and 'document' are kinds a mode opens, never files the door receives: the screen reads each .xlsx
|
|
18
|
+
* sheet into CSV text (spreadsheet-text.ts), and each PDF, .docx and .pptx into text (document-text.ts) before
|
|
19
|
+
* sending, so a mode that takes them takes that text - and a scanned PDF page as an image.
|
|
17
20
|
*/
|
|
18
|
-
export type AttachmentKind = 'image' | 'text' | 'spreadsheet'
|
|
21
|
+
export type AttachmentKind = 'image' | 'text' | 'spreadsheet' | 'document'
|
|
19
22
|
|
|
20
23
|
export interface AttachmentPolicy {
|
|
21
24
|
kinds: AttachmentKind[]
|
|
@@ -38,9 +41,13 @@ const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.s
|
|
|
38
41
|
const KIND_ACCEPT: Record<AttachmentKind, string[]> = {
|
|
39
42
|
image: ['image/*'],
|
|
40
43
|
text: [...TEXT_EXTENSIONS, ...TEXT_TYPES],
|
|
41
|
-
spreadsheet: ['.xlsx', XLSX_TYPE]
|
|
44
|
+
spreadsheet: ['.xlsx', XLSX_TYPE],
|
|
45
|
+
document: DOCUMENT_ACCEPT
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/** The kinds that are read into text on the screen - they arrive as text. */
|
|
49
|
+
const READ_INTO_TEXT: AttachmentKind[] = ['spreadsheet', 'document']
|
|
50
|
+
|
|
44
51
|
/** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */
|
|
45
52
|
const acceptFor = (kinds: AttachmentKind[]) => [...new Set(kinds.flatMap(kind => KIND_ACCEPT[kind]))].join(',')
|
|
46
53
|
|
|
@@ -73,8 +80,8 @@ export function refuseAttachments(
|
|
|
73
80
|
if (!files.length) return null
|
|
74
81
|
if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }
|
|
75
82
|
|
|
76
|
-
/* What is sent: a spreadsheet
|
|
77
|
-
const sent = policy.kinds.map(kind => (kind
|
|
83
|
+
/* What is sent: a spreadsheet or a document arrives as its text. */
|
|
84
|
+
const sent = policy.kinds.map(kind => (READ_INTO_TEXT.includes(kind) ? 'text' : kind))
|
|
78
85
|
const limits = narrowLimits(limitsOf(policy), server)
|
|
79
86
|
const { refused } = sortPickedFiles(files, { accept: acceptFor(sent), limits })
|
|
80
87
|
if (!refused.length) return null
|
|
@@ -86,10 +93,10 @@ export function refuseAttachments(
|
|
|
86
93
|
: { code: 'attachment-too-large', name: first.file.name || '', maxBytes: limits.maxFileSize! }
|
|
87
94
|
}
|
|
88
95
|
|
|
89
|
-
/** The file picker's `accept` for a policy - a spreadsheet as the person picks it (.xlsx). */
|
|
96
|
+
/** The file picker's `accept` for a policy - a spreadsheet or a document as the person picks it (.xlsx, .pdf, ...). */
|
|
90
97
|
export function acceptOf(policy: AttachmentPolicy | null | undefined): string {
|
|
91
98
|
const kinds = policy?.kinds ?? []
|
|
92
|
-
return acceptFor(kinds.includes(
|
|
99
|
+
return acceptFor(kinds.some(kind => READ_INTO_TEXT.includes(kind)) ? [...kinds, 'text'] : kinds)
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
/** Where a conversation's file opens - the gated route, under the page's domain path. */
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A document read into text the conversation can carry (design/ai/shared-dock.md §9-6) - PDF, Word (.docx) and
|
|
3
|
+
* PowerPoint (.pptx), as a spreadsheet is (spreadsheet-text.ts): the door takes images and text only, so a mode that
|
|
4
|
+
* opens 'document' has the screen read the file before sending (2026-10-28, the user: 「pdf, word, ppt 파일은 처리할
|
|
5
|
+
* 수 없는가?」).
|
|
6
|
+
*
|
|
7
|
+
* - Every page and slide is marked with its own number, so an answer can say "page 3" or "slide 5" and be checked.
|
|
8
|
+
* - A PDF page with no text on it is a scan: it is drawn into an image and sent beside the text, named by its page.
|
|
9
|
+
* - The old binary .doc and .ppt are refused by name, as .xls is - nothing here opens them.
|
|
10
|
+
* - The limit is on the text after reading. A document over it is refused, never cut: a cut contract is read as the
|
|
11
|
+
* whole contract.
|
|
12
|
+
* - Word keeps no pages (they are laid out when printed), so a .docx is read in order with its headings marked.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type DocumentKind = 'pdf' | 'docx' | 'pptx' | 'doc' | 'ppt'
|
|
16
|
+
|
|
17
|
+
const PDF_TYPE = 'application/pdf'
|
|
18
|
+
const DOCX_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
19
|
+
const PPTX_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
20
|
+
const DOC_TYPE = 'application/msword'
|
|
21
|
+
const PPT_TYPE = 'application/vnd.ms-powerpoint'
|
|
22
|
+
|
|
23
|
+
/** The picker's `accept` for documents. */
|
|
24
|
+
export const DOCUMENT_ACCEPT = ['.pdf', PDF_TYPE, '.docx', DOCX_TYPE, '.pptx', PPTX_TYPE]
|
|
25
|
+
|
|
26
|
+
/** A document by its type, or by its name when the browser did not know the type. */
|
|
27
|
+
export function documentKindOf(file: { type?: string; name?: string }): DocumentKind | null {
|
|
28
|
+
const type = (file.type || '').toLowerCase()
|
|
29
|
+
const name = (file.name || '').toLowerCase()
|
|
30
|
+
if (type === PDF_TYPE || name.endsWith('.pdf')) return 'pdf'
|
|
31
|
+
if (type === DOCX_TYPE || name.endsWith('.docx')) return 'docx'
|
|
32
|
+
if (type === PPTX_TYPE || name.endsWith('.pptx')) return 'pptx'
|
|
33
|
+
if (type === DOC_TYPE || name.endsWith('.doc')) return 'doc'
|
|
34
|
+
if (type === PPT_TYPE || name.endsWith('.ppt')) return 'ppt'
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type DocumentRefusal =
|
|
39
|
+
| { code: 'attachment-old-office-not-read'; name: string }
|
|
40
|
+
| { code: 'attachment-document-unreadable'; name: string }
|
|
41
|
+
| { code: 'attachment-document-empty'; name: string }
|
|
42
|
+
| { code: 'attachment-document-too-long'; name: string; maxChars: number }
|
|
43
|
+
| { code: 'attachment-scan-needs-images'; name: string }
|
|
44
|
+
| { code: 'attachment-scan-too-many-pages'; name: string; pages: number; max: number }
|
|
45
|
+
|
|
46
|
+
/** What a document becomes: one text file, and the scanned pages of a PDF as images. */
|
|
47
|
+
export interface DocumentFiles {
|
|
48
|
+
text: { name: string; text: string }
|
|
49
|
+
images: { name: string; page: number; blob: Blob }[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The file name the text travels under - "계약서.pdf" → "계약서.pdf.txt", so the source stays readable. */
|
|
53
|
+
export const documentTextName = (fileName: string) => `${fileName}.txt`
|
|
54
|
+
|
|
55
|
+
/** The file name a scanned page travels under - "계약서.pdf" page 3 → "계약서.pdf - p3.jpg". */
|
|
56
|
+
export const scannedPageName = (fileName: string, page: number) => `${fileName} - p${page}.jpg`
|
|
57
|
+
|
|
58
|
+
/* ── OOXML, read without a DOM: a small pull reader over the tags the text lives in ─────────────────────────── */
|
|
59
|
+
|
|
60
|
+
export interface XmlToken {
|
|
61
|
+
kind: 'open' | 'close' | 'empty' | 'text'
|
|
62
|
+
name?: string
|
|
63
|
+
attrs?: string
|
|
64
|
+
text?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
|
|
68
|
+
|
|
69
|
+
export function xmlDecode(text: string): string {
|
|
70
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code: string) => {
|
|
71
|
+
if (code[0] === '#') {
|
|
72
|
+
const n = code[1] === 'x' || code[1] === 'X' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)
|
|
73
|
+
return Number.isFinite(n) ? String.fromCodePoint(n) : whole
|
|
74
|
+
}
|
|
75
|
+
return ENTITIES[code.toLowerCase()] ?? whole
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Tags and text in document order. Comments, declarations and processing instructions are skipped. */
|
|
80
|
+
export function* xmlTokens(xml: string): Generator<XmlToken> {
|
|
81
|
+
const pattern = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[([\s\S]*?)\]\]>|<!(?:[^>]*)>|<(\/?)([\w:.-]+)([^>]*?)(\/?)>|([^<]+)/g
|
|
82
|
+
for (const match of xml.matchAll(pattern)) {
|
|
83
|
+
const [, cdata, slash, name, attrs, selfClose, text] = match
|
|
84
|
+
if (cdata !== undefined) yield { kind: 'text', text: cdata }
|
|
85
|
+
else if (name) yield { kind: slash ? 'close' : selfClose ? 'empty' : 'open', name, attrs }
|
|
86
|
+
else if (text !== undefined) yield { kind: 'text', text: xmlDecode(text) }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const attrOf = (attrs: string | undefined, name: string) => new RegExp(`\\b${name}="([^"]*)"`).exec(attrs ?? '')?.[1]
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A Word body as text: paragraphs in order, a heading led by "#" for its level, a table as one line per row with its
|
|
94
|
+
* cells between " | ". Text inside a table cell is joined with spaces, so a row stays one line.
|
|
95
|
+
*/
|
|
96
|
+
export function docxText(xml: string): string {
|
|
97
|
+
const lines: string[] = []
|
|
98
|
+
let paragraph = ''
|
|
99
|
+
let heading = 0
|
|
100
|
+
let inText = false
|
|
101
|
+
let tableDepth = 0
|
|
102
|
+
let row: string[] | null = null
|
|
103
|
+
let cell: string[] | null = null
|
|
104
|
+
|
|
105
|
+
for (const token of xmlTokens(xml)) {
|
|
106
|
+
const name = token.name
|
|
107
|
+
if (token.kind === 'text') {
|
|
108
|
+
if (inText) paragraph += token.text
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (name === 'w:t') inText = token.kind === 'open'
|
|
112
|
+
else if (name === 'w:tab' && token.kind !== 'close') paragraph += '\t'
|
|
113
|
+
else if ((name === 'w:br' || name === 'w:cr') && token.kind !== 'close') paragraph += '\n'
|
|
114
|
+
else if (name === 'w:pStyle') {
|
|
115
|
+
const level = /^heading\s*(\d)$/i.exec(attrOf(token.attrs, 'w:val') ?? '')?.[1] ?? (/^title$/i.test(attrOf(token.attrs, 'w:val') ?? '') ? '1' : '')
|
|
116
|
+
heading = level ? Number(level) : 0
|
|
117
|
+
} else if (name === 'w:p' && token.kind === 'open') {
|
|
118
|
+
paragraph = ''
|
|
119
|
+
heading = 0
|
|
120
|
+
} else if (name === 'w:p' && token.kind !== 'open') {
|
|
121
|
+
const text = paragraph.replace(/[ ]+$/g, '')
|
|
122
|
+
if (cell) cell.push(text.trim())
|
|
123
|
+
else if (text.trim()) lines.push(heading ? `${'#'.repeat(heading)} ${text.trim()}` : text)
|
|
124
|
+
paragraph = ''
|
|
125
|
+
} else if (name === 'w:tbl') {
|
|
126
|
+
tableDepth += token.kind === 'open' ? 1 : token.kind === 'close' ? -1 : 0
|
|
127
|
+
} else if (name === 'w:tr' && tableDepth === 1) {
|
|
128
|
+
if (token.kind === 'open') row = []
|
|
129
|
+
else if (row) {
|
|
130
|
+
if (row.some(one => one)) lines.push(`| ${row.join(' | ')} |`)
|
|
131
|
+
row = null
|
|
132
|
+
}
|
|
133
|
+
} else if (name === 'w:tc' && tableDepth === 1) {
|
|
134
|
+
if (token.kind === 'open') cell = []
|
|
135
|
+
else if (cell) {
|
|
136
|
+
row?.push(cell.filter(Boolean).join(' '))
|
|
137
|
+
cell = null
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return lines.join('\n')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* What a slide or its notes repeat on every page - its number, date, footer, header - is not its content. */
|
|
145
|
+
const REPEATED_PLACEHOLDERS = ['sldNum', 'dt', 'ftr', 'hdr']
|
|
146
|
+
|
|
147
|
+
/** The text of one slide or notes part: each paragraph on its own line, the repeated placeholders left out. */
|
|
148
|
+
export function drawingText(xml: string): string[] {
|
|
149
|
+
const lines: string[] = []
|
|
150
|
+
let paragraph = ''
|
|
151
|
+
let inText = false
|
|
152
|
+
let repeated = false
|
|
153
|
+
for (const token of xmlTokens(xml)) {
|
|
154
|
+
if (token.kind === 'text') {
|
|
155
|
+
if (inText) paragraph += token.text
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (token.name === 'p:sp' && token.kind === 'open') repeated = false
|
|
159
|
+
else if (token.name === 'p:ph' && token.kind !== 'close') repeated = REPEATED_PLACEHOLDERS.includes(attrOf(token.attrs, 'type') ?? '')
|
|
160
|
+
else if (token.name === 'a:t') inText = token.kind === 'open'
|
|
161
|
+
else if (token.name === 'a:br' && token.kind !== 'close') paragraph += '\n'
|
|
162
|
+
else if (token.name === 'a:p' && token.kind !== 'open') {
|
|
163
|
+
if (paragraph.trim() && !repeated) lines.push(paragraph.trim())
|
|
164
|
+
paragraph = ''
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return lines
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The slides in the order the presentation shows them - its slide list read through its relationships. */
|
|
171
|
+
export function slideOrder(presentationXml: string, relsXml: string): string[] {
|
|
172
|
+
const targets = new Map<string, string>()
|
|
173
|
+
for (const token of xmlTokens(relsXml)) {
|
|
174
|
+
if (token.name !== 'Relationship') continue
|
|
175
|
+
const id = attrOf(token.attrs, 'Id')
|
|
176
|
+
const target = attrOf(token.attrs, 'Target')
|
|
177
|
+
if (id && target) targets.set(id, target.replace(/^\/?ppt\//, '').replace(/^\.\//, ''))
|
|
178
|
+
}
|
|
179
|
+
const order: string[] = []
|
|
180
|
+
for (const token of xmlTokens(presentationXml)) {
|
|
181
|
+
if (token.name !== 'p:sldId') continue
|
|
182
|
+
const target = targets.get(attrOf(token.attrs, 'r:id') ?? '')
|
|
183
|
+
if (target) order.push(`ppt/${target}`)
|
|
184
|
+
}
|
|
185
|
+
return order
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The notes part a slide points at, if it has one. */
|
|
189
|
+
export function notesTarget(slidePath: string, slideRelsXml: string): string | null {
|
|
190
|
+
for (const token of xmlTokens(slideRelsXml)) {
|
|
191
|
+
if (token.name !== 'Relationship' || !/notesSlide$/.test(attrOf(token.attrs, 'Type') ?? '')) continue
|
|
192
|
+
const target = attrOf(token.attrs, 'Target')
|
|
193
|
+
if (!target) continue
|
|
194
|
+
const base = slidePath.split('/').slice(0, -1)
|
|
195
|
+
for (const part of target.split('/')) {
|
|
196
|
+
if (part === '..') base.pop()
|
|
197
|
+
else if (part !== '.') base.push(part)
|
|
198
|
+
}
|
|
199
|
+
return base.join('/')
|
|
200
|
+
}
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** One slide as text: its paragraphs, then its speaker notes. */
|
|
205
|
+
export function slideText(number: number, slide: string[], notes: string[]): string {
|
|
206
|
+
return [`--- slide ${number} ---`, ...slide, ...(notes.length ? ['notes:', ...notes] : [])].join('\n')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One PDF page's text items as lines - pdf.js marks the end of a line on the item that ends it. */
|
|
210
|
+
export function pdfPageText(items: { str?: string; hasEOL?: boolean }[]): string {
|
|
211
|
+
let text = ''
|
|
212
|
+
for (const item of items) {
|
|
213
|
+
text += item.str ?? ''
|
|
214
|
+
if (item.hasEOL) text += '\n'
|
|
215
|
+
}
|
|
216
|
+
return text
|
|
217
|
+
.split('\n')
|
|
218
|
+
.map(line => line.replace(/[ \t]+/g, ' ').trim())
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.join('\n')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A page with fewer letters than this is a scan - its text is a page number or a stray mark, not its content. */
|
|
224
|
+
export const SCANNED_PAGE_LETTERS = 16
|
|
225
|
+
|
|
226
|
+
export const isScannedPage = (text: string) => text.replace(/\s/g, '').length < SCANNED_PAGE_LETTERS
|
|
227
|
+
|
|
228
|
+
/** The header line of a document's text - what it was and how it is marked. */
|
|
229
|
+
export function documentHeader(fileName: string, kind: 'pdf' | 'docx' | 'pptx', count?: number): string {
|
|
230
|
+
const many = (word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
|
|
231
|
+
if (kind === 'pdf') return `# ${fileName} · PDF · ${many('page')} · each page begins with "--- page N ---", N its own page number`
|
|
232
|
+
if (kind === 'pptx') return `# ${fileName} · PowerPoint · ${many('slide')} · each slide begins with "--- slide N ---", its notes after "notes:"`
|
|
233
|
+
return `# ${fileName} · Word · read in order, headings led by "#", table rows between "|"`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A finished text checked against the limit and for emptiness - the same three answers for every kind. */
|
|
237
|
+
function settle(fileName: string, text: string, body: string, maxChars: number, images: DocumentFiles['images'] = []): { files: DocumentFiles } | { refusal: DocumentRefusal } {
|
|
238
|
+
if (!body.trim() && !images.length) return { refusal: { code: 'attachment-document-empty', name: fileName } }
|
|
239
|
+
if (text.length > maxChars) return { refusal: { code: 'attachment-document-too-long', name: fileName, maxChars } }
|
|
240
|
+
return { files: { text: { name: documentTextName(fileName), text }, images } }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** What reads a zip - JSZip, passed in so the screen loads it only when a document is attached. */
|
|
244
|
+
export interface ZipReader {
|
|
245
|
+
loadAsync(data: ArrayBuffer | Uint8Array): Promise<{ file(path: string): { async(type: 'string'): Promise<string> } | null }>
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function readDocx(data: ArrayBuffer | Uint8Array, fileName: string, maxChars: number, zip: ZipReader) {
|
|
249
|
+
let body: string
|
|
250
|
+
try {
|
|
251
|
+
const archive = await zip.loadAsync(data)
|
|
252
|
+
const part = archive.file('word/document.xml')
|
|
253
|
+
if (!part) return { refusal: { code: 'attachment-document-unreadable', name: fileName } as DocumentRefusal }
|
|
254
|
+
body = docxText(await part.async('string'))
|
|
255
|
+
} catch {
|
|
256
|
+
return { refusal: { code: 'attachment-document-unreadable', name: fileName } as DocumentRefusal }
|
|
257
|
+
}
|
|
258
|
+
return settle(fileName, `${documentHeader(fileName, 'docx')}\n${body}\n`, body, maxChars)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function readPptx(data: ArrayBuffer | Uint8Array, fileName: string, maxChars: number, zip: ZipReader) {
|
|
262
|
+
const slides: string[] = []
|
|
263
|
+
try {
|
|
264
|
+
const archive = await zip.loadAsync(data)
|
|
265
|
+
const read = async (path: string) => (await archive.file(path)?.async('string')) ?? null
|
|
266
|
+
const presentation = await read('ppt/presentation.xml')
|
|
267
|
+
const rels = await read('ppt/_rels/presentation.xml.rels')
|
|
268
|
+
if (!presentation || !rels) return { refusal: { code: 'attachment-document-unreadable', name: fileName } as DocumentRefusal }
|
|
269
|
+
const order = slideOrder(presentation, rels)
|
|
270
|
+
for (const [index, path] of order.entries()) {
|
|
271
|
+
const slide = await read(path)
|
|
272
|
+
if (slide === null) continue
|
|
273
|
+
const slideRels = await read(path.replace(/slides\/(slide\d+\.xml)$/, 'slides/_rels/$1.rels'))
|
|
274
|
+
const notesPath = slideRels ? notesTarget(path, slideRels) : null
|
|
275
|
+
const notes = notesPath ? await read(notesPath) : null
|
|
276
|
+
slides.push(slideText(index + 1, drawingText(slide), notes ? drawingText(notes) : []))
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
return { refusal: { code: 'attachment-document-unreadable', name: fileName } as DocumentRefusal }
|
|
280
|
+
}
|
|
281
|
+
const body = slides.join('\n')
|
|
282
|
+
const said = slides.some(slide => slide.split('\n').length > 1)
|
|
283
|
+
return settle(fileName, `${documentHeader(fileName, 'pptx', slides.length)}\n${body}\n`, said ? body : '', maxChars)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** What reads a PDF - pdf.js's document, narrowed to what is used, so a test can hand in pages. */
|
|
287
|
+
export interface PdfSource {
|
|
288
|
+
numPages: number
|
|
289
|
+
getPage(number: number): Promise<{ getTextContent(): Promise<{ items: any[] }> }>
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Every page of a PDF as text, its scanned pages drawn as images by `drawPage` (the screen's canvas). A scan cannot be
|
|
294
|
+
* read unless the mode takes images, and no more scanned pages are drawn than the message may carry - both refused by
|
|
295
|
+
* name, never dropped.
|
|
296
|
+
*/
|
|
297
|
+
export async function readPdf(
|
|
298
|
+
open: () => Promise<PdfSource>,
|
|
299
|
+
fileName: string,
|
|
300
|
+
maxChars: number,
|
|
301
|
+
scans: { allowed: boolean; max: number },
|
|
302
|
+
drawPage: (number: number) => Promise<Blob>
|
|
303
|
+
): Promise<{ files: DocumentFiles } | { refusal: DocumentRefusal }> {
|
|
304
|
+
const pages: string[] = []
|
|
305
|
+
const scanned: number[] = []
|
|
306
|
+
let count = 0
|
|
307
|
+
try {
|
|
308
|
+
const pdf = await open()
|
|
309
|
+
count = pdf.numPages
|
|
310
|
+
for (let number = 1; number <= count; number++) {
|
|
311
|
+
const page = await pdf.getPage(number)
|
|
312
|
+
const text = pdfPageText((await page.getTextContent()).items)
|
|
313
|
+
if (isScannedPage(text)) {
|
|
314
|
+
scanned.push(number)
|
|
315
|
+
pages.push(`--- page ${number} ---\n(no text on this page - a scan, sent as the image "${scannedPageName(fileName, number)}")`)
|
|
316
|
+
} else pages.push(`--- page ${number} ---\n${text}`)
|
|
317
|
+
}
|
|
318
|
+
} catch {
|
|
319
|
+
return { refusal: { code: 'attachment-document-unreadable', name: fileName } }
|
|
320
|
+
}
|
|
321
|
+
if (scanned.length && !scans.allowed) return { refusal: { code: 'attachment-scan-needs-images', name: fileName } }
|
|
322
|
+
if (scanned.length > scans.max) return { refusal: { code: 'attachment-scan-too-many-pages', name: fileName, pages: scanned.length, max: scans.max } }
|
|
323
|
+
const images: DocumentFiles['images'] = []
|
|
324
|
+
try {
|
|
325
|
+
for (const page of scanned) images.push({ name: scannedPageName(fileName, page), page, blob: await drawPage(page) })
|
|
326
|
+
} catch {
|
|
327
|
+
return { refusal: { code: 'attachment-document-unreadable', name: fileName } }
|
|
328
|
+
}
|
|
329
|
+
const body = pages.join('\n')
|
|
330
|
+
const read = scanned.length < count ? body : ''
|
|
331
|
+
return settle(fileName, `${documentHeader(fileName, 'pdf', count)}\n${body}\n`, read, maxChars, images)
|
|
332
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* One 입력안's fingerprint - what the AI
|
|
2
|
+
* One 입력안's fingerprint - what the AI 작업 패널 showed and what the card enters are the same proposal
|
|
3
3
|
* (operato-application/design/ai/workspace.md §6-1). The screen's copy and the server's are one rule;
|
|
4
4
|
* `test/entry-hash-agree.test.ts` runs both over one table.
|
|
5
5
|
*
|
|
@@ -106,7 +106,7 @@ export function entryProposalHandler(
|
|
|
106
106
|
) {
|
|
107
107
|
return async (proposal: any, context: ProposalContext): Promise<ProposalOutcome> => {
|
|
108
108
|
if (!context.sessionId || !proposal?.key) return { verdict: 'unavailable', errorCode: 'no-conversation' }
|
|
109
|
-
/* Reviewed in the
|
|
109
|
+
/* Reviewed in the 작업 패널: entered only as it was shown there (design/ai/workspace.md §6-1) - here, and again at the server. */
|
|
110
110
|
const reviewed = reviewedOf(proposal.key)
|
|
111
111
|
if (!reviewStillHolds(proposal, reviewed)) return { verdict: 'refused', errorCode: 'review-stale', note: t('ai-assistant.entry.review-stale', { defaultValue: 'this proposal changed after it was reviewed in the workspace - review it again before entering.' }) } as any
|
|
112
112
|
const answer = { ...(await apply(context.sessionId, proposal.key, reviewed)), op: proposal.op }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Reviewing a 입력안 in the AI
|
|
2
|
+
* Reviewing a 입력안 in the AI 작업 패널 (operato-application/design/ai/workspace.md §6-1). Pure but for the
|
|
3
3
|
* session store - tested without a browser.
|
|
4
4
|
*
|
|
5
5
|
* The page is read-only: every column and every row, sorted as stored, issues marked. The one place that enters
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* pdf.js on the screen - loaded only when a PDF is attached, its worker started once (document-text.ts reads the
|
|
3
|
+
* pages; this opens the file and draws a scanned page into an image).
|
|
4
|
+
*/
|
|
5
|
+
let loading: Promise<any> | undefined
|
|
6
|
+
|
|
7
|
+
/** pdf.js with its worker - the worker parses the file off the page's thread. */
|
|
8
|
+
export function loadPdfjs(): Promise<any> {
|
|
9
|
+
loading ??= (async () => {
|
|
10
|
+
const pdfjs: any = await import('pdfjs-dist')
|
|
11
|
+
if (!pdfjs.GlobalWorkerOptions.workerPort) {
|
|
12
|
+
pdfjs.GlobalWorkerOptions.workerPort = new Worker(new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url), { type: 'module' })
|
|
13
|
+
}
|
|
14
|
+
return pdfjs
|
|
15
|
+
})()
|
|
16
|
+
return loading
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The longest side of a drawn page, in pixels - enough for a model to read print, small enough to send. */
|
|
20
|
+
export const PAGE_IMAGE_SIDE = 1600
|
|
21
|
+
|
|
22
|
+
/** One page drawn into a JPEG. */
|
|
23
|
+
export async function drawPdfPage(pdf: any, number: number): Promise<Blob> {
|
|
24
|
+
const page = await pdf.getPage(number)
|
|
25
|
+
const base = page.getViewport({ scale: 1 })
|
|
26
|
+
const viewport = page.getViewport({ scale: Math.min(2, PAGE_IMAGE_SIDE / Math.max(base.width, base.height)) })
|
|
27
|
+
const canvas = document.createElement('canvas')
|
|
28
|
+
canvas.width = Math.ceil(viewport.width)
|
|
29
|
+
canvas.height = Math.ceil(viewport.height)
|
|
30
|
+
const context = canvas.getContext('2d')!
|
|
31
|
+
/* A page draws on transparent pixels; a JPEG would turn them black. */
|
|
32
|
+
context.fillStyle = '#ffffff'
|
|
33
|
+
context.fillRect(0, 0, canvas.width, canvas.height)
|
|
34
|
+
await page.render({ canvas, canvasContext: context, viewport }).promise
|
|
35
|
+
return await new Promise<Blob>((resolve, reject) =>
|
|
36
|
+
canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('the page could not be drawn'))), 'image/jpeg', 0.85)
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The AI
|
|
2
|
+
* The AI 작업 패널 on the screen - which versions a workspace has, and the table's sort and CSV
|
|
3
3
|
* (operato-application/design/ai/workspace.md). Pure - tested without a browser.
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -245,6 +245,8 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
245
245
|
private onAttachFiles;
|
|
246
246
|
/** Each .xlsx replaced by one CSV file per sheet; any other file passes as it is. */
|
|
247
247
|
private readSpreadsheets;
|
|
248
|
+
/** Each document replaced by its text, and a PDF's scanned pages by their images; any other file passes as it is. */
|
|
249
|
+
private readDocuments;
|
|
248
250
|
private readFileAsBase64;
|
|
249
251
|
private removeAttachment;
|
|
250
252
|
/** A pasted table goes back into the box as the words it was copied as - the conversion was not the person's choice. */
|
|
@@ -580,7 +582,7 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
580
582
|
private renderInviteCard;
|
|
581
583
|
/** The screen card - which list, the conditions the screen does not take, and the way to open it. */
|
|
582
584
|
private renderOpenPageCard;
|
|
583
|
-
/** The AI
|
|
585
|
+
/** The AI 작업 패널 card - what it holds, which version, and the way to open it. The person opens it; the model does not. */
|
|
584
586
|
private renderWorkspaceCard;
|
|
585
587
|
/**
|
|
586
588
|
* 눌린 카드가 말하는 것 — **호스트가 한 일**이고, 아직 모르면 모른다고 말한다.
|