@things-factory/figure-service 10.1.153 → 10.1.155

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 (26) hide show
  1. package/dist-server/service/figure/figure-mutation.d.ts +1 -1
  2. package/dist-server/service/figure/figure-mutation.js +17 -2
  3. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  4. package/dist-server/service/figure/figure-package-export.js +3 -1
  5. package/dist-server/service/figure/figure-package-export.js.map +1 -1
  6. package/dist-server/service/figure/figure-propose-steps.d.ts +3 -0
  7. package/dist-server/service/figure/figure-propose-steps.js +1 -4
  8. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  9. package/dist-server/service/figure/figure-query.d.ts +1 -1
  10. package/dist-server/service/figure/figure-query.js +3 -3
  11. package/dist-server/service/figure/figure-query.js.map +1 -1
  12. package/dist-server/service/figure/figure-tools.js +7 -0
  13. package/dist-server/service/figure/figure-tools.js.map +1 -1
  14. package/dist-server/tsconfig.tsbuildinfo +1 -1
  15. package/package.json +7 -7
  16. package/server/service/figure/figure-llm-smoke.test.ts +5 -4
  17. package/server/service/figure/figure-mutation.ts +16 -1
  18. package/server/service/figure/figure-namespace-db.test.ts +31 -2
  19. package/server/service/figure/figure-package-export.ts +3 -1
  20. package/server/service/figure/figure-propose-clips.test.ts +3 -7
  21. package/server/service/figure/figure-propose-steps.test.ts +8 -15
  22. package/server/service/figure/figure-propose-steps.ts +4 -3
  23. package/server/service/figure/figure-query.ts +3 -3
  24. package/server/service/figure/figure-thumbnail-db.test.ts +24 -0
  25. package/server/service/figure/figure-tools.test.ts +9 -2
  26. package/server/service/figure/figure-tools.ts +8 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.153",
3
+ "version": "10.1.155",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,16 +27,16 @@
27
27
  "dependencies": {
28
28
  "@hatiolab/figure-model": "^0.6.3",
29
29
  "@operato/typeorm-history": "^10.0.0",
30
- "@things-factory/ai-client-base": "^10.1.149",
31
- "@things-factory/attachment-base": "^10.1.150",
32
- "@things-factory/auth-base": "^10.1.150",
30
+ "@things-factory/ai-client-base": "^10.1.155",
31
+ "@things-factory/attachment-base": "^10.1.155",
32
+ "@things-factory/auth-base": "^10.1.155",
33
33
  "@things-factory/env": "^10.1.149",
34
- "@things-factory/setting-base": "^10.1.150",
35
- "@things-factory/shell": "^10.1.150",
34
+ "@things-factory/setting-base": "^10.1.155",
35
+ "@things-factory/shell": "^10.1.155",
36
36
  "graphql-scalars": "^1.22.4",
37
37
  "graphql-upload": "^15.0.2",
38
38
  "jszip": "^3.10.1",
39
39
  "koa-router": "^7.4.0"
40
40
  },
41
- "gitHead": "54b3e985a55d5d0b01622d87f385f07518fb5d16"
41
+ "gitHead": "0211c9c0764a26c10151fdc7d60094fc334905ff"
42
42
  }
@@ -9,7 +9,7 @@
9
9
  * RUN_FIGURE_LLM_SMOKE=1 OPENAI_API_KEY=... yarn test:figure-llm-smoke
10
10
  * 선택: FIGURE_LLM_PROVIDER=anthropic|gemini|openai, FIGURE_LLM_MODEL=<model id>
11
11
  */
12
- import { createAIClient, setDefaultAIClient } from '@things-factory/ai-client-base'
12
+ import { createAIClient, type AIClient } from '@things-factory/ai-client-base'
13
13
 
14
14
  import { applyFigureProposal, createFigureAsset, inspectFigureAsset } from '@hatiolab/figure-model'
15
15
 
@@ -39,18 +39,19 @@ const picked = process.env.RUN_FIGURE_LLM_SMOKE === '1' ? provider() : undefined
39
39
  const describeIf = picked ? describe : describe.skip
40
40
 
