@things-factory/ai-assistant 10.1.90 → 10.1.91

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 (53) hide show
  1. package/client/components/assistant-chat.ts +26 -0
  2. package/client/components/assistant-dock.ts +9 -1
  3. package/client/components/workspace-view.ts +428 -0
  4. package/client/pages/assistant-workspace-page.ts +237 -0
  5. package/client/route.ts +9 -0
  6. package/client/utils/workspace.ts +89 -0
  7. package/dist-client/components/assistant-chat.d.ts +2 -0
  8. package/dist-client/components/assistant-chat.js +27 -0
  9. package/dist-client/components/assistant-chat.js.map +1 -1
  10. package/dist-client/components/assistant-dock.d.ts +1 -0
  11. package/dist-client/components/assistant-dock.js +9 -1
  12. package/dist-client/components/assistant-dock.js.map +1 -1
  13. package/dist-client/components/workspace-view.d.ts +19 -0
  14. package/dist-client/components/workspace-view.js +416 -0
  15. package/dist-client/components/workspace-view.js.map +1 -0
  16. package/dist-client/pages/assistant-workspace-page.d.ts +27 -0
  17. package/dist-client/pages/assistant-workspace-page.js +263 -0
  18. package/dist-client/pages/assistant-workspace-page.js.map +1 -0
  19. package/dist-client/route.d.ts +1 -0
  20. package/dist-client/route.js +10 -0
  21. package/dist-client/route.js.map +1 -0
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/dist-client/utils/workspace.d.ts +40 -0
  24. package/dist-client/utils/workspace.js +68 -0
  25. package/dist-client/utils/workspace.js.map +1 -0
  26. package/dist-server/service/assistant-mode/resolve.d.ts +2 -2
  27. package/dist-server/service/assistant-mode/resolve.js +5 -3
  28. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  29. package/dist-server/service/index.d.ts +2 -0
  30. package/dist-server/service/index.js +3 -0
  31. package/dist-server/service/index.js.map +1 -1
  32. package/dist-server/service/workspace/workspace-blocks.d.ts +24 -0
  33. package/dist-server/service/workspace/workspace-blocks.js +118 -0
  34. package/dist-server/service/workspace/workspace-blocks.js.map +1 -0
  35. package/dist-server/service/workspace/workspace-tools.d.ts +24 -0
  36. package/dist-server/service/workspace/workspace-tools.js +65 -0
  37. package/dist-server/service/workspace/workspace-tools.js.map +1 -0
  38. package/dist-server/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +2 -2
  40. package/server/service/assistant-mode/resolve.test.ts +2 -1
  41. package/server/service/assistant-mode/resolve.ts +5 -3
  42. package/server/service/data-entry/data-entry.test.ts +2 -2
  43. package/server/service/index.ts +3 -0
  44. package/server/service/workspace/workspace-blocks.ts +127 -0
  45. package/server/service/workspace/workspace-tools.ts +66 -0
  46. package/server/service/workspace/workspace.test.ts +58 -0
  47. package/test/workspace.test.ts +54 -0
  48. package/things-factory.config.js +6 -1
  49. package/translations/en.json +21 -1
  50. package/translations/ja.json +21 -1
  51. package/translations/ko.json +21 -1
  52. package/translations/ms.json +21 -1
  53. package/translations/zh.json +21 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.90",
3
+ "version": "10.1.91",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -51,5 +51,5 @@
51
51
  "copyfiles": "^2.4.1",
52
52
  "rimraf": "^5.0.0"
53
53
  },
54
- "gitHead": "cc35c48a40020554ceed0b1801dc7955fe027e79"
54
+ "gitHead": "e9a052f7faaeb5884db1ff8b862b8f05567a31f9"
55
55
  }
