@things-factory/ai-assistant 10.1.223 → 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.223",
3
+ "version": "10.1.227",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -35,25 +35,27 @@
35
35
  "@operato/shell": "^10.29.39",
36
36
  "@operato/styles": "^10.0.0",
37
37
  "@operato/utils": "^10.29.39",
38
- "@things-factory/ai-client-base": "^10.1.223",
39
- "@things-factory/apptool-base": "^10.1.221",
40
- "@things-factory/attachment-base": "^10.1.223",
41
- "@things-factory/auth-base": "^10.1.223",
38
+ "@things-factory/ai-client-base": "^10.1.227",
39
+ "@things-factory/apptool-base": "^10.1.227",
40
+ "@things-factory/attachment-base": "^10.1.227",
41
+ "@things-factory/auth-base": "^10.1.227",
42
42
  "@things-factory/env": "^10.1.149",
43
- "@things-factory/setting-base": "^10.1.223",
44
- "@things-factory/shell": "^10.1.208",
43
+ "@things-factory/setting-base": "^10.1.227",
44
+ "@things-factory/shell": "^10.1.227",
45
45
  "dompurify": "^3.0.0",
46
46
  "exceljs": "4.2.0",
47
47
  "graphql-scalars": "^1.22.0",
48
48
  "graphql-tag": "^2.12.6",
49
49
  "graphql-upload": "^15.0.2",
50
+ "jszip": "^3.10.1",
50
51
  "lit": "^3.1.2",
51
- "marked": "^15.0.0"
52
+ "marked": "^15.0.0",
53
+ "pdfjs-dist": "5.7.284"
52
54
  },
53
55
  "browser": "dist-client/index.js",
54
56
  "devDependencies": {
55
57
  "copyfiles": "^2.4.1",
56
58
  "rimraf": "^5.0.0"
57
59
  },
58
- "gitHead": "868f2026b8373371ad04f42b0a097964d0d0ed0f"
60
+ "gitHead": "f66591e3864aab08a6fb93fb1fcf1c9de6b546ac"
59
61
  }
@@ -58,7 +58,7 @@ class AssistantModeView {
58
58
 
59
59
  @Field(() => GraphQLJSON, {
60
60
  nullable: true,
61
- description: "What this conversation accepts as attachments: { kinds: ('image'|'text'|'spreadsheet')[], maxBytes, maxFiles, maxChars }. Null: none."
61
+ description: "What this conversation accepts as attachments: { kinds: ('image'|'text'|'spreadsheet'|'document')[], maxBytes, maxFiles, maxChars }. Null: none."
62
62
  })
63
63
  attachments?: any
64
64
  }
