@things-factory/ai-assistant 10.1.106 → 10.1.108

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 (83) hide show
  1. package/client/components/assistant-chat.ts +67 -2
  2. package/client/components/assistant-dock.ts +4 -4
  3. package/client/components/assistant-mode.ts +1 -1
  4. package/client/components/entry-review.ts +23 -3
  5. package/client/pages/assistant-workspace-page.ts +82 -2
  6. package/client/utils/attachment-policy.ts +22 -3
  7. package/client/utils/entry-hash.ts +35 -0
  8. package/client/utils/entry-proposal.ts +10 -3
  9. package/client/utils/entry-workspace.ts +70 -0
  10. package/client/utils/pasted-table.ts +145 -0
  11. package/client/utils/spreadsheet-text.ts +125 -0
  12. package/dist-client/components/assistant-chat.d.ts +2 -0
  13. package/dist-client/components/assistant-chat.js +63 -2
  14. package/dist-client/components/assistant-chat.js.map +1 -1
  15. package/dist-client/components/assistant-dock.js +4 -4
  16. package/dist-client/components/assistant-dock.js.map +1 -1
  17. package/dist-client/components/assistant-mode.d.ts +2 -1
  18. package/dist-client/components/assistant-mode.js.map +1 -1
  19. package/dist-client/components/entry-review.d.ts +2 -0
  20. package/dist-client/components/entry-review.js +30 -3
  21. package/dist-client/components/entry-review.js.map +1 -1
  22. package/dist-client/pages/assistant-workspace-page.d.ts +8 -0
  23. package/dist-client/pages/assistant-workspace-page.js +98 -2
  24. package/dist-client/pages/assistant-workspace-page.js.map +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/dist-client/utils/attachment-policy.d.ts +7 -1
  27. package/dist-client/utils/attachment-policy.js +14 -2
  28. package/dist-client/utils/attachment-policy.js.map +1 -1
  29. package/dist-client/utils/entry-hash.d.ts +2 -0
  30. package/dist-client/utils/entry-hash.js +36 -0
  31. package/dist-client/utils/entry-hash.js.map +1 -0
  32. package/dist-client/utils/entry-proposal.d.ts +1 -1
  33. package/dist-client/utils/entry-proposal.js +9 -2
  34. package/dist-client/utils/entry-proposal.js.map +1 -1
  35. package/dist-client/utils/entry-workspace.d.ts +20 -0
  36. package/dist-client/utils/entry-workspace.js +68 -0
  37. package/dist-client/utils/entry-workspace.js.map +1 -0
  38. package/dist-client/utils/pasted-table.d.ts +34 -0
  39. package/dist-client/utils/pasted-table.js +150 -0
  40. package/dist-client/utils/pasted-table.js.map +1 -0
  41. package/dist-client/utils/spreadsheet-text.d.ts +48 -0
  42. package/dist-client/utils/spreadsheet-text.js +110 -0
  43. package/dist-client/utils/spreadsheet-text.js.map +1 -0
  44. package/dist-server/service/assistant-chat-resolver.js +1 -1
  45. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  46. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +1 -1
  47. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  48. package/dist-server/service/chat-session/attachment-policy.d.ts +5 -2
  49. package/dist-server/service/chat-session/attachment-policy.js +10 -1
  50. package/dist-server/service/chat-session/attachment-policy.js.map +1 -1
  51. package/dist-server/service/chat-session/attachment-tools.js +3 -1
  52. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  53. package/dist-server/service/chat-session/session-attachments.d.ts +3 -2
  54. package/dist-server/service/chat-session/session-attachments.js +5 -4
  55. package/dist-server/service/chat-session/session-attachments.js.map +1 -1
  56. package/dist-server/service/data-entry/entry-apply.d.ts +2 -0
  57. package/dist-server/service/data-entry/entry-apply.js +4 -0
  58. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  59. package/dist-server/service/data-entry/entry-hash.d.ts +2 -0
  60. package/dist-server/service/data-entry/entry-hash.js +39 -0
  61. package/dist-server/service/data-entry/entry-hash.js.map +1 -0
  62. package/dist-server/service/data-entry/entry-resolver.d.ts +1 -1
  63. package/dist-server/service/data-entry/entry-resolver.js +5 -3
  64. package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
  65. package/dist-server/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +5 -4
  67. package/server/service/assistant-chat-resolver.ts +2 -2
  68. package/server/service/assistant-mode/assistant-mode-resolver.ts +1 -1
  69. package/server/service/chat-session/attachment-policy.ts +18 -2
  70. package/server/service/chat-session/attachment-tools.ts +3 -1
  71. package/server/service/chat-session/session-attachments.ts +5 -4
  72. package/server/service/data-entry/data-entry.test.ts +10 -0
  73. package/server/service/data-entry/entry-apply.ts +5 -0
  74. package/server/service/data-entry/entry-hash.ts +35 -0
  75. package/server/service/data-entry/entry-resolver.ts +4 -2
  76. package/test/entry-workspace.test.ts +89 -0
  77. package/test/pasted-table.test.ts +140 -0
  78. package/test/spreadsheet-text.test.ts +92 -0
  79. package/translations/en.json +12 -1
  80. package/translations/ja.json +12 -1
  81. package/translations/ko.json +12 -1
  82. package/translations/ms.json +12 -1
  83. package/translations/zh.json +12 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.106",
3
+ "version": "10.1.108",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -35,11 +35,12 @@
35
35
  "@operato/styles": "^10.0.0",
36
36
  "@things-factory/ai-client-base": "^10.1.81",
37
37
  "@things-factory/apptool-base": "^10.1.106",
38
- "@things-factory/attachment-base": "^10.1.106",
39
- "@things-factory/auth-base": "^10.1.106",
38
+ "@things-factory/attachment-base": "^10.1.107",
39
+ "@things-factory/auth-base": "^10.1.107",
40
40
  "@things-factory/env": "^10.1.20",