@@ -34,7 +34,8 @@ it('★ the settings come from the registered mode', () => {
34
34
 
35
35
  expect(settings).toEqual({
36
36
  systemPrompt: 'you answer about one space',
37
- toolCategories: ['twin-ops'],
37
+ /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
38
+ toolCategories: ['twin-ops', 'assistant-workspace'],
38
39
  requireToolOnFirstTurn: true,
39
40
  model: 'claude-sonnet-5',
40
41
  temperature: 0.2,
@@ -44,15 +44,17 @@ export interface ModeSettings {
44
44
  }
45
45
 
46
46
  /**
47
- * The categories a mode opens: its own, the attachment reader when it takes files (design/ai/shared-dock.md
48
- * §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
47
+ * The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
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[] }): string[] | undefined {
51
51
  const extra = [
52
+ /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */
53
+ 'assistant-workspace',
52
54
  ...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
53
55
  ...(mode.entryTargets ?? []).map(key => `data-entry:${key}`)
54
56
  ]
55
- return extra.length ? [...(mode.toolCategories ?? []), ...extra] : mode.toolCategories
57
+ return [...new Set([...(mode.toolCategories ?? []), ...extra])]
56
58
  }
57
59
 
58
60
  /** The conversation's settings, from the server's declaration. */
@@ -66,8 +66,8 @@ describe('declaring a target', () => {
66
66
  })
67
67
 
68
68
  it('a mode opens a target by naming it', () => {
69
- expect(modeToolCategories({ toolCategories: ['office-ops'], entryTargets: ['office.expense-line'] })).toEqual(['office-ops', 'data-entry:office.expense-line'])
70
- expect(modeToolCategories({ toolCategories: ['office-ops'] })).toEqual(['office-ops'])
69
+ expect(modeToolCategories({ toolCategories: ['office-ops'], entryTargets: ['office.expense-line'] })).toEqual(['office-ops', 'assistant-workspace', 'data-entry:office.expense-line'])
70
+ expect(modeToolCategories({ toolCategories: ['office-ops'] })).toEqual(['office-ops', 'assistant-workspace'])
71
71
  })
72
72
  })
73
73
 
@@ -11,6 +11,7 @@ import './chat-session/attachment-tools.js'
11
11
  import './chat-session/session-purge.js'
12
12
  import './chat-session/attachment-route.js'
13
13
  import { EntryProposalResolver } from './data-entry/entry-resolver.js'
14
+ import './workspace/workspace-tools.js'
14
15
 
15
16
  export * from './types.js'
16
17
  export * from './chat-session-resolver.js'
@@ -27,6 +28,8 @@ export * from './tool-access.js'
27
28
  /* Data entry from material - an app registers what it can enter; a mode names it (design/ai/data-entry.md). */
28
29
  export * from './data-entry/entry-target.js'
29
30
  export { registerEntryTarget, entryCategoryOf } from './data-entry/entry-tools.js'
31
+ /* The AI 작업판 - large results on a page of their own (design/ai/workspace.md). */
32
+ export * from './workspace/workspace-blocks.js'
30
33
  /* 무엇이 근거인가 — 도구 결과 밖의 셋(프롬프트 · 대화 · 화면 스냅숏). */
31
34
  export * from './grounding-sources.js'
32
35
  /* 앵커 순수 로직 — 호스트가 앵커 종류를 참조한다(리졸버는 데코레이터 때문에 무겁다). */