41
41
  describeIf('Figure 실제 LLM smoke', () => {
42
+ let client: AIClient
42
43
  beforeAll(() => {
43
- setDefaultAIClient(createAIClient({ ...picked!, ...(process.env.FIGURE_LLM_MODEL ? { model: process.env.FIGURE_LLM_MODEL } : {}) }))
44
+ client = createAIClient({ ...picked!, ...(process.env.FIGURE_LLM_MODEL ? { model: process.env.FIGURE_LLM_MODEL } : {}) })
44
45
  // eslint-disable-next-line no-console
45
46
  console.log(`[figure-llm-smoke] provider=${picked!.provider} model=${process.env.FIGURE_LLM_MODEL ?? '(adapter default)'}`)
46
47
  })
47
48
 
48
- afterAll(() => setDefaultAIClient(undefined))
49
49
 
50
50
  test(
51
51
  '새 Figure 는 게이트를 지나는 명령 목록으로 온다',
52
52
  async () => {
53
53
  const result = await proposeFigureSteps({
54
+ client,
54
55
  prompt: 'two cylindrical rollers inside a simple rectangular press frame; make the rollers visually distinct',
55
56
  name: 'Roll press'
56
57
  })
@@ -74,7 +75,7 @@ describeIf('Figure 실제 LLM smoke', () => {
74
75
  { kind: 'attach', part: 'port', face: 'back', to: { part: 'body', face: 'front' } },
75
76
  { kind: 'fit-room', placement: 'floor' }
76
77
  ] as any)
77
- const result = await proposeFigureSteps({ prompt: 'make the port twice as wide and give it an accent colour', base: draft })
78
+ const result = await proposeFigureSteps({ client, prompt: 'make the port twice as wide and give it an accent colour', base: draft })
78
79
 
79
80
  const ids = result.source.document.model.nodes.map((n: any) => n.id)
80
81
  expect(ids.some((id: string) => id.includes('body'))).toBe(true)
@@ -3,6 +3,7 @@ import { Arg, Ctx, Directive, Mutation, Resolver, UseMiddleware } from 'type-gra
3
3
 
4
4
  import { isFigureType, parseFigureAsset, placeholderType, TYPE_LENGTH } from '@hatiolab/figure-model'
5
5
  import { getRepository, Refusal, SpeakRefusals } from '@things-factory/shell'
6
+ import { aiClientFor } from '@things-factory/ai-client-base'
6
7
 
7
8
  import { Figure } from './figure.js'
8
9
  import { FigureHistory } from './figure-history.js'
@@ -225,6 +226,19 @@ export class FigureMutation {
225
226
  throw new Error(`Figure ${id} 를 찾을 수 없다`)
226
227
  }
227
228
 
229
+ /*
230
+ **그림만 바꾸면 발행이 풀리지 않는다.** 미리보기에서 사람이 돌려 놓은 각도로 찍은 그림이 그렇게 온다.
231
+ 그림은 발행이 약속하는 것이 아니다 — 발행본은 제 그림을 따로 갖고, 다음 발행이 이 그림을 가져간다.
232
+ 판본 구독자를 거치지 않는다: 발행된 행의 저장을 판으로 베끼는데, 이것은 판이 아니다.
233
+ */
234
+ if (patch.thumbnail !== undefined && Object.keys(patch).every(key => key === 'thumbnail' || (patch as any)[key] === undefined)) {
235
+ const picture = await pictureOf(tx, domain, user, patch.thumbnail, figure)
236
+ if (picture.thumbnailHash) {
237
+ await repository.createQueryBuilder().update(Figure).set({ ...picture, updater: user }).where('id = :id', { id }).callListeners(false).execute()
238
+ }
239
+ return { figure: await repository.findOneOrFail({ where: { id } }), violations: [] }
240
+ }
241
+
228
242
  // 정본이 바뀌면 파생값도 다시 계산한다. 안 바뀌었으면 손대지 않는다 —
229
243
  // 이름만 고쳤는데 점수가 흔들리면 읽는 사람이 원인을 못 찾는다.
230
244
  let derived: { score: number; triangles: number; groups: number } | undefined
@@ -542,7 +556,7 @@ export class FigureMutation {
542
556
  @Mutation(returns => FigureProposal, {
543
557
  description: 'Asks the authoring assistant for modeller commands that make or revise a figure. Nothing is saved.'
544
558
  })
545
- async proposeFigure(@Arg('request') request: ProposeRequest): Promise<FigureProposal> {
559
+ async proposeFigure(@Arg('request') request: ProposeRequest, @Ctx() context: ResolverContext): Promise<FigureProposal> {
546
560
  // 그림은 메모리로 받는다. 참고용이라 저장하지 않는다 — 남기면 지울 사람이 없다.
547
561
  let image
548
562
  if (request.image) {
@@ -556,6 +570,7 @@ export class FigureMutation {
556
570
 
557
571
  try {
558
572
  const result = await proposeFigureSteps({
573
+ client: await aiClientFor({ domain: context.state.domain, user: context.state.user }),
559
574
  prompt: request.prompt,
560
575
  base: request.base ? parseFigureAsset(request.base) : undefined,
561
576
  name: request.name,
@@ -17,6 +17,7 @@ let Figure: any
17
17
  let FigureMutation: any
18
18
  let FigureQuery: any
19
19
  let figurePackageOf: any
20
+ let FigureHistory: any
20
21
 
21
22
  function loadCompiled(modulePath: string): any {
22
23
  const { existsSync } = require('fs')
@@ -29,7 +30,7 @@ function loadCompiled(modulePath: string): any {
29
30
 
30
31
  beforeAll(async () => {
31
32
  Figure = loadCompiled('service/figure/figure').Figure
32
- const FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
33
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
33
34
  const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
34
35
  ;({ FigureMutation } = loadCompiled('service/figure/figure-mutation'))
35
36
  ;({ FigureQuery } = loadCompiled('service/figure/figure-query'))
@@ -130,7 +131,7 @@ describe('figureBlueprints answers each full name, an item or a refusal', () =>
130
131
  context
131
132
  )
132
133
 
133
- expect(answer.release).toBeNull()
134
+ expect(Object.keys(answer).sort()).toEqual(['items', 'refused'])
134
135
  expect(answer.items.map((item: any) => [item.type, item.ref])).toEqual([['hatiolab.com/conveyor', 'hatiolab.com/conveyor@1']])
135
136
  expect(answer.items[0].asset.version).toBe('figure-asset-1')
136
137
  expect(answer.refused.map((r: any) => [r.type, r.code])).toEqual([
@@ -170,6 +171,34 @@ describe('the figure package', () => {
170
171
  expect(read.releases[0].source).toBe(stored.source)
171
172
  })
172
173
 
174
+ it('dates each release when it was released, not when the figure was made, and carries its note', async () => {
175
+ const { domain, context } = await domainWith('hatiolab.com')
176
+ const figure = await releasedFigure(context, 'trailer-23')
177
+ /* sqlite keeps these times to the second */
178
+ await new Promise(resolve => setTimeout(resolve, 1100))
179
+ const current = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
180
+ await new FigureMutation().saveFigure(releasable('Trailer 23 wide'), context, figure.id, current.source)
181
+ await new FigureMutation().releaseFigure(figure.id, 'wider body', context)
182
+
183
+ const { bytes } = await figurePackageOf({
184
+ refs: ['hatiolab.com/trailer-23@1', 'hatiolab.com/trailer-23@2'],
185
+ domain,
186
+ exportedBy: 'author',
187
+ sourceUrl: 'x',
188
+ files: { put: async () => ({}), send: async () => {}, read: async () => Buffer.alloc(0) }
189
+ })
190
+ const zip = await JSZip.loadAsync(bytes)
191
+ const files = new Map<string, Uint8Array>()
192
+ for (const path of Object.keys(zip.files)) if (!zip.files[path].dir) files.set(path, await zip.files[path].async('uint8array'))
193
+ const [first, second] = readFigurePackage(files, sha256).releases
194
+
195
+ const histories = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id }, order: { version: 'ASC' } })
196
+ expect(first.releasedAt).toBe(new Date(histories[0].updatedAt).toISOString())
197
+ expect(second.releasedAt).toBe(new Date(histories[1].updatedAt).toISOString())
198
+ expect(second.releasedAt > first.releasedAt).toBe(true)
199
+ expect([first.comment, second.comment]).toEqual(['first', 'wider body'])
200
+ })
201
+
173
202
  it('refuses a release this domain cannot read, and a malformed reference', async () => {
174
203
  const { domain, context } = await domainWith('hatiolab.com')
175
204
  await releasedFigure(context, 'conveyor')
@@ -126,6 +126,8 @@ function packageReleaseOf(
126
126
  thumbnail: history.thumbnailHash && blobs.has(history.thumbnailHash) ? history.thumbnailHash : null,
127
127
  deprecated: !!figure.deprecated,
128
128
  supersededBy: figure.supersededBy && figure.namespace ? `${figure.namespace}/${figure.supersededBy}` : null,
129
- releasedAt: new Date(history.createdAt ?? Date.now()).toISOString()
129
+ /* The history row copies the figure's own createdAt; the save that released this version is its updatedAt. */
130
+ releasedAt: new Date(history.updatedAt ?? history.createdAt ?? Date.now()).toISOString(),
131
+ comment: history.comment ?? null
130
132
  }
131
133
  }
@@ -5,7 +5,6 @@
5
5
  */
6
6
  import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart } from '@hatiolab/figure-model'
7
7
  import type { FigureAsset } from '@hatiolab/figure-model'
8
- import { setDefaultAIClient } from '@things-factory/ai-client-base'
9
8
  import type { AIClient } from '@things-factory/ai-client-base'
10
9
 
11
10
  import { ProposeFigureFailure, judgeFigureAnswer, proposeFigureSteps } from './figure-propose-steps'
@@ -26,8 +25,6 @@ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[]
26
25
  return client as unknown as AIClient & { calls: number; sent: any[][] }
27
26
  }
28
27
 
29
- afterEach(() => setDefaultAIClient(undefined))
30
-
31
28
  function door(): FigureAsset {
32
29
  let a = createFigureAsset('Door')
33
30
  a = addFigureBox(a, { id: 'frame', size: { x: 1000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
@@ -52,8 +49,7 @@ describe('an AI answer and the clips of the draft it revises', () => {
52
49
 
53
50
  it('asked three times and three times lossy, nothing comes back: no half-made figure to save', async () => {
54
51
  const client = stubClient([lossy])
55
- setDefaultAIClient(client)
56
- await expect(proposeFigureSteps({ prompt: '문을 없애 줘', base: door() })).rejects.toBeInstanceOf(ProposeFigureFailure)
52
+ await expect(proposeFigureSteps({ client, prompt: '문을 없애 줘', base: door() })).rejects.toBeInstanceOf(ProposeFigureFailure)
57
53
  expect(client.calls).toBe(3)
58
54
  expect(JSON.stringify(client.sent)).not.toMatch(/dropClips/)
59
55
  })
@@ -66,8 +62,8 @@ describe('an AI answer and the clips of the draft it revises', () => {
66
62
 
67
63
  it('an answer that stops no clip keeps every clip, a rename of the part it hangs from included', async () => {
68
64
  const base = door()
69
- setDefaultAIClient(stubClient([{ steps: [{ kind: 'rename', name: 'Door 2' }, { kind: 'nudge-part', part: 'sign', by: { x: 50 } }, { kind: 'rename-part', target: 'frame', name: 'jamb' }] }]))
70
- const made = await proposeFigureSteps({ prompt: '이름을 바꿔 줘', base })
65
+ const client = stubClient([{ steps: [{ kind: 'rename', name: 'Door 2' }, { kind: 'nudge-part', part: 'sign', by: { x: 50 } }, { kind: 'rename-part', target: 'frame', name: 'jamb' }] }])
66
+ const made = await proposeFigureSteps({ client, prompt: '이름을 바꿔 줘', base })
71
67
  expect(made.source.drivers).toEqual(base.drivers)
72
68
  })
73
69
  })
@@ -6,7 +6,6 @@
6
6
  * the proposer says it could not make it, and nothing half made comes back.
7
7
  */
8
8
  import { FIGURE_PROPOSAL_GRAMMAR, applyFigureProposal, createFigureAsset, inspectFigureAsset } from '@hatiolab/figure-model'
9
- import { setDefaultAIClient } from '@things-factory/ai-client-base'
10
9
  import type { AIClient } from '@things-factory/ai-client-base'
11
10
 
12
11
  import { ProposeFigureFailure, proposeFigureSteps, rulesPromptFigure } from './figure-propose-steps'
@@ -32,8 +31,6 @@ function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[]
32
31
  return client as unknown as AIClient & { calls: number; sent: any[][]; system?: string }
33
32
  }
34
33
 
35
- afterEach(() => setDefaultAIClient(undefined))
36
-
37
34
  const liftTable = [
38
35
  { kind: 'add-part', part: { id: 'base', kind: 'box', dimensions: { width: 1200, height: 80, depth: 800 } } },
39
36
  { kind: 'add-part', part: { id: 'column', kind: 'cylinder', dimensions: { radius: 90, length: 600 } } },
@@ -66,8 +63,7 @@ describe('the model is told the kernel\'s commands and the open draft', () => {
66
63
  it('a revision carries the draft\'s source', async () => {
67
64
  const draft = applyFigureProposal(createFigureAsset('Lift table'), liftTable as any)
68
65
  const client = stubClient([{ steps: [{ kind: 'rename', name: 'Lift table 2' }] }])
69
- setDefaultAIClient(client)
70
- const made = await proposeFigureSteps({ prompt: '이름을 바꿔 줘', base: draft })
66
+ const made = await proposeFigureSteps({ client, prompt: '이름을 바꿔 줘', base: draft })
71
67
  expect(client.sent[0]![0]!.content).toContain('"version":"figure-asset-1"')
72
68
  expect(made.source.name).toBe('Lift table 2')
73
69
  expect(draft.name).toBe('Lift table')
@@ -76,8 +72,8 @@ describe('the model is told the kernel\'s commands and the open draft', () => {
76
72
 
77
73
  describe('the steps are held to the kernel and the gate', () => {
78
74
  it('a list the kernel takes comes back with the figure it makes, the gate clean, on the first attempt', async () => {
79
- setDefaultAIClient(stubClient([{ steps: liftTable }]))
80
- const made = await proposeFigureSteps({ prompt: '리프트 테이블', name: 'Lift table' })
75
+ const client = stubClient([{ steps: liftTable }])
76
+ const made = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' })
81
77
  expect(made.attempts).toBe(1)
82
78
  expect(made.steps).toEqual(liftTable)
83
79
  expect(inspectFigureAsset(made.source).violations).toEqual([])
@@ -86,8 +82,7 @@ describe('the steps are held to the kernel and the gate', () => {
86
82
 
87
83
  it('a refused step goes back with its place and the kernel\'s code, and the next answer is taken', async () => {
88
84
  const client = stubClient([{ steps: wrongFace }, { steps: liftTable }])
89
- setDefaultAIClient(client)
90
- const made = await proposeFigureSteps({ prompt: '리프트 테이블', name: 'Lift table' })
85
+ const made = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' })
91
86
  expect(made.attempts).toBe(2)
92
87
  const said = client.sent[1]!.at(-1)!.content as string
93
88
  expect(said).toMatch(/ATTACH_FACE: steps\[4\]: attach: /)
@@ -95,23 +90,21 @@ describe('the steps are held to the kernel and the gate', () => {
95
90
 
96
91
  it('a new figure must come out whole: a list that leaves the room undeclared is sent back', async () => {
97
92
  const client = stubClient([{ steps: withoutRoom }, { steps: liftTable }])
98
- setDefaultAIClient(client)
99
- const made = await proposeFigureSteps({ prompt: '리프트 테이블', name: 'Lift table' })
93
+ const made = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' })
100
94
  expect(made.attempts).toBe(2)
101
95
  expect(client.sent[1]!.at(-1)!.content).toMatch(/OCCUPANCY_UNDECLARED/)
102
96
  })
103
97
 
104
98
  it('a revision is not blamed for what the draft already had', async () => {
105
99
  const draft = applyFigureProposal(createFigureAsset('Lift table'), withoutRoom as any)
106
- setDefaultAIClient(stubClient([{ steps: [{ kind: 'rename', name: 'Lift' }] }]))
107
- const made = await proposeFigureSteps({ prompt: '이름', base: draft })
100
+ const client = stubClient([{ steps: [{ kind: 'rename', name: 'Lift' }] }])
101
+ const made = await proposeFigureSteps({ client, prompt: '이름', base: draft })
108
102
  expect(made.attempts).toBe(1)
109
103
  })
110
104
 
111
105
  it('three refusals said back, then it says it could not make it, with the last reasons -- nothing half made', async () => {
112
106
  const client = stubClient([{ steps: wrongFace }, new Error('cut short'), { nope: true }])
113
- setDefaultAIClient(client)
114
- const failure = await proposeFigureSteps({ prompt: '리프트 테이블', name: 'Lift table' }).catch(e => e)
107
+ const failure = await proposeFigureSteps({ client, prompt: '리프트 테이블', name: 'Lift table' }).catch(e => e)
115
108
  expect(failure).toBeInstanceOf(ProposeFigureFailure)
116
109
  expect(failure.message).toBe('만들지 못했습니다.')
117
110
  expect(failure.attempts).toBe(3)
@@ -1,6 +1,6 @@
1
1
  import { FIGURE_PROPOSAL_GRAMMAR, applyFigureProposal, createFigureAsset, inspectFigureAsset, serializeFigureAsset, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'
2
2
  import type { FigureAsset, FigureProposalStep, FigureRefusalDetails } from '@hatiolab/figure-model'
3
- import { getDefaultAIClient } from '@things-factory/ai-client-base'
3
+ import type { AIClient } from '@things-factory/ai-client-base'
4
4
  import type { AIImageMediaType, AIMessage } from '@things-factory/ai-client-base'
5
5
 
6
6
  /**
@@ -20,6 +20,8 @@ const MAX_ATTEMPTS = 3
20
20
  const GENERATE_MAX_TOKENS = 16384
21
21
 
22
22
  export interface ProposeFigureInput {
23
+ /** The domain's AI agent that answers (aiClientFor). */
24
+ client: AIClient
23
25
  /** What the author asked for, in their words. */
24
26
  prompt: string
25
27
  /** The open draft to revise. Absent: a new figure. */
@@ -140,8 +142,7 @@ export function judgeFigureAnswer(base: FigureAsset, answer: unknown, revising:
140
142
 
141
143
  /** Asks the model for steps and holds them to the kernel and the gate, three times at most. */
142
144
  export async function proposeFigureSteps(input: ProposeFigureInput): Promise<ProposeFigureResult> {
143
- const client = getDefaultAIClient()
144
- if (!client) throw new ProposeFigureFailure('AI 모델이 설정돼 있지 않습니다.', [], 0)
145
+ const { client } = input
145
146
 
146
147
  const base = input.base ?? createFigureAsset(input.name ?? 'Figure')
147
148
  const system = rulesPromptFigure()
@@ -321,12 +321,12 @@ export class FigureQuery {
321
321
  *
322
322
  * One answer per name: an item, or a refusal that names why. A refusal never takes another item away -- the board
323
323
  * draws what it got and a stand-in with the reason for the rest. This server keeps its policy: the latest release,
324
- * shadowed by the child domain as `figureBlueprint` does. `release` is null; a type map is the board server's.
324
+ * shadowed by the child domain as `figureBlueprint` does. A type map is the board server's.
325
325
  */
326
326
  @Directive('@privilege(category: "figure-published", privilege: "query", domainOwnerGranted: true)')
327
327
  @Query(returns => GraphQLJSON, {
328
328
  description:
329
- 'Released figures asked together by full name {namespace}/{type}: { release: null, items: [{ type, ref, asset }], refused: [{ type, code, message }] }. The latest release of each; one refusal never removes another item.'
329
+ 'Released figures asked together by full name {namespace}/{type}: { items: [{ type, ref, asset }], refused: [{ type, code, message }] }. The latest release of each; one refusal never removes another item.'
330
330
  })
331
331
  async figureBlueprints(
332
332
  @Arg('types', type => [String], { description: 'Full figure names {namespace}/{type}. At most 500.' }) types: string[],
@@ -376,7 +376,7 @@ export class FigureQuery {
376
376
  }
377
377
  }
378
378
 
379
- return { release: null, items, refused }
379
+ return { items, refused }
380
380
  }
381
381
  }
382
382
 
@@ -135,6 +135,30 @@ describe('a picture kept by its content', () => {
135
135
  expect(row.thumbnailUpdatedAt).toBeTruthy()
136
136
  useThumbnailFiles(was)
137
137
  })
138
+
139
+ it('a patch of the picture alone replaces only the picture: a released figure stays released, at its version, with no new version row', async () => {
140
+ const was = useThumbnailFiles(mapFiles())
141
+ const domain = await newDomain()
142
+ const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
143
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'CHOSEN', name: 'chosen', source: SOURCE, state: 'draft', version: 0, domain })
144
+ await new FigureMutation().updateFigure(figure.id, { thumbnail: picture(11) }, context)
145
+ /* Released as a row, without the subscriber that would copy it into a version. */
146
+ await dataSource!.getRepository(Figure).createQueryBuilder().update(Figure).set({ state: 'released', version: 3 }).where('id = :id', { id: figure.id }).callListeners(false).execute()
147
+ const versions = await dataSource!.getRepository(FigureHistory).count({ where: { originalId: figure.id } })
148
+ const before = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
149
+
150
+ const { figure: chosen } = await new FigureMutation().updateFigure(figure.id, { thumbnail: picture(12) }, context)
151
+ expect([chosen.state, chosen.version, chosen.source]).toEqual(['released', 3, SOURCE])
152
+ expect(chosen.thumbnailHash).toBe(sha256Of(bytesOf(picture(12))))
153
+ expect(chosen.thumbnailHash).not.toBe(before.thumbnailHash)
154
+ expect(await dataSource!.getRepository(FigureHistory).count({ where: { originalId: figure.id } })).toBe(versions)
155
+
156
+ /* The same picture again is no change: the time stays. */
157
+ const { figure: again } = await new FigureMutation().updateFigure(figure.id, { thumbnail: picture(12) }, context)
158
+ expect(again.thumbnailUpdatedAt).toEqual(chosen.thumbnailUpdatedAt)
159
+ await expect(new FigureMutation().updateFigure(figure.id, { thumbnail: 'not a picture' }, context)).rejects.toThrow(/data URL/)
160
+ useThumbnailFiles(was)
161
+ })
138
162
  })
139
163
 
140
164
  describe('served behind the permission, from the figure\'s own domain', () => {
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * 실제 모델은 부르지 않는다. 여기서 볼 것은 모델의 솜씨가 아니라 **우리 배선**이다.
14
14
  */
15
- import { clearToolRegistry, getToolCategories, registryAssistantChat } from '@things-factory/ai-client-base'
15
+ import { clearToolRegistry, getToolCategories, registryAssistantChat, setAIClientForTesting } from '@things-factory/ai-client-base'
16
16
 
17
17
  import { __setFigureToolDepsForTest, registerFigureToolCategories } from './figure-tools'
18
18
 
@@ -50,7 +50,11 @@ const ROWS = [
50
50
  /** 부른 것을 적어 두는 가짜 제안기. */
51
51
  const proposed: any[] = []
52
52
 
53
+ /* The proposal tool asks the domain's AI agent; here a stand-in answers - the proposer below never calls it. */
54
+ const agentClient: any = { id: 'test:figure', chat: async () => '', generateJSON: async () => ({}), listModels: async () => [] }
55
+
53
56
  function install() {
57
+ setAIClientForTesting(agentClient)
54
58
  const model = require('@hatiolab/figure-model')
55
59
  __setFigureToolDepsForTest({
56
60
  getRepository: () => stubRepository(ROWS),
@@ -93,7 +97,10 @@ beforeEach(() => {
93
97
  registerFigureToolCategories()
94
98
  })
95
99
 
96
- afterEach(() => clearToolRegistry())
100
+ afterEach(() => {
101
+ clearToolRegistry()
102
+ setAIClientForTesting(undefined)
103
+ })
97
104
 
98
105
  test('실제 registryAssistantChat 경계가 열린 모델 문맥을 Figure 도구에 전달한다', async () => {
99
106
  const seen: any[] = []
@@ -35,7 +35,7 @@
35
35
  * 있다. 그래서 호출자 도메인 소속을 먼저 확인한다.
36
36
  */
37
37
  import { randomUUID } from 'crypto'
38
- import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
38
+ import { aiClientFor, registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
39
39
 
40
40
  /*
41
41
  서버 의존성은 lazy require 다. 등록은 부팅 초반에 평가되고 그때 typeorm DataSource 가 준비되지
@@ -353,6 +353,13 @@ function figureToolSpecs(): ToolSpec[] {
353
353
  let result: any
354
354
  try {
355
355
  result = await proposeFigureSteps({
356
+ /* The agent of this conversation's turn - the one the person picked, or the domain default. */
357
+ client: await aiClientFor({
358
+ domain: ctx?.state?.domain,
359
+ user: ctx?.state?.user,
360
+ agentId: ctx?.state?.aiAgentId,
361
+ modeKind: ctx?.mode?.kind
362
+ }),
356
363
  prompt,
357
364
  base,
358
365
  name: typeof args?.name === 'string' && args.name.trim() ? args.name.trim() : undefined,