41
41
  "@things-factory/shell": "^10.1.106",
42
42
  "dompurify": "^3.0.0",
43
+ "exceljs": "4.2.0",
43
44
  "graphql-scalars": "^1.22.0",
44
45
  "graphql-tag": "^2.12.6",
45
46
  "graphql-upload": "^15.0.2",
@@ -51,5 +52,5 @@
51
52
  "copyfiles": "^2.4.1",
52
53
  "rimraf": "^5.0.0"
53
54
  },
54
- "gitHead": "8d78c14708420773ec32d18b8dd44c300771c44d"
55
+ "gitHead": "b9e1747b2796c323ba8b0eebce7b4628be1e3fd0"
55
56
  }
@@ -55,7 +55,7 @@ import { inviteMentionedUsers } from './chat-session-participant/mention-invite.
55
55
  import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
56
56
  import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
57
57
  import { assertMayWriteSession } from './chat-session/session-write-gate.js'
58
- import { attachmentHistoryLine, refuseAttachments } from './chat-session/attachment-policy.js'
58
+ import { attachmentHistoryLine, refuseAttachments, textCharsOf } from './chat-session/attachment-policy.js'
59
59
  import { attachmentForModel, describeAttachment, findChatAttachment } from './chat-session/session-attachments.js'
60
60
  import { getAssistantMode } from './assistant-mode/registry.js'
61
61
  import { resolveModeSettings } from './assistant-mode/resolve.js'
@@ -444,7 +444,7 @@ export class AssistantChatResolver {
444
444
  if (last.role === 'user' && typeof last.content === 'string') {
445
445
  const parts: any[] = [{ type: 'text', text: last.content }]
446
446
  for (const row of attachmentRows) {
447
- const file = await attachmentForModel(row)
447
+ const file = await attachmentForModel(row, textCharsOf(getAssistantMode(input.mode?.kind ?? '')?.attachments))
448
448
  if (file.type === 'image') {
449
449
  parts.push({ type: 'image', source: { kind: 'base64', data: file.data, mediaType: file.mediaType } })
450
450
  } else {
@@ -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')[], maxBytes, maxFiles }. Null: none."
61
+ description: "What this conversation accepts as attachments: { kinds: ('image'|'text'|'spreadsheet')[], maxBytes, maxFiles, maxChars }. Null: none."
62
62
  })
63
63
  attachments?: any
64
64
  }
@@ -6,7 +6,11 @@
6
6
  * showed them to the model - the person got an answer about a file it had never seen (AI standard §7).
7
7
  */
8
8
 
9
- export type AttachmentKind = 'image' | 'text'
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.
12
+ */
13
+ export type AttachmentKind = 'image' | 'text' | 'spreadsheet'
10
14
 
11
15
  export interface AttachmentPolicy {
12
16
  kinds: AttachmentKind[]
@@ -14,6 +18,8 @@ export interface AttachmentPolicy {
14
18
  maxBytes?: number
15
19
  /** Per message. */
16
20
  maxFiles?: number
21
+ /** Per text file, the characters the model reads - a sheet read into text longer than this is refused, not cut. */
22
+ maxChars?: number
17
23
  }
18
24
 
19
25
  export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024
@@ -41,6 +47,16 @@ export type AttachmentRefusal =
41
47
  | { code: 'too-many-attachments'; maxFiles: number }
42
48
 
43
49
  /** `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. */
51
+ function takes(policy: AttachmentPolicy, kind: AttachmentKind): boolean {
52
+ return policy.kinds.includes(kind) || (kind === 'text' && policy.kinds.includes('spreadsheet'))
53
+ }
54
+
55
+ /** The characters of a text file the model reads under this policy. */
56
+ export function textCharsOf(policy: AttachmentPolicy | null | undefined): number {
57
+ return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL
58
+ }
59
+
44
60
  export function refuseAttachments(
45
61
  policy: AttachmentPolicy | null | undefined,
46
62
  files: { name?: string; type?: string; size?: number }[]
@@ -54,7 +70,7 @@ export function refuseAttachments(
54
70
  const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES
55
71
  for (const file of files) {
56
72
  const kind = attachmentKindOf(file)
57
- if (!kind || !policy.kinds.includes(kind)) {
73
+ if (!kind || !takes(policy, kind)) {
58
74
  return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }
59
75
  }
60
76
  if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }
@@ -16,6 +16,8 @@ import { ChatSession } from './chat-session.js'
16
16
  import { mayReadSession } from './session-visibility.js'
17
17
  import { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'
18
18
  import { attachmentForModel, CHAT_ATTACHMENT_REF } from './session-attachments.js'
19
+ import { textCharsOf } from './attachment-policy.js'
20
+ import { getAssistantMode } from '../assistant-mode/registry.js'
19
21
 
20
22
  export const CHAT_ATTACHMENT_CATEGORY = 'chat-attachments'
21
23
 
@@ -46,7 +48,7 @@ const readChatAttachment: ToolSpec = {
46
48
  return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }
47
49
  }
48
50
 
49
- const file = await attachmentForModel(row)
51
+ const file = await attachmentForModel(row, textCharsOf(getAssistantMode(session.kind || '')?.attachments))
50
52
  return file.type === 'image'
51
53
  ? { attachmentId: id, name: file.name, type: 'image', note: 'images are shown to you only in the turn they were sent; ask the person to send it again to look at it.' }
52
54
  : { attachmentId: id, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }
@@ -44,10 +44,11 @@ export async function findChatAttachment(domainId: string, sessionId: string, at
44
44
  }
45
45
 
46
46
  /**
47
- * What the model gets for a file: an image as bytes, a text file as its text (cut at TEXT_CHARS_FOR_MODEL,
47
+ * What the model gets for a file: an image as bytes, a text file as its text (cut at the mode's maxChars -
48
+ * TEXT_CHARS_FOR_MODEL when it declares none -
48
49
  * and the cut is said), anything else as a line that names it.
49
50
  */
50
- export async function attachmentForModel(row: any): Promise<
51
+ export async function attachmentForModel(row: any, maxChars: number = TEXT_CHARS_FOR_MODEL): Promise<
51
52
  | { type: 'image'; data: Buffer; mediaType: string; name: string }
52
53
  | { type: 'text'; text: string; name: string; truncated: boolean }
53
54
  > {
@@ -57,8 +58,8 @@ export async function attachmentForModel(row: any): Promise<
57
58
  return { type: 'image', data: Buffer.isBuffer(data) ? data : Buffer.from(data), mediaType: row.mimetype, name: row.name }
58
59
  }
59
60
  const content = String(await STORAGE.readFile(row.path, 'utf8'))
60
- const truncated = content.length > TEXT_CHARS_FOR_MODEL
61
- return { type: 'text', text: truncated ? content.slice(0, TEXT_CHARS_FOR_MODEL) : content, name: row.name, truncated }
61
+ const truncated = content.length > maxChars
62
+ return { type: 'text', text: truncated ? content.slice(0, maxChars) : content, name: row.name, truncated }
62
63
  }
63
64
 
64
65
  /** Removes these files from storage and their rows. */
@@ -175,6 +175,16 @@ describe('entering what the person pressed', () => {
175
175
  expect(await applyEntryProposal('k2', deps(messages))).toMatchObject({ verdict: 'accepted', applied: 1 })
176
176
  })
177
177
 
178
+ it('★ reviewed in the workspace, a proposal is entered only as it was shown there (workspace.md §6-1)', async () => {
179
+ const { entryProposalHash } = await import('./entry-hash.js')
180
+ const shownThen = proposal('k1', [row({ spentOn: '2026-09-12', amount: 1 })])
181
+ const reviewed = entryProposalHash(shownThen)
182
+ const nowStored = [{ id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 100 })])] }]
183
+ expect(await applyEntryProposal('k1', deps(nowStored, { reviewedHash: reviewed }))).toMatchObject({ verdict: 'refused', errorCode: 'review-stale', applied: 0 })
184
+ expect(written).toEqual([])
185
+ expect(await applyEntryProposal('k1', deps([{ id: 'm1', proposals: [shownThen] }], { reviewedHash: reviewed }))).toMatchObject({ verdict: 'accepted', applied: 1 })
186
+ })
187
+
178
188
  it('★ the door is asked again at the moment of writing', async () => {
179
189
  const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 1 })])] }]