@@ -0,0 +1,127 @@
1
+ /*
2
+ * The AI 작업판 - what a workspace is made of (operato-application/design/ai/workspace.md §3).
3
+ *
4
+ * The model does not write HTML. A workspace is a list of blocks of known kinds; the screen draws each, so
5
+ * theme, language and accessibility are the framework's. A block that does not fit is not dropped: it is
6
+ * kept as a fact in its place ("a block that cannot be drawn"), so the person sees that something was meant
7
+ * to be there. Pure - tested without a server.
8
+ */
9
+
10
+ export const WORKSPACE_BLOCK_KINDS = ['heading', 'text', 'metrics', 'table', 'chart', 'steps'] as const
11
+ export type WorkspaceBlockKind = (typeof WORKSPACE_BLOCK_KINDS)[number]
12
+
13
+ export const MAX_BLOCKS = 40
14
+ export const MAX_TABLE_ROWS = 1000
15
+ export const MAX_SERIES = 8
16
+ const MAX_TEXT = 20_000
17
+
18
+ export interface WorkspaceBlock {
19
+ kind: WorkspaceBlockKind | 'unreadable'
20
+ [key: string]: unknown
21
+ }
22
+
23
+ export interface Workspace {
24
+ name: string
25
+ title: string
26
+ summary?: string
27
+ blocks: WorkspaceBlock[]
28
+ }
29
+
30
+ const text = (value: unknown, max = 500): string | undefined => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined)
31
+ const scalar = (value: unknown): string | number | boolean | null =>
32
+ value === null || value === undefined ? null : typeof value === 'number' || typeof value === 'boolean' ? value : String(value).slice(0, 500)
33
+
34
+ /** A short key of the workspace - the same name in one conversation is the same workspace, a new version. */
35
+ export function workspaceName(name: unknown, title: string): string {
36
+ const given = typeof name === 'string' ? name.trim().toLowerCase() : ''
37
+ if (/^[a-z0-9][a-z0-9-]{0,63}$/.test(given)) return given
38
+ const slug = (given || title)
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9가-힣]+/g, '-')
41
+ .replace(/^-+|-+$/g, '')
42
+ .slice(0, 64)
43
+ return slug || 'workspace'
44
+ }
45
+
46
+ function unreadable(kind: unknown, reason: string): WorkspaceBlock {
47
+ return { kind: 'unreadable', was: typeof kind === 'string' ? kind.slice(0, 40) : null, reason }
48
+ }
49
+
50
+ function block(raw: any): WorkspaceBlock {
51
+ const kind = raw?.kind
52
+ switch (kind) {
53
+ case 'heading': {
54
+ const value = text(raw.text, 200)
55
+ if (!value) return unreadable(kind, 'no-text')
56
+ const level = [1, 2, 3].includes(raw.level) ? raw.level : 2
57
+ return { kind, text: value, level }
58
+ }
59
+ case 'text': {
60
+ const value = text(raw.markdown ?? raw.text, MAX_TEXT)
61
+ return value ? { kind, markdown: value } : unreadable(kind, 'no-text')
62
+ }
63
+ case 'metrics': {
64
+ const items = (Array.isArray(raw.items) ? raw.items : [])
65
+ .filter((one: any) => text(one?.label) && one?.value !== undefined && one?.value !== null)
66
+ .slice(0, 12)
67
+ .map((one: any) => ({ label: text(one.label, 80), value: scalar(one.value), ...(text(one.unit, 20) ? { unit: text(one.unit, 20) } : {}), ...(text(one.note, 200) ? { note: text(one.note, 200) } : {}) }))
68
+ return items.length ? { kind, items } : unreadable(kind, 'no-items')
69
+ }
70
+ case 'table': {
71
+ const columns = (Array.isArray(raw.columns) ? raw.columns : [])
72
+ .filter((one: any) => text(one?.name, 80))
73
+ .slice(0, 30)
74
+ .map((one: any) => ({ name: text(one.name, 80)!, ...(text(one.label, 80) ? { label: text(one.label, 80) } : {}), type: ['number', 'date', 'string'].includes(one.type) ? one.type : 'string' }))
75
+ if (!columns.length) return unreadable(kind, 'no-columns')
76
+ const given = Array.isArray(raw.rows) ? raw.rows : []
77
+ /* A row is an object by column name, or an array in column order - models write both. */
78
+ const rows = given.slice(0, MAX_TABLE_ROWS).map((row: any) =>
79
+ Object.fromEntries(columns.map((column: any, i: number) => [column.name, scalar(Array.isArray(row) ? row[i] : row?.[column.name])]))
80
+ )
81
+ return { kind, columns, rows, ...(text(raw.caption, 300) ? { caption: text(raw.caption, 300) } : {}), ...(given.length > MAX_TABLE_ROWS ? { cut: given.length - MAX_TABLE_ROWS } : {}) }
82
+ }
83
+ case 'chart': {
84
+ const x = (Array.isArray(raw.x) ? raw.x : []).slice(0, 200).map((one: unknown) => String(one ?? '').slice(0, 60))
85
+ const series = (Array.isArray(raw.series) ? raw.series : [])
86
+ .filter((one: any) => text(one?.name, 60) && Array.isArray(one?.values))
87
+ .map((one: any) => ({ name: text(one.name, 60)!, values: x.map((_: unknown, i: number) => (typeof one.values[i] === 'number' && Number.isFinite(one.values[i]) ? one.values[i] : null)) }))
88
+ if (!x.length || !series.length) return unreadable(kind, 'no-series')
89
+ /* Never a ninth hue: past eight, the first seven stay and the rest fold into one "other". */
90
+ const shown =
91
+ series.length > MAX_SERIES
92
+ ? [
93
+ ...series.slice(0, MAX_SERIES - 1),
94
+ { name: 'other', other: true, values: x.map((_: unknown, i: number) => series.slice(MAX_SERIES - 1).reduce((acc: number, one: any) => acc + (one.values[i] ?? 0), 0)) }
95
+ ]
96
+ : series
97
+ return { kind, chart: raw.chart === 'line' || raw.type === 'line' || raw.shape === 'line' ? 'line' : 'bar', x, series: shown, ...(text(raw.unit, 20) ? { unit: text(raw.unit, 20) } : {}), ...(text(raw.caption, 300) ? { caption: text(raw.caption, 300) } : {}) }
98
+ }
99
+ case 'steps': {
100
+ const items = (Array.isArray(raw.items) ? raw.items : [])
101
+ .filter((one: any) => text(one?.label, 200))
102
+ .slice(0, 50)
103
+ .map((one: any) => ({ label: text(one.label, 200), state: ['done', 'doing', 'todo', 'failed'].includes(one.state) ? one.state : 'todo', ...(text(one.note, 300) ? { note: text(one.note, 300) } : {}) }))
104
+ return items.length ? { kind, items } : unreadable(kind, 'no-items')
105
+ }
106
+ default:
107
+ return unreadable(kind, 'unknown-kind')
108
+ }
109
+ }
110
+
111
+ /** The workspace as the screen reads it. */
112
+ export function normalizeWorkspace(args: any): { workspace?: Workspace; error?: string } {
113
+ const title = text(args?.title, 200)
114
+ if (!title) return { error: 'no-title' }
115
+ const given = Array.isArray(args?.blocks) ? args.blocks : []
116
+ if (!given.length) return { error: 'no-blocks' }
117
+ const blocks = given.slice(0, MAX_BLOCKS).map(block)
118
+ if (given.length > MAX_BLOCKS) blocks.push({ kind: 'unreadable', was: null, reason: 'too-many-blocks', cut: given.length - MAX_BLOCKS })
119
+ return { workspace: { name: workspaceName(args?.name, title), title, ...(text(args?.summary, 500) ? { summary: text(args.summary, 500) } : {}), blocks } }
120
+ }
121
+
122
+ /** One line of what is in it - for the card in the conversation and for the model. */
123
+ export function workspaceContents(workspace: Workspace): Record<string, number> {
124
+ const counts: Record<string, number> = {}
125
+ for (const one of workspace.blocks) if (['table', 'chart', 'metrics', 'steps'].includes(one.kind)) counts[one.kind] = (counts[one.kind] ?? 0) + 1
126
+ return counts
127
+ }
@@ -0,0 +1,66 @@
1
+ /*
2
+ * showInWorkspace - the model puts a large result on the AI 작업판 (operato-application/design/ai/workspace.md §5).
3
+ *
4
+ * The result rides on the answer as a proposal of kind 'workspace', so the conversation's gates hold for it:
5
+ * who may read the conversation reads the workspace, and a value behind a door the reader cannot pass hides
6
+ * it with the answer (ADR-0073). The tool itself reaches no door - what it carries came from the turn's other
7
+ * tools, whose doors the answer row collects.
8
+ */
9
+ import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
10
+
11
+ import { normalizeWorkspace, workspaceContents, WORKSPACE_BLOCK_KINDS } from './workspace-blocks.js'
12
+
13
+ export const WORKSPACE_CATEGORY = 'assistant-workspace'
14
+
15
+ export function showInWorkspace(args: any) {
16
+ const { workspace, error } = normalizeWorkspace(args)
17
+ if (!workspace) return { rejected: true, error, message: 'a workspace needs a title and at least one block.' }
18
+ const contents = workspaceContents(workspace)
19
+ return {
20
+ proposed: true,
21
+ kind: 'workspace',
22
+ icon: 'dashboard',
23
+ label: workspace.title,
24
+ workspace,
25
+ contents,
26
+ message: `put "${workspace.title}" on the workspace (${workspace.blocks.length} blocks). The person opens it from the card - say in one line what it holds; do not repeat its tables in the answer.`
27
+ }
28
+ }
29
+
30
+ const spec = {
31
+ kind: 'read',
32
+ name: 'showInWorkspace',
33
+ doors: [],
34
+ description:
35
+ 'Put a large result on the AI workspace - a page the person opens from a card, beside the conversation. Blocks: ' +
36
+ 'heading{text,level}, text{markdown}, metrics{items:[{label,value,unit,note}]}, table{columns:[{name,label,type:number|date|string}],rows,caption}, ' +
37
+ 'chart{chart:bar|line, x:[labels], series:[{name,values:[numbers]}], unit, caption}, steps{items:[{label,state:done|doing|todo|failed,note}]}.',
38
+ schema: {
39
+ type: 'object',
40
+ properties: {
41
+ name: { type: 'string', description: 'A short key, lowercase with dashes ("contracts-by-customer"). The same name again makes a new version of the same workspace.' },
42
+ title: { type: 'string' },
43
+ summary: { type: 'string', description: 'One line under the title.' },
44
+ blocks: {
45
+ type: 'array',
46
+ items: { type: 'object', properties: { kind: { type: 'string', enum: [...WORKSPACE_BLOCK_KINDS] } }, required: ['kind'] }
47
+ }
48
+ },
49
+ required: ['name', 'title', 'blocks']
50
+ },
51
+ builder: (args: any) => showInWorkspace(args)
52
+ } as ToolSpec
53
+
54
+ export const workspaceCategory: ToolCategory = {
55
+ name: WORKSPACE_CATEGORY,
56
+ description: 'the AI workspace - a page for results too large for the conversation',
57
+ guidance: [
58
+ '- When a result is more than a short table (over 20 rows, or two or more tables or charts), or the person asks for a page, call showInWorkspace. Keep the answer short: the point, and that it is on the workspace.',
59
+ '- Every number in a block comes from a tool result in this conversation - never computed or guessed by you.',
60
+ '- To change a workspace, call showInWorkspace again with the same name and the whole new content; it becomes a new version.',
61
+ '- Never say something is on the workspace unless you called showInWorkspace in this turn.'
62
+ ].join('\n'),
63
+ specs: [spec]
64
+ }
65
+
66
+ registerToolCategory(workspaceCategory)
@@ -0,0 +1,58 @@
1
+ /*
2
+ * The AI 작업판 (design/ai/workspace.md) - what a block may be, what happens to one that is not, and what the
3
+ * tool hands back.
4
+ */
5
+ import { normalizeWorkspace, workspaceName, MAX_SERIES } from './workspace-blocks'
6
+ import { showInWorkspace, workspaceCategory } from './workspace-tools'
7
+
8
+ describe('the workspace', () => {
9
+ it('★ is blocks of known kinds - a block that does not fit stays in its place as a fact, never dropped silently', () => {
10
+ const { workspace } = normalizeWorkspace({
11
+ name: 'contracts',
12
+ title: '2026 contracts',
13
+ blocks: [{ kind: 'heading', text: 'By customer' }, { kind: 'html', html: '<script>x</script>' }, { kind: 'table', rows: [] }]
14
+ })
15
+ expect(workspace!.blocks).toEqual([
16
+ { kind: 'heading', text: 'By customer', level: 2 },
17
+ { kind: 'unreadable', was: 'html', reason: 'unknown-kind' },
18
+ { kind: 'unreadable', was: 'table', reason: 'no-columns' }
19
+ ])
20
+ })
21
+
22
+ it('reads a table row as an object by column or an array in column order, and cuts at the limit saying how many', () => {
23
+ const rows = [{ customer: 'A', amount: 10 }, ['B', 20], ...Array.from({ length: 1000 }, () => ({ customer: 'x', amount: 1 }))]
24
+ const table: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'table', columns: [{ name: 'customer' }, { name: 'amount', type: 'number' }], rows }] }).workspace!.blocks[0]
25
+ expect(table.rows.slice(0, 2)).toEqual([{ customer: 'A', amount: 10 }, { customer: 'B', amount: 20 }])
26
+ expect(table.rows).toHaveLength(1000)
27
+ expect(table.cut).toBe(2)
28
+ })
29
+
30
+ it('★ a chart never gets a ninth colour - past eight series the rest fold into "other"', () => {
31
+ const series = Array.from({ length: 10 }, (_, i) => ({ name: `s${i}`, values: [1, 2] }))
32
+ const chart: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'chart', x: ['Jan', 'Feb'], series }] }).workspace!.blocks[0]
33
+ expect(chart.series).toHaveLength(MAX_SERIES)
34
+ expect(chart.series[MAX_SERIES - 1]).toEqual({ name: 'other', other: true, values: [3, 6] })
35
+ expect(chart.chart).toBe('bar')
36
+ })
37
+
38
+ it('a value that is not a number is a gap in the chart, never a zero', () => {
39
+ const chart: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'chart', chart: 'line', x: ['a', 'b', 'c'], series: [{ name: 'n', values: [1, 'x', 3] }] }] }).workspace!.blocks[0]
40
+ expect(chart.series[0].values).toEqual([1, null, 3])
41
+ })
42
+
43
+ it('the same name is the same workspace - a name that is not a key is made one from it', () => {
44
+ expect(workspaceName('contracts-by-customer', 'x')).toBe('contracts-by-customer')
45
+ expect(workspaceName(undefined, '2026 Contracts / 고객사')).toBe('2026-contracts-고객사')
46
+ })
47
+
48
+ it('★ the tool hands back a proposal of kind workspace, so the answer row carries it - and the conversation gates it', () => {
49
+ const result: any = showInWorkspace({ name: 'c', title: 'Contracts', blocks: [{ kind: 'table', columns: [{ name: 'a' }], rows: [[1]] }, { kind: 'metrics', items: [{ label: 'n', value: 26 }] }] })
50
+ expect(result).toMatchObject({ proposed: true, kind: 'workspace', label: 'Contracts', contents: { table: 1, metrics: 1 } })
51
+ expect((workspaceCategory.specs[0] as any).doors).toEqual([])
52
+ })
53
+
54
+ it('no title or no blocks is refused, with what it needs', () => {
55
+ expect(showInWorkspace({ blocks: [{ kind: 'text', markdown: 'x' }] })).toMatchObject({ rejected: true, error: 'no-title' })
56
+ expect(showInWorkspace({ title: 'x', blocks: [] })).toMatchObject({ rejected: true, error: 'no-blocks' })
57
+ })
58
+ })
@@ -0,0 +1,54 @@
1
+ /*
2
+ * The AI 작업판 on the screen (design/ai/workspace.md) - which versions a reader sees, the card's version, and the
3
+ * table's sort and CSV. The page and the chat are read as text: importing them pulls the shell.
4
+ */
5
+ import { readFileSync } from 'fs'
6
+ import { join } from 'path'
7
+
8
+ import { sortRows, tableCsv, versionOfCard, workspaceAddress, workspaceVersions } from '../client/utils/workspace'
9
+
10
+ const ws = (name: string, title = name) => ({ key: `k-${title}`, kind: 'workspace', workspace: { name, title, blocks: [] } })
11
+
12
+ describe('the workspace on the screen', () => {
13
+ it('★ the same name in one conversation is one workspace; its versions are counted in order', () => {
14
+ const messages = [
15
+ { id: 'm1', proposalsJson: [ws('contracts', 'v1')] },
16
+ { id: 'm2', proposalsJson: [{ key: 'x', tool: 'proposeAction' }, ws('loans', 'l1')] },
17
+ { id: 'm3', proposalsJson: null },
18
+ { id: 'm4', proposalsJson: [ws('contracts', 'v2')] }
19
+ ]
20
+ expect(workspaceVersions(messages, 'contracts').map(one => `${one.version}:${one.messageId}:${one.workspace.title}`)).toEqual(['1:m1:v1', '2:m4:v2'])
21
+ })
22
+
23
+ it('★ a version behind a door the reader cannot pass is not among them - its row comes back without proposals', () => {
24
+ const messages = [{ id: 'm1', proposalsJson: [ws('contracts', 'v1')] }, { id: 'm2', proposalsJson: null }]
25
+ expect(workspaceVersions(messages, 'contracts')).toHaveLength(1)
26
+ })
27
+
28
+ it('a card knows which version it is', () => {
29
+ expect(versionOfCard([[ws('c', 'a')], [ws('d', 'x')], [ws('c', 'b')]], 'c', 'k-b')).toEqual({ version: 2, of: 2 })
30
+ })
31
+
32
+ it('the address carries the conversation, the name and the version', () => {
33
+ expect(workspaceAddress('s-1', 'contracts', 2)).toBe('assistant-workspace?session=s-1&name=contracts&version=2')
34
+ })
35
+
36
+ it('sorts numbers as numbers and puts empty cells last, both ways', () => {
37
+ const rows = [{ v: 10 }, { v: null }, { v: 9 }, { v: 100 }]
38
+ expect(sortRows(rows, 'v', 'asc').map(row => row.v)).toEqual([9, 10, 100, null])
39
+ expect(sortRows(rows, 'v', 'desc').map(row => row.v)).toEqual([100, 10, 9, null])
40
+ })
41
+
42
+ it('CSV quotes what needs quoting and starts with a BOM so a spreadsheet reads Korean', () => {
43
+ expect(tableCsv([{ name: 'a', label: '고객사' }, { name: 'b' }], [{ a: 'A, Inc', b: 'say "hi"' }])).toBe('고객사,b\r\n"A, Inc","say ""hi"""')
44
+ })
45
+
46
+ it('★ the card opens the page and runs nothing; the page reads the conversation through its own door', () => {
47
+ const chat = readFileSync(join(__dirname, '..', 'client', 'components', 'assistant-chat.ts'), 'utf8')
48
+ expect(chat).toContain('if (isWorkspaceProposal(p)) return this.renderWorkspaceCard(p, key)')
49
+ const page = readFileSync(join(__dirname, '..', 'client', 'pages', 'assistant-workspace-page.ts'), 'utf8')
50
+ expect(page).toContain('chatMessages(sessionId: $sessionId')
51
+ const config = readFileSync(join(__dirname, '..', 'things-factory.config.js'), 'utf8')
52
+ expect(config).toContain("page: 'assistant-workspace'")
53
+ })
54
+ })
@@ -1 +1,6 @@
1
- export default {}
1
+ import route from './dist-client/route.js'
2
+
3
+ export default {
4
+ route,
5
+ routes: [{ tagname: 'assistant-workspace-page', page: 'assistant-workspace' }]
6
+ }
@@ -232,5 +232,25 @@
232
232
  "ai-assistant.entry.total-matches": "matches",