@@ -44,12 +44,12 @@ export function turnSystemPrompt(modePrompt: string, screen: { page?: string; su
44
44
  }
45
45
 
46
46
  /**
47
- * The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
47
+ * The categories a mode opens: its own, the AI 작업 패널 (design/ai/workspace.md), the attachment reader when it
48
48
  * takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
49
49
  */
50
50
  export function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }): string[] | undefined {
51
51
  const extra = [
52
- /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
52
+ /* Every conversation may put a large result on the AI 작업 패널 (design/ai/workspace.md). */
53
53
  'assistant-workspace',
54
54
  /* Every conversation may read who works here and offer to bring one of them in (people/, ADR-0073 decision 3). */
55
55
  ...PEOPLE_CATEGORIES,
@@ -64,7 +64,7 @@ export function modeToolCategories(mode: { toolCategories?: string[]; attachment
64
64
  /**
65
65
  * The categories an unattended run of a mode opens: its own and the lists it reads (design/ai/unattended-run.md §3).
66
66
  *
67
- * Not the 입력안 tools - nobody is there to press the card - nor the AI 작업판 or the attachment reader: there is no
67
+ * Not the 입력안 tools - nobody is there to press the card - nor the AI 작업 패널 or the attachment reader: there is no
68
68
  * conversation for a card or a file to belong to.
69
69
  */
70
70
  export function unattendedToolCategories(mode: { toolCategories?: string[]; readTargets?: string[] }): string[] {
@@ -129,7 +129,7 @@ function axesOfKey(mode: FrontMode, key: string): string[] {
129
129
  }
130
130
 
131
131
  /**
132
- * The categories a turn puts in front when the mode's tools do not fit in one call: the mode's own, the AI 작업판, the
132
+ * The categories a turn puts in front when the mode's tools do not fit in one call: the mode's own, the AI 작업 패널, the
133
133
  * people list and the invite card, the attachment reader, and the open screen's records. The rest the model opens with `openTools`.
134
134
  */
135
135
  export function modeFrontCategories(mode: FrontMode, screen: TurnScreen = {}): string[] {
@@ -72,7 +72,7 @@ describe('the open screen decides what goes in front', () => {
72
72
  expect(modeFrontCategories({ ...mode, readTargets: [], entryTargets: [] }, { page: 'cash-transaction-list' })).toEqual(['office-ops', 'assistant-workspace', 'data-read:organization.employee', 'chat-invite', 'chat-attachments'])
73
73
  })
74
74
 
75
- it('no screen: the mode\'s own, the AI 작업판 and the attachment reader', () => {
75
+ it('no screen: the mode\'s own, the AI 작업 패널 and the attachment reader', () => {
76
76
  expect(modeFrontCategories(mode)).toEqual(['office-ops', 'assistant-workspace', 'data-read:organization.employee', 'chat-invite', 'chat-attachments'])
77
77
  expect(modeFrontCategories(mode, { page: 'some-other-page' })).toEqual(['office-ops', 'assistant-workspace', 'data-read:organization.employee', 'chat-invite', 'chat-attachments'])
78
78
  })
@@ -35,7 +35,7 @@ it('★ the settings come from the registered mode', () => {
35
35
  expect(settings).toEqual({
36
36
  /* The app's persona, then the rules every conversation follows (design/ai/screen-pattern.md §4). */
37
37
  systemPrompt: conversationPrompt('you answer about one space'),
38
- /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
38
+ /* Every conversation may put a large result on the AI 작업 패널 (design/ai/workspace.md). */
39
39
  toolCategories: ['twin-ops', 'assistant-workspace', 'data-read:organization.employee', 'chat-invite'],
40
40
  requireToolOnFirstTurn: true,
41
41
  temperature: 0.2,
@@ -7,10 +7,12 @@
7
7
  */
8
8
 
9
9
  /*
10
- * 'spreadsheet' is a kind a mode opens, never a file the door receives: the screen reads each .xlsx sheet into CSV
11
- * text before sending (client/utils/spreadsheet-text.ts), so a mode that takes spreadsheets takes that text.
10
+ * 'spreadsheet' and 'document' are kinds a mode opens, never files the door receives: the screen reads each .xlsx sheet
11
+ * into CSV text (client/utils/spreadsheet-text.ts), and each PDF, .docx and .pptx into text with its page and slide
12
+ * numbers (client/utils/document-text.ts) before sending, so a mode that takes them takes that text. A scanned PDF
13
+ * page arrives as an image, which the mode takes as 'image'.
12
14
  */
13
- export type AttachmentKind = 'image' | 'text' | 'spreadsheet'
15
+ export type AttachmentKind = 'image' | 'text' | 'spreadsheet' | 'document'
14
16
 
15
17
  export interface AttachmentPolicy {
16
18
  kinds: AttachmentKind[]
@@ -47,9 +49,9 @@ export type AttachmentRefusal =
47
49
  | { code: 'too-many-attachments'; maxFiles: number }
48
50
 
49
51
  /** `null` when every file may go; otherwise the first reason, by name - said before sending, never cut silently. */
50
- /** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */
52
+ /** A kind the policy takes - text also when it takes spreadsheets or documents, which arrive as their text. */
51
53
  function takes(policy: AttachmentPolicy, kind: AttachmentKind): boolean {
52
- return policy.kinds.includes(kind) || (kind === 'text' && policy.kinds.includes('spreadsheet'))
54
+ return policy.kinds.includes(kind) || (kind === 'text' && (policy.kinds.includes('spreadsheet') || policy.kinds.includes('document')))
53
55
  }
54
56
 
55
57
  /** The characters of a text file the model reads under this policy. */
@@ -59,7 +59,7 @@ export interface EntryApplyDeps {
59
59
  execute(document: string, variables: Record<string, unknown>): Promise<{ data?: any; errors?: readonly { message: string }[] }>
60
60
  state: any
61
61
  report?(message: string, error: unknown): void
62
- /** The fingerprint of what the AI 작업판 showed, when the person reviewed it there - entered only if the proposal is still that one. */
62
+ /** The fingerprint of what the AI 작업 패널 showed, when the person reviewed it there - entered only if the proposal is still that one. */
63
63
  reviewedHash?: string
64
64
  }
65
65
 
@@ -101,7 +101,7 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
101
101
  if (proposal.kind !== 'entry') return nothing('refused', 'not-an-entry-proposal')
102
102
  if (superseded) return nothing('refused', 'superseded')
103
103
  if (deps.answered) return nothing('refused', 'already-answered')
104
- /* What the 작업판 showed and what is entered are one proposal (design/ai/workspace.md §6-1). */
104
+ /* What the 작업 패널 showed and what is entered are one proposal (design/ai/workspace.md §6-1). */
105
105
  if (deps.reviewedHash && deps.reviewedHash !== entryProposalHash(proposal)) return nothing('refused', 'review-stale')
106
106
 
107
107
  const target = targetOf(proposal.target)
@@ -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
  *
@@ -4,7 +4,7 @@
4
4
  * The application declares what it lets the assistant read: its list query, the conditions the model may set and
5
5
  * how each becomes one of the query's filters, the columns to show, and the screen that lists the same records.
6
6
  * The framework reads the rows through that query with the person's request state - the same @privilege and
7
- * domain as the screen - counts and sums them itself, and puts a long result on the AI 작업판 as the query returned
7
+ * domain as the screen - counts and sums them itself, and puts a long result on the AI 작업 패널 as the query returned
8
8
  * it. The model is told the count, the sums and a few rows; it never copies a table.
9
9
  *
10
10
  * Pure but for the GraphQL runner, which comes in as an argument - the rules are tested without a server.
@@ -101,7 +101,7 @@ export interface ReadTarget {
101
101
  mine?: boolean
102
102
  }
103
103
 
104
- /** At most this many rows are read - the AI 작업판 table's own limit (workspace-blocks MAX_TABLE_ROWS). */
104
+ /** At most this many rows are read - the AI 작업 패널 table's own limit (workspace-blocks MAX_TABLE_ROWS). */
105
105
  export const MAX_READ_ROWS = 1000
106
106
  /** Rows the model is shown; the rest stay on the card. */
107
107
  export const ROWS_FOR_MODEL = 20
@@ -2,7 +2,7 @@
2
2
  * The tools of an AI 조회 target (operato-application/design/ai/read-targets.md §4, §5).
3
3
  *
4
4
  * query<X> reads the rows through the application's list query as the person, and tells the model the count, the
5
- * sums, the groups and a few rows. A longer result goes on the AI 작업판 as a proposal the server builds from the rows
5
+ * sums, the groups and a few rows. A longer result goes on the AI 작업 패널 as a proposal the server builds from the rows
6
6
  * themselves - the table is never written by the model. open<X>Screen reads nothing: it hands the card the address
7
7
  * of the application's list screen with the conditions it takes.
8
8
  */
@@ -145,7 +145,7 @@ export async function queryRecords(target: ReadTarget, args: any, context: any,
145
145
  label: title,
146
146
  ...said,
147
147
  forScreen: { workspace, contents: workspaceContents(workspace!) },
148
- message: `${rows.length} rows of ${total} are on the AI 작업판 as read - the person opens it from the card.${incomplete} Say the point in one or two lines from the sums, groups and rows above; do not write the table out.`
148
+ message: `${rows.length} rows of ${total} are on the AI 작업 패널 as read - the person opens it from the card.${incomplete} Say the point in one or two lines from the sums, groups and rows above; do not write the table out.`
149
149
  }
150
150
  }
151
151
 
@@ -186,14 +186,14 @@ export function readCategoryOf(target: ReadTarget, run: (context: any) => Graphq
186
186
  doors: [door],
187
187
  description:
188
188
  `Read ${target.description} Set the conditions the question names. Returns the count, the sums, ` +
189
- `groups when groupBy is given (a date column groups by month) and the first ${ROWS_FOR_MODEL} rows; more rows go on the AI 작업판 as read.`,
189
+ `groups when groupBy is given (a date column groups by month) and the first ${ROWS_FOR_MODEL} rows; more rows go on the AI 작업 패널 as read.`,
190
190
  schema: {
191
191
  type: 'object',
192
192
  properties: {
193
193
  id: { type: 'string', description: 'One record by its id, from an earlier answer - its detail.' },
194
194
  ...conditions,
195
195
  groupBy: { type: 'string', description: `One column to group by: ${target.columns.map(one => one.name).join(', ')}.` },
196
- show: { type: 'string', enum: ['answer', 'workspace'], description: 'workspace to put even a short result on the AI 작업판.' },
196
+ show: { type: 'string', enum: ['answer', 'workspace'], description: 'workspace to put even a short result on the AI 작업 패널.' },
197
197
  title: { type: 'string', description: 'The workspace title, in the person\'s language.' },
198
198
  name: { type: 'string', description: 'The workspace key; the same key again makes a new version.' }
199
199
  }
@@ -218,7 +218,7 @@ export function readCategoryOf(target: ReadTarget, run: (context: any) => Graphq
218
218
  description: `data read: ${target.key}`,
219
219
  guidance: [
220
220
  `- To answer from ${target.key} records - a list, or one record found by its name, code or id - call ${queryToolName(target.key)} with the conditions the question names. Every number you say comes from its result - never add up rows yourself; use its sums and groups.`,
221
- `- A list, a summary by item or month, or a comparison: call it with groupBy, and let a long result go on the AI 작업판 - then answer in one or two lines.`,
221
+ `- A list, a summary by item or month, or a comparison: call it with groupBy, and let a long result go on the AI 작업 패널 - then answer in one or two lines.`,
222
222
  `- When the result says complete: false, say how many of how many were read and ask to narrow - never call partial sums the total.`,
223
223
  ...(target.columns.some(one => one.currency)
224
224
  ? [`- Sums of ${target.columns.filter(one => one.currency).map(one => one.name).join(', ')} come per currency. Say each currency's sum; never add two currencies into one number. A comparison across currencies uses a rate you read, and says the rate and its date.`]
@@ -84,7 +84,7 @@ describe('the read tool', () => {
84
84
  expect(result.legend).toEqual({ kind: { HR: '인사', EVENT: '행사' }, state: { unread: '안 읽음' } })
85
85
  })
86
86
 
87
- it('★ the 작업판 table shows the words', async () => {
87
+ it('★ the 작업 패널 table shows the words', async () => {
88
88
  const result: any = await queryRecords(target, { show: 'workspace' }, context, run)
89
89
  const table = result.forScreen.workspace.blocks.find((one: any) => one.kind === 'table')
90
90
  expect(table.rows[0]).toMatchObject({ kind: '인사', state: '안 읽음', publishedAt: '2026-10-26 09:09' })
@@ -40,7 +40,7 @@ export * from './data-read/read-target.js'
40
40
  export { registerReadTarget, readCategoryOf, registerDetailRead, detailCategoryOf, runDetail, type DetailRun } from './data-read/read-tools.js'
41
41
  export * from './data-read/list-read.js'
42
42
  export * from './people/index.js'
43
- /* The AI 작업판 - large results on a page of their own (design/ai/workspace.md). */
43
+ /* The AI 작업 패널 - large results on a page of their own (design/ai/workspace.md). */
44
44
  export * from './workspace/workspace-blocks.js'
45
45
  /* 무엇이 근거인가 — 도구 결과 밖의 셋(프롬프트 · 대화 · 화면 스냅숏). */
46
46
  export * from './grounding-sources.js'
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  * The people tools every conversation carries - the people list (people-read.ts) and the invite card (invite-tools.ts).
3
- * Registered once, at boot, as the AI 작업판 is.
3
+ * Registered once, at boot, as the AI 작업 패널 is.
4
4
  */
5
5
  import { registerReadTarget } from '../data-read/read-tools.js'
6
6
  import { peopleReadTarget } from './people-read.js'
@@ -197,7 +197,7 @@ describe('§3 what runs, and what is kept', () => {
197
197
  expect(seen).toHaveLength(2)
198
198
  })
199
199
 
200
- it('★ only the mode\'s own reads are offered - no 입력안, no 작업판, and none behind a door the owner cannot pass', async () => {
200
+ it('★ only the mode\'s own reads are offered - no 입력안, no 작업 패널, and none behind a door the owner cannot pass', async () => {
201
201
  const { client, seen } = scriptedModel([said('drafted')])
202
202
  await runAssistantUnattended(input(), deps({ model: client }))
203
203
  expect(seen[0].tools.map((tool: any) => tool.name).sort()).toEqual(['readCard', 'readGoals', 'readSales'])
@@ -1,5 +1,5 @@
1
1
  /*
2
- * The AI 작업판 - what a workspace is made of (operato-application/design/ai/workspace.md §3).
2
+ * The AI 작업 패널 - what a workspace is made of (operato-application/design/ai/workspace.md §3).
3
3
  *
4
4
  * The model does not write HTML. A workspace is a list of blocks of known kinds; the screen draws each, so
5
5
  * theme, language and accessibility are the framework's. A block that does not fit is not dropped: it is
@@ -1,5 +1,5 @@
1
1
  /*
2
- * showInWorkspace - the model puts a large result on the AI 작업판 (operato-application/design/ai/workspace.md §5).
2
+ * showInWorkspace - the model puts a large result on the AI 작업 패널 (operato-application/design/ai/workspace.md §5).
3
3
  *
4
4
  * The result rides on the answer as a proposal of kind 'workspace', so the conversation's gates hold for it:
5
5
  * who may read the conversation reads the workspace, and a value behind a door the reader cannot pass hides
@@ -1,5 +1,5 @@
1
1
  /*
2
- * The AI 작업판 (design/ai/workspace.md) - what a block may be, what happens to one that is not, and what the
2
+ * The AI 작업 패널 (design/ai/workspace.md) - what a block may be, what happens to one that is not, and what the
3
3
  * tool hands back.
4
4
  */
5
5
  import { normalizeWorkspace, workspaceName, MAX_SERIES } from './workspace-blocks'
@@ -5,13 +5,16 @@
5
5
  import * as server from '../server/service/chat-session/attachment-policy'
6
6
  import * as screen from '../client/utils/attachment-policy'
7
7
 
8
- const policies = [undefined, { kinds: [] }, { kinds: ['image'] }, { kinds: ['image', 'text'], maxBytes: 100, maxFiles: 2 }] as any[]
8
+ /* A document arrives as its text (and a scanned page as an image) - the door sees what the screen sends, never the PDF. */
9
+ const policies = [undefined, { kinds: [] }, { kinds: ['image'] }, { kinds: ['image', 'text'], maxBytes: 100, maxFiles: 2 }, { kinds: ['document'] }, { kinds: ['image', 'document'] }] as any[]
9
10
  const fileSets = [
10
11
  [],
11
12
  [{ name: 'a.png', type: 'image/png', size: 10 }],
12
13
  [{ name: 'a.csv', type: 'text/csv', size: 10 }],
13
14
  [{ name: 'report.CSV', type: '', size: 10 }],
14
15
  [{ name: 'a.pdf', type: 'application/pdf', size: 10 }],
16
+ [{ name: 'a.pdf.txt', type: 'text/plain', size: 10 }],
17
+ [{ name: 'a.pdf - p2.jpg', type: 'image/jpeg', size: 10 }],
15
18
  [{ name: 'big.png', type: 'image/png', size: 101 }],
16
19
  [{ name: 'a.png', type: 'image/png', size: 1 }, { name: 'b.png', type: 'image/png', size: 1 }, { name: 'c.png', type: 'image/png', size: 1 }],
17
20
  /* Mixed batches: the same "no" in the same order - kind, then size, then one too many, file by file. */
@@ -0,0 +1,175 @@
1
+ /*
2
+ * A document read into the text a conversation carries (design/ai/shared-dock.md §9-6): every page and slide marked
3
+ * with its own number, a scanned PDF page drawn as an image, the old .doc/.ppt refused by name, and a document longer
4
+ * than the limit refused - never cut.
5
+ */
6
+ import JSZip from 'jszip'
7
+
8
+ import * as screen from '../client/utils/attachment-policy'
9
+ import { docxText, documentKindOf, drawingText, isScannedPage, pdfPageText, readDocx, readPdf, readPptx, scannedPageName, slideOrder, xmlDecode } from '../client/utils/document-text'
10
+
11
+ const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
12
+ const para = (text: string, style?: string) => `<w:p>${style ? `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>` : ''}<w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>`
13
+
14
+ async function zip(parts: Record<string, string>): Promise<Uint8Array> {
15
+ const archive = new JSZip()
16
+ for (const [path, text] of Object.entries(parts)) archive.file(path, text)
17
+ return archive.generateAsync({ type: 'uint8array' })
18
+ }
19
+
20
+ /* A paragraph is a shape of its own; "#n" is the page-number placeholder a notes page repeats. */
21
+ const shape = (text: string) =>
22
+ text.startsWith('#')
23
+ ? `<p:sp><p:nvSpPr><p:nvPr><p:ph type="sldNum" idx="5"/></p:nvPr></p:nvSpPr><p:txBody><a:p><a:fld type="slidenum"><a:t>${text.slice(1)}</a:t></a:fld></a:p></p:txBody></p:sp>`
24
+ : `<p:sp><p:txBody><a:p><a:r><a:t>${text}</a:t></a:r></a:p></p:txBody></p:sp>`
25
+ const slideXml = (...paragraphs: string[]) => `<p:sld xmlns:p="p" xmlns:a="a"><p:cSld><p:spTree>${paragraphs.map(shape).join('')}</p:spTree></p:cSld></p:sld>`
26
+
27
+ async function pptx(slides: { text: string[]; notes?: string[] }[], order = slides.map((_, i) => i)) {
28
+ const parts: Record<string, string> = {
29
+ 'ppt/presentation.xml': `<p:presentation xmlns:p="p" xmlns:r="r"><p:sldIdLst>${order.map(i => `<p:sldId id="${256 + i}" r:id="rId${i + 1}"/>`).join('')}</p:sldIdLst></p:presentation>`,
30
+ 'ppt/_rels/presentation.xml.rels': `<Relationships>${slides.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide${i + 1}.xml"/>`).join('')}</Relationships>`
31
+ }
32
+ slides.forEach((slide, i) => {
33
+ parts[`ppt/slides/slide${i + 1}.xml`] = slideXml(...slide.text)
34
+ if (slide.notes) {
35
+ parts[`ppt/slides/_rels/slide${i + 1}.xml.rels`] = `<Relationships><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide${i + 1}.xml"/></Relationships>`
36
+ parts[`ppt/notesSlides/notesSlide${i + 1}.xml`] = slideXml(...slide.notes)
37
+ }
38
+ })
39
+ return zip(parts)
40
+ }
41
+
42
+ const pdf = (pages: string[]) => async () => ({
43
+ numPages: pages.length,
44
+ getPage: async (n: number) => ({
45
+ getTextContent: async () => ({ items: pages[n - 1].split('\n').map(str => ({ str, hasEOL: true })) })
46
+ })
47
+ })
48
+ const drawn = (n: number) => Promise.resolve(new Blob([`page ${n}`], { type: 'image/jpeg' }))
49
+
50
+ describe('which files are documents', () => {
51
+ it('by type, or by name when the browser did not know it', () => {
52
+ expect(documentKindOf({ name: 'a.PDF', type: '' })).toBe('pdf')
53
+ expect(documentKindOf({ name: 'x', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })).toBe('docx')
54
+ expect(documentKindOf({ name: '발표.pptx' })).toBe('pptx')
55
+ expect(documentKindOf({ name: '옛문서.doc' })).toBe('doc')
56
+ expect(documentKindOf({ name: '옛발표.ppt' })).toBe('ppt')
57
+ expect(documentKindOf({ name: 'a.png', type: 'image/png' })).toBeNull()
58
+ })
59
+
60
+ it('★ the picker offers them only to a mode that takes documents', () => {
61
+ expect(screen.acceptOf({ kinds: ['image', 'text', 'document'] })).toMatch(/\.pdf/)
62
+ expect(screen.acceptOf({ kinds: ['image', 'text', 'document'] })).toMatch(/\.docx/)
63
+ expect(screen.acceptOf({ kinds: ['image', 'text', 'document'] })).toMatch(/\.pptx/)
64
+ expect(screen.acceptOf({ kinds: ['image', 'text'] })).not.toMatch(/\.pdf/)
65
+ })
66
+ })
67
+
68
+ describe('Word', () => {
69
+ it('★ paragraphs in order, headings led by #, a table row on one line', async () => {
70
+ const xml = `<w:document ${W}><w:body>${para('계약서', 'Title')}${para('제1조 목적', 'Heading2')}${para('갑과 을은 &amp; 다음과 같이 정한다.')}<w:tbl><w:tr><w:tc>${para('품목')}</w:tc><w:tc>${para('금액')}</w:tc></w:tr><w:tr><w:tc>${para('설계')}${para('1차')}</w:tc><w:tc>${para('1,000,000')}</w:tc></w:tr></w:tbl>${para('끝')}</w:body></w:document>`
71
+ expect(docxText(xml)).toBe(['# 계약서', '## 제1조 목적', '갑과 을은 & 다음과 같이 정한다.', '| 품목 | 금액 |', '| 설계 1차 | 1,000,000 |', '끝'].join('\n'))
72
+ })
73
+
74
+ it('reads a .docx into one text file named after it', async () => {
75
+ const data = await zip({ 'word/document.xml': `<w:document ${W}><w:body>${para('본문')}</w:body></w:document>` })
76
+ const read: any = await readDocx(data, '계약서.docx', 20_000, JSZip)
77
+ expect(read.files.text.name).toBe('계약서.docx.txt')
78
+ expect(read.files.text.text).toMatch(/^# 계약서\.docx · Word/)
79
+ expect(read.files.text.text).toMatch(/\n본문\n$/)
80
+ })
81
+
82
+ it('a file that is not a .docx, or one with nothing in it, is said', async () => {
83
+ expect(await readDocx(new Uint8Array([1, 2, 3]), 'x.docx', 20_000, JSZip)).toEqual({ refusal: { code: 'attachment-document-unreadable', name: 'x.docx' } })
84
+ const empty = await zip({ 'word/document.xml': `<w:document ${W}><w:body><w:p/></w:body></w:document>` })
85
+ expect(await readDocx(empty, 'empty.docx', 20_000, JSZip)).toEqual({ refusal: { code: 'attachment-document-empty', name: 'empty.docx' } })
86
+ })
87
+
88
+ it('★ longer than the limit is refused, never cut', async () => {
89
+ const data = await zip({ 'word/document.xml': `<w:document ${W}><w:body>${para('가'.repeat(500))}</w:body></w:document>` })
90
+ expect(await readDocx(data, 'long.docx', 300, JSZip)).toEqual({ refusal: { code: 'attachment-document-too-long', name: 'long.docx', maxChars: 300 } })
91
+ })
92
+ })
93
+
94
+ describe('PowerPoint', () => {
95
+ it('★ slides in the order the presentation shows them, each with its number and its notes', async () => {
96
+ const data = await pptx([{ text: ['둘째로 둔 슬라이드'] }, { text: ['표지', '2026 사업계획'], notes: ['발표자가 할 말', '#1'] }], [1, 0])
97
+ const read: any = await readPptx(data, '발표.pptx', 20_000, JSZip)
98
+ expect(read.files.text.text).toBe(
99
+ ['# 발표.pptx · PowerPoint · 2 slides · each slide begins with "--- slide N ---", its notes after "notes:"', '--- slide 1 ---', '표지', '2026 사업계획', 'notes:', '발표자가 할 말', '--- slide 2 ---', '둘째로 둔 슬라이드', ''].join('\n')
100
+ )
101
+ })
102
+
103
+ it('reads the slide list through its relationships', () => {
104
+ expect(slideOrder('<p:sldIdLst><p:sldId id="1" r:id="rId7"/></p:sldIdLst>', '<Relationships><Relationship Id="rId7" Target="slides/slide3.xml"/></Relationships>')).toEqual(['ppt/slides/slide3.xml'])
105
+ expect(drawingText(slideXml('a', 'b'))).toEqual(['a', 'b'])
106
+ /* The number, date and footer a page repeats are left out by their placeholder, not by what they say. */
107
+ expect(drawingText(slideXml('a', '#7'))).toEqual(['a'])
108
+ })
109
+
110
+ it('a deck with no words on any slide is said', async () => {
111
+ const data = await pptx([{ text: [] }])
112
+ expect(await readPptx(data, 'pictures.pptx', 20_000, JSZip)).toEqual({ refusal: { code: 'attachment-document-empty', name: 'pictures.pptx' } })
113
+ })
114
+ })
115
+
116
+ describe('PDF', () => {
117
+ it('★ every page is marked with its own number', async () => {
118
+ const read: any = await readPdf(pdf(['제1조 이 계약은 갑과 을 사이의 용역에 관한 것이다', '제2조 계약 기간은 2026년 11월부터이다']), '계약서.pdf', 20_000, { allowed: true, max: 4 }, drawn)
119
+ expect(read.files.text.text).toBe(
120
+ ['# 계약서.pdf · PDF · 2 pages · each page begins with "--- page N ---", N its own page number', '--- page 1 ---', '제1조 이 계약은 갑과 을 사이의 용역에 관한 것이다', '--- page 2 ---', '제2조 계약 기간은 2026년 11월부터이다', ''].join('\n')
121
+ )
122
+ expect(read.files.images).toEqual([])
123
+ })
124
+
125
+ it('★ a page with no text is a scan - drawn as an image named by its page, and the text says where it went', async () => {
126
+ const read: any = await readPdf(pdf(['본문이 있는 첫 쪽입니다. 글자가 충분히 있습니다.', '3']), '스캔.pdf', 20_000, { allowed: true, max: 4 }, drawn)
127
+ expect(read.files.images.map((one: any) => [one.name, one.page])).toEqual([['스캔.pdf - p2.jpg', 2]])
128
+ expect(read.files.text.text).toContain(`--- page 2 ---\n(no text on this page - a scan, sent as the image "${scannedPageName('스캔.pdf', 2)}")`)
129
+ })
130
+
131
+ it('★ a scan is refused by name when the mode takes no images, or when it has more pages than a message carries', async () => {
132
+ expect(await readPdf(pdf(['', '']), 'scan.pdf', 20_000, { allowed: false, max: 4 }, drawn)).toEqual({ refusal: { code: 'attachment-scan-needs-images', name: 'scan.pdf' } })
133
+ expect(await readPdf(pdf(['', '', '', '', '', '']), 'scan.pdf', 20_000, { allowed: true, max: 4 }, drawn)).toEqual({
134
+ refusal: { code: 'attachment-scan-too-many-pages', name: 'scan.pdf', pages: 6, max: 4 }
135
+ })
136
+ })
137
+
138
+ it('a wholly scanned PDF goes as its images and the page list', async () => {
139
+ const read: any = await readPdf(pdf(['', '']), 'scan.pdf', 20_000, { allowed: true, max: 4 }, drawn)
140
+ expect(read.files.images).toHaveLength(2)
141
+ })
142
+
143
+ it('longer than the limit is refused, never cut; a broken file is said', async () => {
144
+ expect(await readPdf(pdf(['가'.repeat(400)]), 'long.pdf', 300, { allowed: true, max: 4 }, drawn)).toEqual({ refusal: { code: 'attachment-document-too-long', name: 'long.pdf', maxChars: 300 } })
145
+ expect(await readPdf(() => Promise.reject(new Error('bad')), 'bad.pdf', 300, { allowed: true, max: 4 }, drawn)).toEqual({ refusal: { code: 'attachment-document-unreadable', name: 'bad.pdf' } })
146
+ })
147
+
148
+ it('joins pdf.js items into lines and tells a scan by its letters', () => {
149
+ expect(pdfPageText([{ str: '제1조', hasEOL: false }, { str: ' 목적', hasEOL: true }, { str: '본문' }])).toBe('제1조 목적\n본문')
150
+ expect(isScannedPage('- 3 -')).toBe(true)
151
+ expect(isScannedPage('이 쪽에는 글자가 열여섯 자보다 많습니다')).toBe(false)
152
+ })
153
+ })
154
+
155
+ it('decodes the entities OOXML writes', () => {
156
+ expect(xmlDecode('&lt;a&gt; &amp; &quot;b&quot; &#44032; &#xAC00;')).toBe('<a> & "b" 가 가')
157
+ })
158
+
159
+ describe('every refusal is said in every locale', () => {
160
+ const { readFileSync } = require('fs')
161
+ const { join } = require('path')
162
+ const source = readFileSync(join(__dirname, '../client/utils/document-text.ts'), 'utf8')
163
+ const codes = [...new Set([...source.matchAll(/code: '(attachment-[a-z-]+)'/g)].map((match: any) => match[1]))]
164
+
165
+ it('the parser finds the refusals', () => {
166
+ expect(codes.length).toBeGreaterThanOrEqual(6)
167
+ })
168
+
169
+ for (const locale of ['en', 'ko', 'ja', 'zh', 'ms', 'es']) {
170
+ it(locale, () => {
171
+ const dictionary = JSON.parse(readFileSync(join(__dirname, `../translations/${locale}.json`), 'utf8'))
172
+ expect(codes.filter(code => !dictionary[`ai-assistant.text.${code}`])).toEqual([])
173
+ })
174
+ }
175
+ })
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Reviewing a 입력안 in the AI 작업판 (operato-application/design/ai/workspace.md §6-1): read-only on the page,
2
+ * Reviewing a 입력안 in the AI 작업 패널 (operato-application/design/ai/workspace.md §6-1): read-only on the page,
3
3
  * entered from the dock's card - and only as the page showed it.
4
4
  */
5
5
  import { readFileSync } from 'fs'
@@ -1,5 +1,5 @@
1
1
  /*
2
- * The AI 작업판 on the screen (design/ai/workspace.md) - which versions a reader sees, the card's version, and the
2
+ * The AI 작업 패널 on the screen (design/ai/workspace.md) - which versions a reader sees, the card's version, and the
3
3
  * table's sort and CSV. The page and the chat are read as text: importing them pulls the shell.
4
4
  */
5
5
  import { readFileSync } from 'fs'
@@ -293,6 +293,12 @@
293
293
  "ai-assistant.text.pasted-table-name": "pasted table ({rows} rows)",
294
294
  "ai-assistant.text.attachment-sheet-unreadable": "{name} cannot be read as an Excel file.",
295
295
  "ai-assistant.text.attachment-sheet-empty": "{name} has no sheet with anything in it.",
296
+ "ai-assistant.text.attachment-old-office-not-read": "{name} is an old Office file (.doc or .ppt) and is not read. Save it as .docx or .pptx and attach it again.",
297
+ "ai-assistant.text.attachment-document-unreadable": "{name} cannot be read as a document.",
298
+ "ai-assistant.text.attachment-document-empty": "{name} has no text to read.",
299
+ "ai-assistant.text.attachment-document-too-long": "{name} is longer than {maxChars} characters as text and was not sent. Split it and attach the parts.",
300
+ "ai-assistant.text.attachment-scan-needs-images": "{name} has scanned pages, which go as images, and this conversation takes no images.",
301
+ "ai-assistant.text.attachment-scan-too-many-pages": "{name} has {pages} scanned pages; one message carries at most {max}. Attach the pages you need as a smaller PDF.",
296
302
  "ai-assistant.entry.review-in-workspace": "review in the workspace",
297
303
  "ai-assistant.entry.review-stale": "this proposal changed after it was reviewed in the workspace - review it again before entering.",
298
304
  "ai-assistant.workspace.entry-not-found": "there is no proposal here that you can see.",
@@ -293,6 +293,12 @@
293
293
  "ai-assistant.text.pasted-table-name": "tabla pegada ({rows} filas)",
294
294
  "ai-assistant.text.attachment-sheet-unreadable": "{name} no se puede leer como archivo de Excel.",
295
295
  "ai-assistant.text.attachment-sheet-empty": "{name} no tiene ninguna hoja con contenido.",
296
+ "ai-assistant.text.attachment-old-office-not-read": "{name} es un archivo antiguo de Office (.doc o .ppt) y no se lee. Guárdalo como .docx o .pptx y adjúntalo de nuevo.",
297
+ "ai-assistant.text.attachment-document-unreadable": "{name} no se puede leer como documento.",
298
+ "ai-assistant.text.attachment-document-empty": "{name} no tiene texto que leer.",
299
+ "ai-assistant.text.attachment-document-too-long": "{name} supera los {maxChars} caracteres como texto y no se envió. Divídelo y adjunta las partes.",
300
+ "ai-assistant.text.attachment-scan-needs-images": "{name} tiene páginas escaneadas, que se envían como imágenes, y esta conversación no admite imágenes.",
301
+ "ai-assistant.text.attachment-scan-too-many-pages": "{name} tiene {pages} páginas escaneadas; un mensaje lleva como máximo {max}. Adjunta las páginas que necesites como un PDF más pequeño.",
296
302
  "ai-assistant.entry.review-in-workspace": "revisar en el espacio de trabajo",
297
303
  "ai-assistant.entry.review-stale": "esta propuesta cambió después de revisarse en el espacio de trabajo; revísela de nuevo antes de introducirla.",
298
304
  "ai-assistant.workspace.entry-not-found": "aquí no hay ninguna propuesta que pueda ver.",
@@ -293,6 +293,12 @@
293
293
  "ai-assistant.text.pasted-table-name": "貼り付けた表 {rows}行",
294
294
  "ai-assistant.text.attachment-sheet-unreadable": "{name} を Excel ファイルとして読み込めません。",
295
295
  "ai-assistant.text.attachment-sheet-empty": "{name} には内容のあるシートがありません。",
296
+ "ai-assistant.text.attachment-old-office-not-read": "{name} は古い Office 形式(.doc・.ppt)のため読み込みません。.docx・.pptx で保存して再度添付してください。",
297
+ "ai-assistant.text.attachment-document-unreadable": "{name} を文書として読み込めません。",
298
+ "ai-assistant.text.attachment-document-empty": "{name} に読み取れる文字がありません。",
299
+ "ai-assistant.text.attachment-document-too-long": "{name} はテキストにすると {maxChars} 文字を超えるため送信しませんでした。分けて添付してください。",
300
+ "ai-assistant.text.attachment-scan-needs-images": "{name} にはスキャンしたページがあり画像として送る必要がありますが、この会話は画像を受け付けません。",
301
+ "ai-assistant.text.attachment-scan-too-many-pages": "{name} のスキャンしたページは {pages} ページですが、一度に送れるのは {max} ページまでです。必要なページだけを PDF に分けて添付してください。",
296
302
  "ai-assistant.entry.review-in-workspace": "作業ボードで確認",
297
303
  "ai-assistant.entry.review-stale": "作業ボードで確認した後に入力案が変わりました。もう一度確認してから入力してください。",
298
304
  "ai-assistant.workspace.entry-not-found": "表示できる入力案はありません。",
@@ -223,7 +223,7 @@
223
223
  "ai-assistant.entry.issue.not-in-source": "자료에 없는 값입니다",
224
224
  "ai-assistant.entry.issue.source-scattered": "값이 자료의 한 줄에 함께 있지 않습니다",
225
225
  "ai-assistant.entry.issue.source-not-checked": "사진 자료는 대조하지 못했습니다",
226
- "ai-assistant.workspace.title": "AI 작업판",
226
+ "ai-assistant.workspace.title": "AI 작업 패널",
227
227
  "ai-assistant.workspace.unreadable": "그릴 수 없는 블록입니다 ({reason})",
228
228
  "ai-assistant.workspace.rows": "{count}행",
229
229
  "ai-assistant.workspace.csv": "CSV",
@@ -231,14 +231,14 @@
231
231
  "ai-assistant.workspace.other": "기타",
232
232
  "ai-assistant.workspace.as-chart": "차트",
233
233
  "ai-assistant.workspace.as-table": "표로 보기",
234
- "ai-assistant.workspace.loading": "작업판을 불러오는 중…",
235
- "ai-assistant.workspace.unreadable-page": "작업판을 읽지 못했습니다: {reason}",
236
- "ai-assistant.workspace.not-found": "볼 수 있는 작업판이 없습니다.",
237
- "ai-assistant.workspace.newer": "새 판({version})이 있습니다.",
234
+ "ai-assistant.workspace.loading": "작업 패널을 불러오는 중…",
235
+ "ai-assistant.workspace.unreadable-page": "작업 패널을 읽지 못했습니다: {reason}",
236
+ "ai-assistant.workspace.not-found": "볼 수 있는 작업 패널이 없습니다.",
237
+ "ai-assistant.workspace.newer": "새 버전({version})이 있습니다.",
238
238
  "ai-assistant.workspace.show-newer": "보기",
239
- "ai-assistant.workspace.versions": "판",
240
- "ai-assistant.workspace.version": "판 {version} / {of}",
241
- "ai-assistant.workspace.open": "작업판에서 보기",
239
+ "ai-assistant.workspace.versions": "버전",
240
+ "ai-assistant.workspace.version": "버전 {version} / {of}",
241
+ "ai-assistant.workspace.open": "작업 패널에서 보기",
242
242
  "ai-assistant.screen.open": "화면에서 열기",
243
243
  "ai-assistant.screen.not-applied": "화면에서는 걸리지 않는 조건: {conditions}",
244
244
  "ai-assistant.read.count": "건수",
@@ -293,8 +293,14 @@
293
293
  "ai-assistant.text.pasted-table-name": "붙여넣은 표 {rows}행",
294
294
  "ai-assistant.text.attachment-sheet-unreadable": "{name} 을 엑셀 파일로 읽을 수 없습니다.",
295
295
  "ai-assistant.text.attachment-sheet-empty": "{name} 에 내용이 있는 시트가 없습니다.",
296
- "ai-assistant.entry.review-in-workspace": "작업판에서 검토",
297
- "ai-assistant.entry.review-stale": "작업판에서 검토한 뒤 입력안이 바뀌었습니다. 다시 검토한 뒤 입력하십시오.",
296
+ "ai-assistant.text.attachment-old-office-not-read": "{name} 은 옛 Office 형식(.doc·.ppt)이라 읽지 않습니다. .docx·.pptx 로 저장해 다시 첨부해 주십시오.",
297
+ "ai-assistant.text.attachment-document-unreadable": "{name} 을 문서로 읽을 수 없습니다.",
298
+ "ai-assistant.text.attachment-document-empty": "{name} 에 읽을 글자가 없습니다.",
299
+ "ai-assistant.text.attachment-document-too-long": "{name} 은 글자로 바꾸면 {maxChars} 자를 넘어 보내지 않았습니다. 나눠 첨부해 주십시오.",
300
+ "ai-assistant.text.attachment-scan-needs-images": "{name} 에 스캔한 쪽이 있어 이미지로 보내야 하는데, 이 대화는 이미지를 받지 않습니다.",
301
+ "ai-assistant.text.attachment-scan-too-many-pages": "{name} 의 스캔한 쪽은 {pages}쪽인데 한 번에 {max}쪽까지 보낼 수 있습니다. 필요한 쪽만 PDF 로 나눠 첨부해 주십시오.",
302
+ "ai-assistant.entry.review-in-workspace": "작업 패널에서 검토",
303
+ "ai-assistant.entry.review-stale": "작업 패널에서 검토한 뒤 입력안이 바뀌었습니다. 다시 검토한 뒤 입력하십시오.",
298
304
  "ai-assistant.workspace.entry-not-found": "볼 수 있는 입력안이 없습니다.",
299
305
  "ai-assistant.workspace.entry-superseded": "대화에서 더 새로운 카드가 이 카드를 대신했습니다. 그 카드를 검토하십시오.",
300
306
  "ai-assistant.workspace.entry-press-in-dock": "검토 화면입니다. 입력은 AI 도크의 카드에 있는 단추로 합니다.",