180
190
  expect(await applyEntryProposal('k1', deps(messages, { passesDoor: async () => false }))).toMatchObject({ verdict: 'refused', errorCode: 'door-closed' })
@@ -13,6 +13,7 @@
13
13
  import { PROPOSAL_OUTCOME_PREFIX, PROPOSAL_VERDICTS } from '../chat-message/proposal-outcome.js'
14
14
  import { readRecords, stillAsProposed, withChangeChecks, type ChangeRow } from './entry-update.js'
15
15
  import { changeToolName, zonedDateTimes, entryGroups, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
16
+ import { entryProposalHash } from './entry-hash.js'
16
17
 
17
18
  export interface StoredProposalMessage {
18
19
  id: string
@@ -49,6 +50,8 @@ export interface EntryApplyDeps {
49
50
  execute(document: string, variables: Record<string, unknown>): Promise<{ data?: any; errors?: readonly { message: string }[] }>
50
51
  state: any
51
52
  report?(message: string, error: unknown): void
53
+ /** The fingerprint of what the AI 작업판 showed, when the person reviewed it there - entered only if the proposal is still that one. */
54
+ reviewedHash?: string
52
55
  }
53
56
 
54
57
  const nothing = (verdict: EntryApplyOutcome['verdict'], errorCode: string): EntryApplyOutcome => ({ verdict, errorCode, applied: 0, missed: 0, rows: [] })
@@ -73,6 +76,8 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
73
76
  if (proposal.kind !== 'entry') return nothing('refused', 'not-an-entry-proposal')
74
77
  if (superseded) return nothing('refused', 'superseded')
75
78
  if (deps.answered) return nothing('refused', 'already-answered')
79
+ /* What the 작업판 showed and what is entered are one proposal (design/ai/workspace.md §6-1). */
80
+ if (deps.reviewedHash && deps.reviewedHash !== entryProposalHash(proposal)) return nothing('refused', 'review-stale')
76
81
 
77
82
  const target = targetOf(proposal.target)
78
83
  if (!target) return nothing('unavailable', 'unknown-entry-target')
@@ -0,0 +1,35 @@
1
+ /*
2
+ * One 입력안's fingerprint - what the AI 작업판 showed and what the card enters are the same proposal
3
+ * (operato-application/design/ai/workspace.md §6-1). The screen's copy and the server's are one rule;
4
+ * `test/entry-hash-agree.test.ts` runs both over one table.
5
+ *
6
+ * It covers what is entered: the key, the target, create or change, and each row's index, record and values.
7
+ */
8
+
9
+ const canonical = (value: unknown): string => {
10
+ if (value === null || value === undefined) return 'null'
11
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
12
+ if (typeof value === 'object')
13
+ return `{${Object.keys(value as object)
14
+ .filter(name => (value as any)[name] !== undefined)
15
+ .sort()
16
+ .map(name => `${JSON.stringify(name)}:${canonical((value as any)[name])}`)
17
+ .join(',')}}`
18
+ return JSON.stringify(value)
19
+ }
20
+
21
+ /** FNV-1a over the canonical text - a fingerprint to compare, not a secret. */
22
+ export function entryProposalHash(proposal: any): string {
23
+ const text = canonical({
24
+ key: proposal?.key,
25
+ target: proposal?.target,
26
+ op: proposal?.op ?? 'create',
27
+ rows: (proposal?.rows ?? []).map((row: any) => ({ index: row?.index, id: row?.id, values: row?.values ?? {} }))
28
+ })
29
+ let hash = 0x811c9dc5
30
+ for (let i = 0; i < text.length; i++) {
31
+ hash ^= text.charCodeAt(i)
32
+ hash = Math.imul(hash, 0x01000193) >>> 0
33
+ }
34
+ return `${hash.toString(16).padStart(8, '0')}-${text.length.toString(16)}`
35
+ }
@@ -28,7 +28,8 @@ export class EntryProposalResolver {
28
28
  async applyEntryProposal(
29
29
  @Arg('sessionId') sessionId: string,
30
30
  @Arg('proposalKey') proposalKey: string,
31
- @Ctx() context: ResolverContext
31
+ @Ctx() context: ResolverContext,
32
+ @Arg('reviewedHash', { nullable: true, description: 'The fingerprint of the proposal as the AI workspace showed it; a proposal that is no longer that one is refused.' }) reviewedHash?: string
32
33
  ): Promise<EntryApplyOutcome> {
33
34
  const { domain, user } = context.state
34
35
  const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } } as any)
@@ -63,7 +64,8 @@ export class EntryProposalResolver {
63
64
  * door opens its own transaction per row (entry-tools.ts `runAsCaller`).
64
65
  */
65
66
  execute: runAsCaller(context),
66
- report: (message, error) => logger.error(message, error)
67
+ report: (message, error) => logger.error(message, error),
68
+ reviewedHash: reviewedHash || undefined
67
69
  })