233
233
  "ai-assistant.entry.issue.not-in-source": "the file does not have this value",
234
234
  "ai-assistant.entry.issue.source-scattered": "the values are not on one line of the file",
235
- "ai-assistant.entry.issue.source-not-checked": "a photo cannot be checked here"
235
+ "ai-assistant.entry.issue.source-not-checked": "a photo cannot be checked here",
236
+ "ai-assistant.workspace.title": "AI workspace",
237
+ "ai-assistant.workspace.unreadable": "a block that cannot be drawn ({reason})",
238
+ "ai-assistant.workspace.rows": "{count} rows",
239
+ "ai-assistant.workspace.csv": "CSV",
240
+ "ai-assistant.workspace.cut": "{count} more rows were left out over the limit.",
241
+ "ai-assistant.workspace.other": "other",
242
+ "ai-assistant.workspace.as-chart": "chart",
243
+ "ai-assistant.workspace.as-table": "table",
244
+ "ai-assistant.workspace.loading": "loading the workspace…",
245
+ "ai-assistant.workspace.unreadable-page": "could not read this workspace: {reason}",
246
+ "ai-assistant.workspace.not-found": "there is no workspace here that you can see.",
247
+ "ai-assistant.workspace.newer": "a newer version ({version}) is ready.",
248
+ "ai-assistant.workspace.show-newer": "show it",
249
+ "ai-assistant.workspace.versions": "versions",
250
+ "ai-assistant.workspace.version": "version {version} of {of}",
251
+ "ai-assistant.workspace.open": "open in workspace",
252
+ "ai-assistant.workspace.contains-table": "{count} tables",
253
+ "ai-assistant.workspace.contains-chart": "{count} charts",
254
+ "ai-assistant.workspace.contains-metrics": "{count} figures",
255
+ "ai-assistant.workspace.contains-steps": "{count} progress"
236
256
  }
