@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.
Files changed (105) hide show
  1. package/client/components/assistant-chat.ts +50 -2
  2. package/client/components/assistant-dock.ts +1 -1
  3. package/client/components/assistant-mode.ts +1 -1
  4. package/client/components/entry-review.ts +2 -2
  5. package/client/components/workspace-view.ts +1 -1
  6. package/client/index.ts +1 -1
  7. package/client/pages/assistant-workspace-page.ts +1 -1
  8. package/client/route.ts +1 -1
  9. package/client/utils/attachment-policy.ts +15 -8
  10. package/client/utils/document-text.ts +332 -0
  11. package/client/utils/entry-hash.ts +1 -1
  12. package/client/utils/entry-proposal.ts +1 -1
  13. package/client/utils/entry-workspace.ts +1 -1
  14. package/client/utils/pdf-pages.ts +38 -0
  15. package/client/utils/workspace.ts +1 -1
  16. package/dist-client/components/assistant-chat.d.ts +3 -1
  17. package/dist-client/components/assistant-chat.js +48 -2
  18. package/dist-client/components/assistant-chat.js.map +1 -1
  19. package/dist-client/components/assistant-dock.js +1 -1
  20. package/dist-client/components/assistant-dock.js.map +1 -1
  21. package/dist-client/components/assistant-mode.d.ts +1 -1
  22. package/dist-client/components/assistant-mode.js.map +1 -1
  23. package/dist-client/components/entry-review.d.ts +1 -1
  24. package/dist-client/components/entry-review.js +2 -2
  25. package/dist-client/components/entry-review.js.map +1 -1
  26. package/dist-client/components/workspace-view.js +1 -1
  27. package/dist-client/components/workspace-view.js.map +1 -1
  28. package/dist-client/index.js +1 -1
  29. package/dist-client/index.js.map +1 -1
  30. package/dist-client/pages/assistant-workspace-page.js +1 -1
  31. package/dist-client/pages/assistant-workspace-page.js.map +1 -1
  32. package/dist-client/route.js +1 -1
  33. package/dist-client/route.js.map +1 -1
  34. package/dist-client/tsconfig.tsbuildinfo +1 -1
  35. package/dist-client/utils/attachment-policy.d.ts +2 -2
  36. package/dist-client/utils/attachment-policy.js +9 -5
  37. package/dist-client/utils/attachment-policy.js.map +1 -1
  38. package/dist-client/utils/document-text.d.ts +118 -0
  39. package/dist-client/utils/document-text.js +320 -0
  40. package/dist-client/utils/document-text.js.map +1 -0
  41. package/dist-client/utils/entry-hash.js +1 -1
  42. package/dist-client/utils/entry-hash.js.map +1 -1
  43. package/dist-client/utils/entry-proposal.js +1 -1
  44. package/dist-client/utils/entry-proposal.js.map +1 -1
  45. package/dist-client/utils/entry-workspace.js +1 -1
  46. package/dist-client/utils/entry-workspace.js.map +1 -1
  47. package/dist-client/utils/pdf-pages.d.ts +6 -0
  48. package/dist-client/utils/pdf-pages.js +34 -0
  49. package/dist-client/utils/pdf-pages.js.map +1 -0
  50. package/dist-client/utils/workspace.js +1 -1
  51. package/dist-client/utils/workspace.js.map +1 -1
  52. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +1 -1
  53. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  54. package/dist-server/service/assistant-mode/mode-categories.d.ts +3 -3
  55. package/dist-server/service/assistant-mode/mode-categories.js +4 -4
  56. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  57. package/dist-server/service/chat-session/attachment-policy.d.ts +1 -1
  58. package/dist-server/service/chat-session/attachment-policy.js +2 -2
  59. package/dist-server/service/chat-session/attachment-policy.js.map +1 -1
  60. package/dist-server/service/data-entry/entry-apply.d.ts +1 -1
  61. package/dist-server/service/data-entry/entry-apply.js +1 -1
  62. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  63. package/dist-server/service/data-entry/entry-hash.js +1 -1
  64. package/dist-server/service/data-entry/entry-hash.js.map +1 -1
  65. package/dist-server/service/data-read/read-target.d.ts +1 -1
  66. package/dist-server/service/data-read/read-target.js +2 -2
  67. package/dist-server/service/data-read/read-target.js.map +1 -1
  68. package/dist-server/service/data-read/read-tools.js +5 -5
  69. package/dist-server/service/data-read/read-tools.js.map +1 -1
  70. package/dist-server/service/index.js +1 -1
  71. package/dist-server/service/index.js.map +1 -1
  72. package/dist-server/service/people/index.js +1 -1
  73. package/dist-server/service/people/index.js.map +1 -1
  74. package/dist-server/service/workspace/workspace-blocks.js +1 -1
  75. package/dist-server/service/workspace/workspace-blocks.js.map +1 -1
  76. package/dist-server/service/workspace/workspace-tools.js +1 -1
  77. package/dist-server/service/workspace/workspace-tools.js.map +1 -1
  78. package/dist-server/tsconfig.tsbuildinfo +1 -1
  79. package/package.json +11 -9
  80. package/server/service/assistant-mode/assistant-mode-resolver.ts +1 -1
  81. package/server/service/assistant-mode/mode-categories.ts +4 -4
  82. package/server/service/assistant-mode/mode-front.test.ts +1 -1
  83. package/server/service/assistant-mode/resolve.test.ts +1 -1
  84. package/server/service/chat-session/attachment-policy.ts +7 -5
  85. package/server/service/data-entry/entry-apply.ts +2 -2
  86. package/server/service/data-entry/entry-hash.ts +1 -1
  87. package/server/service/data-read/read-target.ts +2 -2
  88. package/server/service/data-read/read-tools.ts +5 -5
  89. package/server/service/data-read/read-words.test.ts +1 -1
  90. package/server/service/index.ts +1 -1
  91. package/server/service/people/index.ts +1 -1
  92. package/server/service/unattended-run/run-unattended.test.ts +1 -1
  93. package/server/service/workspace/workspace-blocks.ts +1 -1
  94. package/server/service/workspace/workspace-tools.ts +1 -1
  95. package/server/service/workspace/workspace.test.ts +1 -1
  96. package/test/attachment-policy-agree.test.ts +4 -1
  97. package/test/document-text.test.ts +175 -0
  98. package/test/entry-workspace.test.ts +1 -1
  99. package/test/workspace.test.ts +1 -1
  100. package/translations/en.json +6 -0
  101. package/translations/es.json +6 -0
  102. package/translations/ja.json +6 -0
  103. package/translations/ko.json +16 -10
  104. package/translations/ms.json +6 -0
  105. 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 작업판 card - what it holds, which version, and the way to open it. The person opens it; the model does not. */
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 작업판. The dock stays open beside it. */
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 작업판: every row, the page's height, the reading size of a page (design/ai/workspace.md §6-1). */
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 작업판's review - every row is drawn, not the first fifty. */
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 작업판, drawn (operato-application/design/ai/workspace.md §3).
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 작업판's blocks, drawn - also a dashboard card's (ADR-0160 decision 5). */
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 작업판 page (operato-application/design/ai/workspace.md).
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 작업판 - results too large for the dock (design/ai/workspace.md). */
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' is a kind a mode opens, never a file the door receives: the screen reads each .xlsx sheet into CSV
16
- * text before sending (client/utils/spreadsheet-text.ts), so a mode that takes spreadsheets takes that text.
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 arrives as the text of its sheets. */
77
- const sent = policy.kinds.map(kind => (kind === 'spreadsheet' ? 'text' : 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('spreadsheet') ? [...kinds, 'text'] : kinds)
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 작업판 showed and what the card enters are the same proposal
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 작업판: entered only as it was shown there (design/ai/workspace.md §6-1) - here, and again at the server. */
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 작업판 (operato-application/design/ai/workspace.md §6-1). Pure but for the
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 작업판 on the screen - which versions a workspace has, and the table's sort and CSV
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 작업판 card - what it holds, which version, and the way to open it. The person opens it; the model does not. */
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
  * 눌린 카드가 말하는 것 — **호스트가 한 일**이고, 아직 모르면 모른다고 말한다.