68
70
  }
69
71
  }
@@ -0,0 +1,89 @@
1
+ /*
2
+ * Reviewing a 입력안 in the AI 작업판 (operato-application/design/ai/workspace.md §6-1): read-only on the page,
3
+ * entered from the dock's card - and only as the page showed it.
4
+ */
5
+ import { readFileSync } from 'fs'
6
+ import { join } from 'path'
7
+
8
+ import * as screen from '../client/utils/entry-hash'
9
+ import * as server from '../server/service/data-entry/entry-hash'
10
+ import { entryForReview, entryReviewAddress, offersWorkspaceReview, rememberReviewed, reviewedHashOf, reviewStillHolds } from '../client/utils/entry-workspace'
11
+ import { entryProposalHandler } from '../client/utils/entry-proposal'
12
+
13
+ const proposal = (key: string, values: any[], more: any = {}) => ({ key, kind: 'entry', target: 'office.contract', rows: values.map((v, index) => ({ index, values: v, sources: [], issues: [] })), columns: [], ...more })
14
+
15
+ describe('the fingerprint', () => {
16
+ const table = [
17
+ proposal('k1', [{ name: 'A', amount: 1 }]),
18
+ proposal('k1', [{ amount: 1, name: 'A' }]),
19
+ proposal('k1', [{ name: 'A', amount: 2 }]),
20
+ proposal('k2', [{ name: 'A', amount: 1 }]),
21
+ { ...proposal('k1', [{ name: 'A' }]), op: 'update' },
22
+ proposal('k1', [{ name: 'A', ref: { id: 'x', label: '엑스' } }]),
23
+ {}
24
+ ]
25
+ it('is one rule on the screen and at the door', () => {
26
+ for (const one of table) expect(screen.entryProposalHash(one)).toBe(server.entryProposalHash(one))
27
+ })
28
+ it('ignores the order of fields, and moves with anything entered', () => {
29
+ expect(screen.entryProposalHash(table[0])).toBe(screen.entryProposalHash(table[1]))
30
+ const distinct = new Set([table[0], table[2], table[3], table[4], table[5]].map(screen.entryProposalHash))
31
+ expect(distinct.size).toBe(5)
32
+ })
33
+ })
34
+
35
+ describe('reviewing in the workspace', () => {
36
+ it('is offered when the card is long or wide', () => {
37
+ expect(offersWorkspaceReview(proposal('k', Array.from({ length: 21 }, () => ({}))))).toBe(true)
38
+ expect(offersWorkspaceReview(proposal('k', [{}], { columns: Array.from({ length: 7 }, (_, i) => ({ name: `c${i}` })) }))).toBe(true)
39
+ expect(offersWorkspaceReview(proposal('k', [{}], { columns: [{ name: 'a' }] }))).toBe(false)
40
+ expect(entryReviewAddress('s 1', 'k/1')).toBe('assistant-workspace?session=s+1&entry=k%2F1')
41
+ })
42
+
43
+ it('finds the card by its key and says when a newer card of the same record replaced it', () => {
44
+ const messages = [
45
+ { id: 'm1', createdAt: 't1', proposalsJson: [proposal('k1', [{}])] },
46
+ { id: 'm2', proposalsJson: [{ ...proposal('u1', [{}]), op: 'update' }] },
47
+ { id: 'm3', proposalsJson: [proposal('k2', [{}])] }
48
+ ]
49
+ expect(entryForReview(messages, 'k1')).toMatchObject({ createdAt: 't1', superseded: true })
50
+ expect(entryForReview(messages, 'u1').superseded).toBe(false)
51
+ expect(entryForReview(messages, 'k2').superseded).toBe(false)
52
+ expect(entryForReview(messages, 'none')).toEqual({ superseded: false })
53
+ })
54
+
55
+ it('remembers what was shown; the card holds only while the proposal is that one', () => {
56
+ const store = new Map<string, string>()
57
+ const storage = { setItem: (k: string, v: string) => void store.set(k, v), getItem: (k: string) => store.get(k) ?? null }
58
+ const shown = proposal('k1', [{ amount: 1 }])
59
+ rememberReviewed(shown, storage)
60
+ const reviewed = reviewedHashOf('k1', storage)
61
+ expect(reviewStillHolds(shown, reviewed)).toBe(true)
62
+ expect(reviewStillHolds(proposal('k1', [{ amount: 9 }]), reviewed)).toBe(false)
63
+ expect(reviewStillHolds(shown, undefined)).toBe(true)
64
+ const blocked = { setItem: () => { throw new Error('blocked') }, getItem: () => { throw new Error('blocked') } }
65
+ expect(() => rememberReviewed(shown, blocked as any)).not.toThrow()
66
+ expect(reviewedHashOf('k1', blocked as any)).toBeUndefined()
67
+ })
68
+
69
+ it('★ the card refuses a proposal that changed after review, before asking the server - and sends the fingerprint when it holds', async () => {
70
+ const asked: any[] = []
71
+ const t = (key: string, options?: any) => options?.defaultValue ?? key
72
+ const shown = proposal('k1', [{ amount: 1 }])
73
+ const reviewed = screen.entryProposalHash(shown)
74
+ const handler = entryProposalHandler(async (...args) => (asked.push(args), { verdict: 'accepted', applied: 1, missed: 0, rows: [] }), t, () => reviewed)
75
+ const stale = await handler(proposal('k1', [{ amount: 2 }]), { sessionId: 's1' } as any)
76
+ expect(stale).toMatchObject({ verdict: 'refused', errorCode: 'review-stale' })
77
+ expect(asked).toEqual([])
78
+ expect(await handler(shown, { sessionId: 's1' } as any)).toMatchObject({ verdict: 'accepted' })
79
+ expect(asked).toEqual([['s1', 'k1', reviewed]])
80
+ })
81
+
82
+ it('the page is read-only - it draws the review wide and enters nothing; the dock card opens it', () => {
83
+ const page = readFileSync(join(__dirname, '..', 'client', 'pages', 'assistant-workspace-page.ts'), 'utf8')
84
+ expect(page).toContain('<ox-assistant-entry-review wide .proposal=${p}>')
85
+ expect(page).not.toMatch(/applyEntryProposal|runProposal|executeProposal/)
86
+ const chat = readFileSync(join(__dirname, '..', 'client', 'components', 'assistant-chat.ts'), 'utf8')
87
+ expect(chat).toContain('entryReviewAddress(this.sessionId!, key)')
88
+ })
89
+ })
@@ -0,0 +1,140 @@
1
+ /*
2
+ * A table pasted into the composer (client/utils/pasted-table.ts) - what is a table, what is words, and the file it
3
+ * becomes. The clipboards are the shapes Excel, Google Sheets and a browser put there.
4
+ */
5
+ import { readFileSync } from 'fs'
6
+ import { join } from 'path'
7
+
8
+ import { pastedTable, pastedTableFile, pastedTableText, tableOfHtml, tableOfText } from '../client/utils/pasted-table'
9
+
10
+ /* Excel for the web / desktop: a whole document, a style block, a conditional comment, classes, &nbsp; in an empty cell. */
11
+ const EXCEL_HTML = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">
12
+ <head><meta charset="utf-8"><style>
13
+ .xl65 { mso-number-format:"\\#\\,\\#\\#0"; }
14
+ td { padding: 0 }
15
+ </style></head>
16
+ <body link="#0563C1">
17
+ <!--StartFragment-->
18
+ <table border=0 cellpadding=0 cellspacing=0 width=300>
19
+ <col width=100 span=3>
20
+ <tr height=20>
21
+ <td height=20 width=100>이용일</td>
22
+ <td width=100>가맹점</td>
23
+ <td width=100>이용금액</td>
24
+ </tr>
25
+ <tr height=20>
26
+ <td height=20 class=xl66 align=right>2026-09-12</td>
27
+ <td>카카오T</td>
28
+ <td class=xl65 align=right>12,400</td>
29
+ </tr>
30
+ <tr height=20>
31
+ <td height=20 class=xl66 align=right>2026-09-13</td>
32
+ <td>스타벅스 &amp; 브런치</td>
33
+ <td class=xl65 align=right>5,000</td>
34
+ </tr>
35
+ <tr height=20>
36
+ <td height=20>&nbsp;</td>
37
+ <td>합계</td>
38
+ <td class=xl65 align=right>17,400</td>
39
+ </tr>
40
+ </table>
41
+ <!--EndFragment-->
42
+ </body></html>`
43
+
44
+ const EXCEL_TEXT = '이용일\t가맹점\t이용금액\r\n2026-09-12\t카카오T\t12,400\r\n2026-09-13\t스타벅스 & 브런치\t5,000\r\n\t합계\t17,400\r\n'
45
+
46
+ /* Google Sheets: its own wrapper and a colgroup, cells without closing tags never happen here but a <br> in a cell does. */
47
+ const SHEETS_HTML =
48
+ '<google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}--></style>' +
49
+ '<table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1"><colgroup><col width="100"/><col width="100"/></colgroup>' +
50
+ '<tbody><tr style="height:21px;"><td style="overflow:hidden;">품목</td><td>비고</td></tr>' +
51
+ '<tr style="height:21px;"><td>볼트 M8</td><td>1차 납품<br/>나머지 다음 주</td></tr></tbody></table></google-sheets-html-origin>'
52
+
53
+ describe('a pasted table', () => {
54
+ it('★ Excel - the HTML is read: the header, the rows, an empty cell kept in its column, &amp; decoded', () => {
55
+ const table = pastedTable({ html: EXCEL_HTML, text: EXCEL_TEXT })
56
+ expect(table).toEqual({
57
+ columns: 3,
58
+ rows: [
59
+ ['이용일', '가맹점', '이용금액'],
60
+ ['2026-09-12', '카카오T', '12,400'],
61
+ ['2026-09-13', '스타벅스 & 브런치', '5,000'],
62
+ ['', '합계', '17,400']
63
+ ]
64
+ })
65
+ })
66
+
67
+ it('★ the plain text alone gives the same table - a spreadsheet\'s tab-separated lines', () => {
68
+ expect(pastedTable({ text: EXCEL_TEXT })?.rows).toEqual(pastedTable({ html: EXCEL_HTML })?.rows)
69
+ })
70
+
71
+ it('Google Sheets - its wrapper and style are not cells, a line break in a cell stays in the cell', () => {
72
+ expect(tableOfHtml(SHEETS_HTML)).toEqual([
73
+ ['품목', '비고'],
74
+ ['볼트 M8', '1차 납품 나머지 다음 주']
75
+ ])
76
+ })
77
+
78
+ it('a cell spanning columns keeps the next cells in their columns', () => {
79
+ expect(tableOfHtml('<table><tr><td colspan="2">9월</td><td>합계</td></tr><tr><td>a</td><td>b</td><td>3</td></tr></table>')).toEqual([
80
+ ['9월', '', '합계'],
81
+ ['a', 'b', '3']
82
+ ])
83
+ })
84
+
85
+ it('a quoted cell of the plain text holds its line break and its tab', () => {
86
+ expect(tableOfText('품목\t비고\n볼트\t"1차\n2차\t분할"\n너트\t-\n')).toEqual([
87
+ ['품목', '비고'],
88
+ ['볼트', '1차 2차 분할'],
89
+ ['너트', '-']
90
+ ])
91
+ })
92
+
93
+ it('★ a web page whose words lie mostly outside its table is words, not a table paste', () => {
94
+ const page = '<p>9월 경비 정산 안내입니다. 아래 표를 참고하시고 이번 주 금요일까지 제출해 주십시오.</p><table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>'
95
+ expect(tableOfHtml(page)).toBeNull()
96
+ expect(pastedTable({ html: page, text: '9월 경비 정산 안내입니다.\n\na\tb\nc\td' })).toBeNull()
97
+ })
98
+
99
+ it('★ words are words - a sentence, a sentence with one tab, one row, one column', () => {
100
+ expect(pastedTable({ text: '카카오T 12,400원 경비로 올려 줘' })).toBeNull()
101
+ expect(pastedTable({ text: '메모:\t내일 다시\n오늘은 여기까지\n그리고 하나 더' })).toBeNull()
102
+ expect(pastedTable({ text: 'a\tb\tc\n' })).toBeNull()
103
+ expect(pastedTable({ html: '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>' })).toBeNull()
104
+ })
105
+
106
+ it('★ the file has the shape of a read sheet - a line naming it, each row led by its number, CSV quoting', () => {
107
+ const table = pastedTable({ text: EXCEL_TEXT })!
108
+ expect(pastedTableText(table, '붙여넣은 표').split('\n')).toEqual([
109
+ '# 붙여넣은 표 · a table the person pasted · the first column is the row number in the pasted table',
110
+ '1,이용일,가맹점,이용금액',
111
+ '2,2026-09-12,카카오T,"12,400"',
112
+ '3,2026-09-13,스타벅스 & 브런치,"5,000"',
113
+ '4,,합계,"17,400"',
114
+ ''
115
+ ])
116
+ })
117
+
118
+ const nameOf = (rows: number) => `붙여넣은 표 ${rows}행`
119
+
120
+ it('★ a conversation that takes text gets a CSV file - one that takes none keeps the paste as words', () => {
121
+ expect(pastedTableFile({ text: EXCEL_TEXT }, { kinds: ['image', 'text'] }, nameOf)).toEqual({ name: '붙여넣은 표 4행.csv', text: pastedTableText(pastedTable({ text: EXCEL_TEXT })!, '붙여넣은 표 4행') })
122
+ expect(pastedTableFile({ text: EXCEL_TEXT }, { kinds: ['spreadsheet'] }, nameOf)).toMatchObject({ name: '붙여넣은 표 4행.csv' })
123
+ expect(pastedTableFile({ text: EXCEL_TEXT }, { kinds: ['image'] }, nameOf)).toBeNull()
124
+ expect(pastedTableFile({ text: EXCEL_TEXT }, null, nameOf)).toBeNull()
125
+ expect(pastedTableFile({ text: '그냥 글입니다' }, { kinds: ['text'] }, nameOf)).toBeNull()
126
+ })
127
+
128
+ it('★ a table longer than the model reads is refused, never cut', () => {
129
+ expect(pastedTableFile({ text: EXCEL_TEXT }, { kinds: ['text'], maxChars: 50 }, nameOf)).toEqual({ refusal: { code: 'attachment-paste-too-long', rows: 4, maxChars: 50 } })
130
+ })
131
+
132
+ it('★ the composer takes the table before the files - the picture a spreadsheet puts beside it is not attached', () => {
133
+ const source = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
134
+ const paste = source.slice(source.indexOf('private onComposerPaste('), source.indexOf('private onComposerDragOver('))
135
+ expect(paste.indexOf('pastedTableFile(')).toBeGreaterThan(0)
136
+ expect(paste.indexOf('pastedTableFile(')).toBeLessThan(paste.indexOf("item.kind === 'file'"))
137
+ expect(paste).toMatch(/e\.preventDefault\(\)[\s\S]*?return\n/)
138
+ })
139
+ })
140
+
@@ -0,0 +1,92 @@
1
+ /*
2
+ * A spreadsheet read into the text a conversation carries (design/ai/shared-dock.md §9-5): computed values, ISO
3
+ * dates, the sheet's own row numbers kept, .xls refused by name, and a sheet longer than the limit refused - never cut.
4
+ */
5
+ import ExcelJS from 'exceljs'
6
+
7
+ import * as server from '../server/service/chat-session/attachment-policy'
8
+ import * as screen from '../client/utils/attachment-policy'
9
+ import { cellText, readSpreadsheet, sheetFileName, sheetText, spreadsheetKindOf } from '../client/utils/spreadsheet-text'
10
+
11
+ async function workbook(build: (wb: any) => void): Promise<Uint8Array> {
12
+ const wb = new ExcelJS.Workbook()
13
+ build(wb)
14
+ return new Uint8Array(await wb.xlsx.writeBuffer())
15
+ }
16
+
17
+ describe('a spreadsheet as text', () => {
18
+ it('keeps the sheet\'s own row numbers - an empty row is left out, never renumbered', async () => {
19
+ const data = await workbook(wb => {
20
+ const ws = wb.addWorksheet('9월')
21
+ ws.getCell('A1').value = '거래일'
22
+ ws.getCell('B1').value = '금액'
23
+ ws.getCell('A2').value = new Date(Date.UTC(2026, 8, 22))
24
+ ws.getCell('B2').value = 4400000
25
+ /* row 3 empty */
26
+ ws.getCell('A4').value = new Date(Date.UTC(2026, 8, 23, 9, 30))
27
+ ws.getCell('B4').value = { formula: 'B2/8', result: 550000 }
28
+ ws.getCell('C4').value = '메모, "따옴표"'
29
+ })
30
+ const read: any = await readSpreadsheet(data, '거래내역.xlsx', 20_000, ExcelJS.Workbook)
31
+ expect(read.files.map((one: any) => one.name)).toEqual(['거래내역 - 9월.csv'])
32
+ expect(read.files[0].text.split('\n').slice(1, 4)).toEqual(['1,거래일,금액', '2,2026-09-22,4400000', '4,2026-09-23T09:30:00,550000,"메모, ""따옴표"""'])
33
+ })
34
+
35
+ it('reads every sheet with something in it, and says so when none has', async () => {
36
+ const two = await workbook(wb => {
37
+ wb.addWorksheet('빈 시트')
38
+ wb.addWorksheet('계약').getCell('A1').value = 'H2026001'
39
+ })
40
+ expect(((await readSpreadsheet(two, 'a.xlsx', 20_000, ExcelJS.Workbook)) as any).files.map((one: any) => one.name)).toEqual(['a - 계약.csv'])
41
+ const none = await workbook(wb => wb.addWorksheet('빈 시트'))
42
+ expect(await readSpreadsheet(none, 'b.xlsx', 20_000, ExcelJS.Workbook)).toEqual({ refusal: { code: 'attachment-sheet-empty', name: 'b.xlsx' } })
43
+ })
44
+
45
+ it('refuses a sheet longer than the limit as text - a small file can be a long text - and never cuts it', async () => {
46
+ const data = await workbook(wb => {
47
+ const ws = wb.addWorksheet('원장')
48
+ for (let i = 1; i <= 400; i++) ws.addRow([i, '가나다라마바사아자차카타파하', 1234567])
49
+ })
50
+ expect(data.byteLength).toBeLessThan(20_000)
51
+ expect(await readSpreadsheet(data, 'big.xlsx', 5_000, ExcelJS.Workbook)).toEqual({ refusal: { code: 'attachment-sheet-too-long', name: 'big.xlsx · 원장', maxChars: 5_000 } })
52
+ const fits: any = await readSpreadsheet(data, 'big.xlsx', 50_000, ExcelJS.Workbook)
53
+ expect(fits.files[0].text.trim().split('\n')).toHaveLength(401)
54
+ })
55
+
56
+ it('names a file that is not a workbook instead of failing silently', async () => {
57
+ expect(await readSpreadsheet(new TextEncoder().encode('not a zip'), 'x.xlsx', 20_000, ExcelJS.Workbook)).toEqual({ refusal: { code: 'attachment-sheet-unreadable', name: 'x.xlsx' } })
58
+ })
59
+
60
+ it('tells .xlsx from the old .xls, by type or by name', () => {
61
+ expect(spreadsheetKindOf({ name: '원장.XLSX' })).toBe('xlsx')
62
+ expect(spreadsheetKindOf({ name: 'x', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })).toBe('xlsx')
63
+ expect(spreadsheetKindOf({ name: '옛 원장.xls' })).toBe('xls')
64
+ expect(spreadsheetKindOf({ name: 'a.csv', type: 'text/csv' })).toBeNull()
65
+ })
66
+
67
+ it('reads a cell as Excel shows its value', () => {
68
+ expect(cellText({ richText: [{ text: '굵은' }, { text: ' 글' }] })).toBe('굵은 글')
69
+ expect(cellText({ text: '링크', hyperlink: 'https://x' })).toBe('링크')
70
+ expect(cellText({ formula: 'A1+1' })).toBe('')
71
+ expect(cellText({ error: '#DIV/0!' })).toBe('#DIV/0!')
72
+ expect(cellText(true)).toBe('true')
73
+ expect(sheetText('f.xlsx', 's', [{ number: 7, cells: ['a', '', ''] }])).toBe('# f.xlsx · sheet "s" · the first column is the sheet\'s own row number\n7,a\n')
74
+ expect(sheetFileName('a/b.xlsx', '9/10월')).toBe('a/b - 9_10월.csv')
75
+ })
76
+ })
77
+
78
+ describe('a mode that takes spreadsheets takes their text - on the screen and at the door alike', () => {
79
+ const policy = { kinds: ['spreadsheet'] } as any
80
+ const sheet = [{ name: '거래내역 - 9월.csv', type: 'text/csv', size: 10 }]
81
+ it('the sheets\' text passes; an image does not', () => {
82
+ for (const rule of [screen, server]) {
83
+ expect(rule.refuseAttachments(policy, sheet)).toBeNull()
84
+ expect(rule.refuseAttachments(policy, [{ name: 'a.png', type: 'image/png', size: 1 }])).toEqual({ code: 'attachment-kind-not-accepted', name: 'a.png', kinds: ['spreadsheet'] })
85
+ }
86
+ })
87
+ it('the limit of text the model reads is the mode\'s, the same on both sides', () => {
88
+ expect(screen.textCharsOf({ kinds: ['spreadsheet'], maxChars: 150_000 })).toBe(150_000)
89
+ expect(screen.textCharsOf(undefined)).toBe(server.TEXT_CHARS_FOR_MODEL)
90
+ expect(screen.TEXT_CHARS_FOR_MODEL).toBe(server.TEXT_CHARS_FOR_MODEL)
91
+ })
92
+ })
@@ -278,5 +278,16 @@
278
278
  "ai-assistant.entry.issue.not-a-reference": "not a record it can point to",
279
279
  "ai-assistant.entry.groups": "{count} records",
280
280
  "ai-assistant.entry.groups-kept-out": "{count} records kept out whole",
281
- "ai-assistant.entry.issue.check-failed": "could not be checked - not changed"
281
+ "ai-assistant.entry.issue.check-failed": "could not be checked - not changed",
282
+ "ai-assistant.text.attachment-xls-not-read": "{name} is an old Excel file (.xls) and is not read. Save it as .xlsx in Excel and attach it again.",
283
+ "ai-assistant.text.attachment-sheet-too-long": "The sheet {name} is longer than {maxChars} characters as text and was not sent. Split it and attach the parts.",
284
+ "ai-assistant.text.attachment-paste-too-long": "The pasted table is longer than {maxChars} characters as text and was not sent. Paste it in parts.",
285
+ "ai-assistant.text.pasted-table-name": "pasted table ({rows} rows)",
286
+ "ai-assistant.text.attachment-sheet-unreadable": "{name} cannot be read as an Excel file.",
287
+ "ai-assistant.text.attachment-sheet-empty": "{name} has no sheet with anything in it.",
288
+ "ai-assistant.entry.review-in-workspace": "review in the workspace",
289
+ "ai-assistant.entry.review-stale": "this proposal changed after it was reviewed in the workspace - review it again before entering.",
290
+ "ai-assistant.workspace.entry-not-found": "there is no proposal here that you can see.",
291
+ "ai-assistant.workspace.entry-superseded": "a newer card replaced this one in the conversation - review that one.",
292
+ "ai-assistant.workspace.entry-press-in-dock": "this is a review. Enter the rows with the button on the card in the AI dock."
282
293
  }
@@ -278,5 +278,16 @@
278
278
  "ai-assistant.entry.issue.not-a-reference": "参照先の記録が分かりません",
279
279
  "ai-assistant.entry.groups": "{count}件",
280
280
  "ai-assistant.entry.groups-kept-out": "{count}件は行が止まり全体を入力しません",
281
- "ai-assistant.entry.issue.check-failed": "検査できなかったため修正しません"
281
+ "ai-assistant.entry.issue.check-failed": "検査できなかったため修正しません",
282
+ "ai-assistant.text.attachment-xls-not-read": "{name} は古い Excel 形式 (.xls) のため読み込みません。Excel で .xlsx として保存し、もう一度添付してください。",
283
+ "ai-assistant.text.attachment-sheet-too-long": "シート {name} はテキストにすると {maxChars} 文字を超えるため送信しませんでした。分けて添付してください。",
284
+ "ai-assistant.text.attachment-paste-too-long": "貼り付けた表はテキストにすると {maxChars} 文字を超えるため送信しませんでした。分けて貼り付けてください。",
285
+ "ai-assistant.text.pasted-table-name": "貼り付けた表 {rows}行",
286
+ "ai-assistant.text.attachment-sheet-unreadable": "{name} を Excel ファイルとして読み込めません。",
287
+ "ai-assistant.text.attachment-sheet-empty": "{name} には内容のあるシートがありません。",
288
+ "ai-assistant.entry.review-in-workspace": "作業ボードで確認",
289
+ "ai-assistant.entry.review-stale": "作業ボードで確認した後に入力案が変わりました。もう一度確認してから入力してください。",
290
+ "ai-assistant.workspace.entry-not-found": "表示できる入力案はありません。",
291
+ "ai-assistant.workspace.entry-superseded": "会話の中で新しいカードがこのカードに置き換わりました。そちらを確認してください。",
292
+ "ai-assistant.workspace.entry-press-in-dock": "これは確認画面です。入力は AI ドックのカードのボタンで行います。"
282
293
  }
@@ -278,5 +278,16 @@
278
278
  "ai-assistant.entry.issue.not-a-reference": "가리키는 기록을 알 수 없습니다",
279
279
  "ai-assistant.entry.groups": "{count}건",
280
280
  "ai-assistant.entry.groups-kept-out": "{count}건은 줄이 막혀 통째로 입력하지 않습니다",
281
- "ai-assistant.entry.issue.check-failed": "검사를 하지 못해 고치지 않습니다"
281
+ "ai-assistant.entry.issue.check-failed": "검사를 하지 못해 고치지 않습니다",
282
+ "ai-assistant.text.attachment-xls-not-read": "{name} 은 옛 엑셀 형식(.xls)이라 읽지 않습니다. 엑셀에서 .xlsx 로 저장해 다시 첨부해 주십시오.",
283
+ "ai-assistant.text.attachment-sheet-too-long": "{name} 시트는 글자로 바꾸면 {maxChars} 자를 넘어 보내지 않았습니다. 시트를 나눠 첨부해 주십시오.",
284
+ "ai-assistant.text.attachment-paste-too-long": "붙여넣은 표가 글자로 {maxChars} 자를 넘어 보내지 않았습니다. 나눠서 붙여넣어 주십시오.",
285
+ "ai-assistant.text.pasted-table-name": "붙여넣은 표 {rows}행",
286
+ "ai-assistant.text.attachment-sheet-unreadable": "{name} 을 엑셀 파일로 읽을 수 없습니다.",
287
+ "ai-assistant.text.attachment-sheet-empty": "{name} 에 내용이 있는 시트가 없습니다.",
288
+ "ai-assistant.entry.review-in-workspace": "작업판에서 검토",
289
+ "ai-assistant.entry.review-stale": "작업판에서 검토한 뒤 입력안이 바뀌었습니다. 다시 검토한 뒤 입력하십시오.",
290
+ "ai-assistant.workspace.entry-not-found": "볼 수 있는 입력안이 없습니다.",
291
+ "ai-assistant.workspace.entry-superseded": "대화에서 더 새로운 카드가 이 카드를 대신했습니다. 그 카드를 검토하십시오.",
292
+ "ai-assistant.workspace.entry-press-in-dock": "검토 화면입니다. 입력은 AI 도크의 카드에 있는 단추로 합니다."
282
293
  }