@@ -232,5 +232,25 @@
232
232
  "ai-assistant.entry.total-matches": "一致",
233
233
  "ai-assistant.entry.issue.not-in-source": "資料にない値です",
234
234
  "ai-assistant.entry.issue.source-scattered": "値が資料の同じ行にありません",
235
- "ai-assistant.entry.issue.source-not-checked": "写真の資料は照合できませんでした"
235
+ "ai-assistant.entry.issue.source-not-checked": "写真の資料は照合できませんでした",
236
+ "ai-assistant.workspace.title": "AI作業ボード",
237
+ "ai-assistant.workspace.unreadable": "描画できないブロックです ({reason})",
238
+ "ai-assistant.workspace.rows": "{count}行",
239
+ "ai-assistant.workspace.csv": "CSV",
240
+ "ai-assistant.workspace.cut": "上限を超えた{count}行は除外されました。",
241
+ "ai-assistant.workspace.other": "その他",
242
+ "ai-assistant.workspace.as-chart": "チャート",
243
+ "ai-assistant.workspace.as-table": "表で見る",
244
+ "ai-assistant.workspace.loading": "作業ボードを読み込み中…",
245
+ "ai-assistant.workspace.unreadable-page": "作業ボードを読み込めませんでした: {reason}",
246
+ "ai-assistant.workspace.not-found": "表示できる作業ボードがありません。",
247
+ "ai-assistant.workspace.newer": "新しい版({version})があります。",
248
+ "ai-assistant.workspace.show-newer": "表示",
249
+ "ai-assistant.workspace.versions": "版",
250
+ "ai-assistant.workspace.version": "版 {version} / {of}",
251
+ "ai-assistant.workspace.open": "作業ボードで見る",
252
+ "ai-assistant.workspace.contains-table": "表 {count}",
253
+ "ai-assistant.workspace.contains-chart": "チャート {count}",
254
+ "ai-assistant.workspace.contains-metrics": "指標 {count}",
255
+ "ai-assistant.workspace.contains-steps": "経過 {count}"
236
256
  }
@@ -232,5 +232,25 @@
232
232
  "ai-assistant.entry.total-matches": "일치",
233
233
  "ai-assistant.entry.issue.not-in-source": "자료에 없는 값입니다",
234
234
  "ai-assistant.entry.issue.source-scattered": "값이 자료의 한 줄에 함께 있지 않습니다",
235
- "ai-assistant.entry.issue.source-not-checked": "사진 자료는 대조하지 못했습니다"
235
+ "ai-assistant.entry.issue.source-not-checked": "사진 자료는 대조하지 못했습니다",
236
+ "ai-assistant.workspace.title": "AI 작업판",
237
+ "ai-assistant.workspace.unreadable": "그릴 수 없는 블록입니다 ({reason})",
238
+ "ai-assistant.workspace.rows": "{count}행",
239
+ "ai-assistant.workspace.csv": "CSV",
240
+ "ai-assistant.workspace.cut": "한도를 넘은 {count}행은 빠졌습니다.",
241
+ "ai-assistant.workspace.other": "기타",
242
+ "ai-assistant.workspace.as-chart": "차트",
243
+ "ai-assistant.workspace.as-table": "표로 보기",
244
+ "ai-assistant.workspace.loading": "작업판을 불러오는 중…",
245
+ "ai-assistant.workspace.unreadable-page": "작업판을 읽지 못했습니다: {reason}",
246
+ "ai-assistant.workspace.not-found": "볼 수 있는 작업판이 없습니다.",
247
+ "ai-assistant.workspace.newer": "새 판({version})이 있습니다.",
248
+ "ai-assistant.workspace.show-newer": "보기",
249
+ "ai-assistant.workspace.versions": "판",
250
+ "ai-assistant.workspace.version": "판 {version} / {of}",
251
+ "ai-assistant.workspace.open": "작업판에서 보기",
252
+ "ai-assistant.workspace.contains-table": "표 {count}",
253
+ "ai-assistant.workspace.contains-chart": "차트 {count}",
254
+ "ai-assistant.workspace.contains-metrics": "수치 {count}",
255
+ "ai-assistant.workspace.contains-steps": "경과 {count}"
236
256
  }
@@ -232,5 +232,25 @@
232
232
  "ai-assistant.entry.total-matches": "sepadan",
233
233
  "ai-assistant.entry.issue.not-in-source": "fail tiada nilai ini",
234
234
  "ai-assistant.entry.issue.source-scattered": "nilai tidak berada pada satu baris fail",
235
- "ai-assistant.entry.issue.source-not-checked": "foto tidak dapat disemak di sini"
235
+ "ai-assistant.entry.issue.source-not-checked": "foto tidak dapat disemak di sini",
236
+ "ai-assistant.workspace.title": "ruang kerja AI",
237
+ "ai-assistant.workspace.unreadable": "blok yang tidak dapat dilukis ({reason})",
238
+ "ai-assistant.workspace.rows": "{count} baris",
239
+ "ai-assistant.workspace.csv": "CSV",
240
+ "ai-assistant.workspace.cut": "{count} baris lagi ditinggalkan kerana melebihi had.",
241
+ "ai-assistant.workspace.other": "lain-lain",
242
+ "ai-assistant.workspace.as-chart": "carta",
243
+ "ai-assistant.workspace.as-table": "jadual",
244
+ "ai-assistant.workspace.loading": "memuatkan ruang kerja…",
245
+ "ai-assistant.workspace.unreadable-page": "tidak dapat membaca ruang kerja ini: {reason}",
246
+ "ai-assistant.workspace.not-found": "tiada ruang kerja yang boleh anda lihat di sini.",
247
+ "ai-assistant.workspace.newer": "versi lebih baharu ({version}) tersedia.",
248
+ "ai-assistant.workspace.show-newer": "tunjukkan",
249
+ "ai-assistant.workspace.versions": "versi",
250
+ "ai-assistant.workspace.version": "versi {version} daripada {of}",
251
+ "ai-assistant.workspace.open": "buka dalam ruang kerja",
252
+ "ai-assistant.workspace.contains-table": "{count} jadual",
253
+ "ai-assistant.workspace.contains-chart": "{count} carta",
254
+ "ai-assistant.workspace.contains-metrics": "{count} angka",
255
+ "ai-assistant.workspace.contains-steps": "{count} kemajuan"
236
256
  }
@@ -232,5 +232,25 @@
232
232
  "ai-assistant.entry.total-matches": "一致",
233
233
  "ai-assistant.entry.issue.not-in-source": "资料中没有此值",
234
234
  "ai-assistant.entry.issue.source-scattered": "这些值不在资料的同一行",
235
- "ai-assistant.entry.issue.source-not-checked": "照片资料无法核对"
235
+ "ai-assistant.entry.issue.source-not-checked": "照片资料无法核对",
236
+ "ai-assistant.workspace.title": "AI 工作板",
237
+ "ai-assistant.workspace.unreadable": "无法绘制的块({reason})",
238
+ "ai-assistant.workspace.rows": "{count}行",
239
+ "ai-assistant.workspace.csv": "CSV",
240
+ "ai-assistant.workspace.cut": "超出上限的{count}行已省略。",
241
+ "ai-assistant.workspace.other": "其他",
242
+ "ai-assistant.workspace.as-chart": "图表",
243
+ "ai-assistant.workspace.as-table": "以表格查看",
244
+ "ai-assistant.workspace.loading": "正在加载工作板…",
245
+ "ai-assistant.workspace.unreadable-page": "无法读取此工作板:{reason}",
246
+ "ai-assistant.workspace.not-found": "没有您可以查看的工作板。",
247
+ "ai-assistant.workspace.newer": "有新版本({version})。",
248
+ "ai-assistant.workspace.show-newer": "查看",
249
+ "ai-assistant.workspace.versions": "版本",
250
+ "ai-assistant.workspace.version": "第{version}版 / 共{of}版",
251
+ "ai-assistant.workspace.open": "在工作板中查看",
252
+ "ai-assistant.workspace.contains-table": "表格 {count}",
253
+ "ai-assistant.workspace.contains-chart": "图表 {count}",
254
+ "ai-assistant.workspace.contains-metrics": "指标 {count}",
255
+ "ai-assistant.workspace.contains-steps": "进度 {count}"
236
256
  }