@things-factory/figure-ui 10.1.167 → 10.1.169
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.
- package/client/figure-assistant.ts +15 -1
- package/client/modeller/ai-proposal.ts +60 -11
- package/client/modeller/figure-properties.ts +1 -3
- package/client/modeller/product-session.ts +32 -13
- package/client/modeller/refusals.ts +14 -0
- package/client/pages/figure-modeller-page.ts +14 -10
- package/dist-client/figure-assistant.d.ts +6 -0
- package/dist-client/figure-assistant.js +14 -1
- package/dist-client/figure-assistant.js.map +1 -1
- package/dist-client/modeller/ai-proposal.d.ts +23 -5
- package/dist-client/modeller/ai-proposal.js +55 -13
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/figure-properties.d.ts +1 -2
- package/dist-client/modeller/figure-properties.js +1 -3
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +14 -4
- package/dist-client/modeller/product-session.js +31 -13
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +1 -5
- package/dist-client/modeller/refusals.js +14 -1
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.js +14 -9
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/test/ai-proposal.test.ts +115 -5
- package/test/browser/acceptance-page.js +3 -1
- package/test/browser/product-page.check.mjs +63 -3
- package/test/browser/product-page.js +7 -2
- package/test/figure-assistant-surface.test.ts +65 -1
- package/test/proposal-record.test.ts +18 -6
- package/translations/en.json +6 -12
- package/translations/es.json +6 -12
- package/translations/ja.json +6 -12
- package/translations/ko.json +6 -12
- package/translations/ms.json +6 -12
- package/translations/zh.json +6 -12
- package/client/modeller/carrier-declaration.ts +0 -63
- package/client/modeller/figure-carrier.ts +0 -117
- package/dist-client/modeller/carrier-declaration.d.ts +0 -31
- package/dist-client/modeller/carrier-declaration.js +0 -53
- package/dist-client/modeller/carrier-declaration.js.map +0 -1
- package/dist-client/modeller/figure-carrier.d.ts +0 -26
- package/dist-client/modeller/figure-carrier.js +0 -123
- package/dist-client/modeller/figure-carrier.js.map +0 -1
- package/test/carrier-declaration.test.ts +0 -69
|
@@ -10,11 +10,21 @@ import { i18next } from '@operato/i18n'
|
|
|
10
10
|
import { navigate } from '@operato/shell'
|
|
11
11
|
import { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js'
|
|
12
12
|
|
|
13
|
+
import { fetchProposalOutcome } from './graphql/index.js'
|
|
13
14
|
import { modellerPathOf } from './modeller/modeller-route.js'
|
|
14
|
-
import { pendNewFigure, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'
|
|
15
|
+
import { pendNewFigure, proposalOutcomeFrom, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'
|
|
15
16
|
|
|
16
17
|
export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The dock's "check the result" of a figure proposal, on every screen that offers its card: what the server recorded of
|
|
21
|
+
* its save (ADR-0097 §5.6 item 5). The modeller asks it, and so does an app's home where a new figure's card is made --
|
|
22
|
+
* there it was missing, and a card cut off by a reload stayed "no answer" for good (figure audit 2026-10-16 N8).
|
|
23
|
+
*/
|
|
24
|
+
export async function figureProposalOutcome(proposalKey: string) {
|
|
25
|
+
return proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params))
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
|
|
19
29
|
const outcome = await runFigureProposal(
|
|
20
30
|
proposal,
|
|
@@ -27,6 +37,10 @@ registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
|
|
|
27
37
|
const opened = pendNewFigure(steps, key)
|
|
28
38
|
navigate(modellerPathOf())
|
|
29
39
|
return opened
|
|
40
|
+
},
|
|
41
|
+
savedInto: fetchProposalOutcome,
|
|
42
|
+
openSaved(id) {
|
|
43
|
+
navigate(modellerPathOf({ id }))
|
|
30
44
|
}
|
|
31
45
|
},
|
|
32
46
|
(key, params) => i18next.t(key, params)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { FigureProposalStep } from '@hatiolab/figure-model'
|
|
9
9
|
import type { FigureProductSession } from './product-session.js'
|
|
10
|
-
import { clipLossOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'
|
|
10
|
+
import { clipLossOf, editRefusalOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'
|
|
11
11
|
|
|
12
12
|
/** What the card carries, as `proposeFigureCandidate` returned it. */
|
|
13
13
|
export interface FigureProposalCard {
|
|
@@ -28,6 +28,8 @@ export interface FigureApplyDetail extends FigureRefusalRecord {
|
|
|
28
28
|
key?: string
|
|
29
29
|
steps: FigureProposalStep[]
|
|
30
30
|
create: boolean
|
|
31
|
+
/** The figure a revision was made for: the one the model read. */
|
|
32
|
+
figureId?: string
|
|
31
33
|
context?: string
|
|
32
34
|
revision?: number
|
|
33
35
|
applied?: boolean
|
|
@@ -41,6 +43,10 @@ export interface FigureProposalTarget {
|
|
|
41
43
|
offer(detail: FigureApplyDetail): FigureApplyDetail
|
|
42
44
|
/** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */
|
|
43
45
|
openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>
|
|
46
|
+
/** The figures the server recorded a save of this proposal into (`figureProposalOutcome`), first saved first. */
|
|
47
|
+
savedInto(key: string): Promise<ReadonlyArray<{ figureId: string; type?: string; name?: string }>>
|
|
48
|
+
/** Opens a saved figure in the modeller. */
|
|
49
|
+
openSaved(figureId: string): void
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
export interface FigureProposalOutcome {
|
|
@@ -78,7 +84,8 @@ function refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): F
|
|
|
78
84
|
? (error ?? '')
|
|
79
85
|
: clips
|
|
80
86
|
? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })
|
|
81
|
-
:
|
|
87
|
+
: /* A refusal with a code is said from it, as the modeller says an edit it refuses; never its message (audit N6). */
|
|
88
|
+
say('figure.text.proposal-refused', { reason: code ? editRefusalOf(refusal, (key, params) => say(key, params)) : (error ?? '') })
|
|
82
89
|
return { verdict: 'refused', errorCode: code ?? 'refused', note }
|
|
83
90
|
}
|
|
84
91
|
|
|
@@ -95,6 +102,12 @@ export function takeFigureOffer(
|
|
|
95
102
|
): boolean {
|
|
96
103
|
const view = session.read()
|
|
97
104
|
if (detail.create && onSavedFigure) return false
|
|
105
|
+
/*
|
|
106
|
+
A revision is for the figure the model read; with another open it is left untaken, and the handler says it needs that
|
|
107
|
+
figure open. The version is no proof of the figure: two figures opened in two page loads stood at the same one
|
|
108
|
+
(figure audit 2026-10-16 N3).
|
|
109
|
+
*/
|
|
110
|
+
if (!detail.create && typeof detail.figureId === 'string' && detail.figureId !== (view.id ?? view.creationId)) return false
|
|
98
111
|
if (detail.create && view.dirty) {
|
|
99
112
|
/* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */
|
|
100
113
|
detail.applied = false
|
|
@@ -104,8 +117,11 @@ export function takeFigureOffer(
|
|
|
104
117
|
}
|
|
105
118
|
try {
|
|
106
119
|
if (detail.create) {
|
|
107
|
-
/*
|
|
108
|
-
|
|
120
|
+
/*
|
|
121
|
+
Taken on the new figure the modeller has open, whose conversation asked for it: the figure keeps its own creation
|
|
122
|
+
id, the anchor of that conversation (ADR-0061 decision 3, ruled 2026-10-16). One proposal stays one figure by the
|
|
123
|
+
save's record of it, which `runFigureProposal` reads before the card runs again.
|
|
124
|
+
*/
|
|
109
125
|
session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)
|
|
110
126
|
} else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)
|
|
111
127
|
else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)
|
|
@@ -121,15 +137,26 @@ export function takeFigureOffer(
|
|
|
121
137
|
|
|
122
138
|
export async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {
|
|
123
139
|
const steps = card?.steps
|
|
124
|
-
if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps' }
|
|
140
|
+
if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps', note: say('figure.text.proposal-empty') }
|
|
125
141
|
const create = card.create === true
|
|
142
|
+
/*
|
|
143
|
+
A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,
|
|
144
|
+
ruled 2026-10-16). Taken on a new figure that kept its own creation id, the save's record is what says it was made.
|
|
145
|
+
*/
|
|
146
|
+
if (create && card.key) {
|
|
147
|
+
const saved = await target.savedInto(card.key)
|
|
148
|
+
if (saved.length) {
|
|
149
|
+
target.openSaved(saved[0]!.figureId)
|
|
150
|
+
return proposalOutcomeFrom(saved, say)!
|
|
151
|
+
}
|
|
152
|
+
}
|
|
126
153
|
const accepted = (): FigureProposalOutcome => ({
|
|
127
154
|
verdict: 'accepted',
|
|
128
155
|
applied: steps.length,
|
|
129
156
|
note: say('figure.text.proposal-applied', { n: String(steps.length) })
|
|
130
157
|
})
|
|
131
158
|
|
|
132
|
-
const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision })
|
|
159
|
+
const detail = target.offer({ key: card.key, steps, create, figureId: create ? undefined : card.figureId, context: card.context, revision: card.revision })
|
|
133
160
|
if (detail.applied) return accepted()
|
|
134
161
|
if (detail.applied === false) return refused(detail, say)
|
|
135
162
|
|
|
@@ -148,11 +175,14 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
|
|
|
148
175
|
modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new
|
|
149
176
|
figure must not receive steps nobody is waiting for.
|
|
150
177
|
*/
|
|
151
|
-
|
|
178
|
+
type Settle = (result: { applied: boolean; error?: string; code?: string }) => void
|
|
179
|
+
let pending: { steps: FigureProposalStep[]; key?: string; settle: Settle; hold(): void; resume(): void } | undefined
|
|
152
180
|
|
|
153
181
|
export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {
|
|
154
182
|
pending?.settle({ applied: false, code: 'not-opened' })
|
|
155
183
|
return new Promise(resolve => {
|
|
184
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
185
|
+
const lapse = () => mine.settle({ applied: false, code: 'not-opened' })
|
|
156
186
|
const mine = {
|
|
157
187
|
steps,
|
|
158
188
|
key,
|
|
@@ -160,17 +190,36 @@ export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10
|
|
|
160
190
|
clearTimeout(timer)
|
|
161
191
|
if (pending === mine) pending = undefined
|
|
162
192
|
resolve(result)
|
|
193
|
+
},
|
|
194
|
+
hold: () => clearTimeout(timer),
|
|
195
|
+
resume: () => {
|
|
196
|
+
clearTimeout(timer)
|
|
197
|
+
timer = setTimeout(lapse, ms)
|
|
163
198
|
}
|
|
164
199
|
}
|
|
165
|
-
|
|
200
|
+
mine.resume()
|
|
166
201
|
pending = mine
|
|
167
202
|
})
|
|
168
203
|
}
|
|
169
204
|
|
|
205
|
+
/**
|
|
206
|
+
* For a page about to ask the person whether to leave unsaved edits: the new figure waiting to open stops its clock
|
|
207
|
+
* while they answer -- an answer is no absent page (figure audit 2026-10-16 N4). Give it the answer: leaving runs the
|
|
208
|
+
* clock again for the page that opens; staying says now that it was not opened. Nothing waiting: undefined.
|
|
209
|
+
*/
|
|
210
|
+
export function askingToLeave(): ((leaving: boolean) => void) | undefined {
|
|
211
|
+
const waiting = pending
|
|
212
|
+
if (!waiting) return undefined
|
|
213
|
+
waiting.hold()
|
|
214
|
+
return leaving => {
|
|
215
|
+
if (pending !== waiting) return
|
|
216
|
+
if (leaving) waiting.resume()
|
|
217
|
+
else waiting.settle({ applied: false, code: 'not-opened' })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
170
221
|
/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
|
|
171
|
-
export function takePendingNewFigure():
|
|
172
|
-
| { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }
|
|
173
|
-
| undefined {
|
|
222
|
+
export function takePendingNewFigure(): { steps: FigureProposalStep[]; key?: string; settle: Settle } | undefined {
|
|
174
223
|
const taken = pending
|
|
175
224
|
pending = undefined
|
|
176
225
|
return taken
|
|
@@ -8,7 +8,6 @@ import { i18next, localize } from '@operato/i18n'
|
|
|
8
8
|
import { FIGURE_FACES } from '@hatiolab/figure-model'
|
|
9
9
|
import type { FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
|
|
10
10
|
|
|
11
|
-
import './figure-carrier.js'
|
|
12
11
|
import { occupancyFromParts } from './occupancy.js'
|
|
13
12
|
import { tagsOf, figureFigureFacts, type Placement, type FigureFigureFacts } from './figure-facts.js'
|
|
14
13
|
import { offeredMountingOf } from './mounting-part.js'
|
|
@@ -26,7 +25,7 @@ const FACES = Object.keys(FIGURE_FACES) as FigureFace[]
|
|
|
26
25
|
* catalogue files it.
|
|
27
26
|
*
|
|
28
27
|
* Two kinds of change leave this tab, and it does neither itself:
|
|
29
|
-
* - `command` carries an authoring command (rename, declare the room, the mounting face
|
|
28
|
+
* - `command` carries an authoring command (rename, declare the room, the mounting face). The page runs it
|
|
30
29
|
* through the session, so it is one step of the same undo history as every other edit, and is saved with it.
|
|
31
30
|
* - `catalog` carries a change to the row (category, tags). Those are not in the asset; the page writes them
|
|
32
31
|
* to the saved row at once, so they need a saved figure.
|
|
@@ -125,7 +124,6 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
125
124
|
<h3>${i18next.t('figure.label.group-standing')}</h3>
|
|
126
125
|
${this.renderSpace(facts)} ${this.renderMounting(facts)}
|
|
127
126
|
</section>
|
|
128
|
-
<figure-carrier .carrier=${this.revision?.asset.carrier}></figure-carrier>
|
|
129
127
|
<section>
|
|
130
128
|
<h3>${i18next.t('figure.label.group-catalog')}</h3>
|
|
131
129
|
${this.renderCatalog()}
|
|
@@ -14,6 +14,15 @@ import {
|
|
|
14
14
|
} from '@hatiolab/figure-model'
|
|
15
15
|
|
|
16
16
|
/** Consent to let clips go, and the figure and version it was given on. */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A request the session refuses, with its code: a screen says it from the code (`sessionRefusalOf`), never from the
|
|
20
|
+
* message, which stays for logs (ADR-0054; figure audit 2026-10-16 N6 -- an AI card put the message in its note).
|
|
21
|
+
*/
|
|
22
|
+
function refusal(code: string, message: string): Error & { code: string } {
|
|
23
|
+
return Object.assign(Error(message), { code })
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
export interface FigureClipConsentFor extends FigureClipConsent {
|
|
18
27
|
context: string
|
|
19
28
|
revision: number
|
|
@@ -72,8 +81,12 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
72
81
|
return result
|
|
73
82
|
}
|
|
74
83
|
const inEffect = () => [...new Set(done.filter((tag): tag is string => !!tag))]
|
|
84
|
+
/*
|
|
85
|
+
The creation id is read with the rest: a figure not saved yet is named by it (ADR-0061 decision 3) -- its AI
|
|
86
|
+
conversation is anchored on it, and its first save inserts the row under it, so the name holds across that save.
|
|
87
|
+
*/
|
|
75
88
|
function read() {
|
|
76
|
-
return { ...workspace.read(), id, loading, saving, error }
|
|
89
|
+
return { ...workspace.read(), id, creationId, loading, saving, error }
|
|
77
90
|
}
|
|
78
91
|
/*
|
|
79
92
|
The version on screen, one object for as long as it is the version: the workspace hands out one asset per version
|
|
@@ -106,13 +119,18 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
106
119
|
}
|
|
107
120
|
return { dropClips: given.dropClips }
|
|
108
121
|
}
|
|
109
|
-
|
|
122
|
+
/* A model open to read: the draft is read during a save too -- the dock reads it when the person sends (audit N5). */
|
|
123
|
+
function readable() {
|
|
110
124
|
const view = workspace.read()
|
|
111
|
-
if (loading || error || !view.context || view.revision === null) throw
|
|
112
|
-
// Serializing writes also makes create retries safe: no second create while the first is pending.
|
|
113
|
-
if (saving) throw Error('저장이 끝난 뒤 편집해 주세요.')
|
|
125
|
+
if (loading || error || !view.context || view.revision === null) throw refusal('SESSION_NOT_OPEN', '먼저 모델을 정상적으로 열어 주세요.')
|
|
114
126
|
return { context: view.context, expectedRevision: view.revision, origin: 'ui' as const }
|
|
115
127
|
}
|
|
128
|
+
function writable() {
|
|
129
|
+
const open = readable()
|
|
130
|
+
// Serializing writes also makes create retries safe: no second create while the first is pending.
|
|
131
|
+
if (saving) throw refusal('SESSION_SAVING', '저장이 끝난 뒤 편집해 주세요.')
|
|
132
|
+
return open
|
|
133
|
+
}
|
|
116
134
|
function clear() {
|
|
117
135
|
workspace.close({ discardDesignChanges: true })
|
|
118
136
|
id = undefined
|
|
@@ -175,7 +193,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
175
193
|
const control = writable()
|
|
176
194
|
// AI must supply the context AND revision captured when it started. Never substitute current values.
|
|
177
195
|
if (origin === 'ai' && (context === undefined || expectedRevision === undefined))
|
|
178
|
-
throw
|
|
196
|
+
throw refusal('SESSION_VERSION_MISSING', 'AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.')
|
|
179
197
|
return tracked('do', () =>
|
|
180
198
|
workspace.apply({
|
|
181
199
|
...control,
|
|
@@ -196,13 +214,14 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
196
214
|
return tracked('do', () => workspace.applyProposal({ origin: 'ai', context, expectedRevision, steps }), key)
|
|
197
215
|
},
|
|
198
216
|
/**
|
|
199
|
-
* A new figure
|
|
200
|
-
*
|
|
201
|
-
*
|
|
217
|
+
* A new figure opened for an AI proposal from another screen is created under the proposal's key (ADR-0097 §5.6
|
|
218
|
+
* item 5). Only before the modeller names the figure to the AI dock: once a conversation is anchored on the creation
|
|
219
|
+
* id it stays (ADR-0061 decision 3), and the save's record of the proposal keeps one proposal one figure. Only on a
|
|
220
|
+
* new figure not yet touched -- one being made is the person's, and keeps its own.
|
|
202
221
|
*/
|
|
203
222
|
createUnder(key: string) {
|
|
204
|
-
if (id || workspace.read().dirty) throw
|
|
205
|
-
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw
|
|
223
|
+
if (id || workspace.read().dirty) throw refusal('SESSION_NEW_FIGURE_TOUCHED', '손대지 않은 새 모델에만 쓸 수 있습니다.')
|
|
224
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw refusal('SESSION_PROPOSAL_KEY', '제안 식별자가 올바르지 않습니다.')
|
|
206
225
|
creationId = key
|
|
207
226
|
},
|
|
208
227
|
/** The AI proposals whose steps are in the model now, each once. */
|
|
@@ -224,7 +243,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
224
243
|
/** What a command would do, on a copy (ADR-0097 §5.5): nothing is written, and the answer names its version. */
|
|
225
244
|
previewCommand(action: FigureAuthoringAction) {
|
|
226
245
|
const view = workspace.read()
|
|
227
|
-
if (loading || error || !view.context || view.revision === null) throw
|
|
246
|
+
if (loading || error || !view.context || view.revision === null) throw refusal('SESSION_NOT_OPEN', '먼저 모델을 정상적으로 열어 주세요.')
|
|
228
247
|
return workspace.previewCommand({ context: view.context, action })
|
|
229
248
|
},
|
|
230
249
|
/** One authoring command — what a person means, not a graph edit — as one step of the same history. */
|
|
@@ -251,7 +270,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
251
270
|
return tracked('redo', () => workspace.redo(writable()))
|
|
252
271
|
},
|
|
253
272
|
source() {
|
|
254
|
-
const { context } =
|
|
273
|
+
const { context } = readable()
|
|
255
274
|
return workspace.export({ context }).text
|
|
256
275
|
},
|
|
257
276
|
/**
|
|
@@ -226,8 +226,22 @@ function documentLimitRefusalOf(error: unknown, say: Say): string | null {
|
|
|
226
226
|
* the kernel's English on the screen (the duplicate of a row's part showed 「EDIT_TARGET: … not a repeated template」,
|
|
227
227
|
* 2026-09-27).
|
|
228
228
|
*/
|
|
229
|
+
/** The modeller session's own refusals (`product-session.ts`), by their code. Nothing for any other refusal. */
|
|
230
|
+
const SESSION_REFUSALS: Record<string, string> = {
|
|
231
|
+
SESSION_NOT_OPEN: 'figure.text.session-not-open',
|
|
232
|
+
SESSION_SAVING: 'figure.text.session-saving',
|
|
233
|
+
SESSION_VERSION_MISSING: 'figure.text.session-version-missing',
|
|
234
|
+
SESSION_NEW_FIGURE_TOUCHED: 'figure.text.session-new-figure-touched',
|
|
235
|
+
SESSION_PROPOSAL_KEY: 'figure.text.session-proposal-key'
|
|
236
|
+
}
|
|
237
|
+
export function sessionRefusalOf(error: unknown, say: Say): string | null {
|
|
238
|
+
const key = SESSION_REFUSALS[codeOf(error) ?? '']
|
|
239
|
+
return key ? say(key, {}) : null
|
|
240
|
+
}
|
|
241
|
+
|
|
229
242
|
export function editRefusalOf(error: unknown, say: Say): string {
|
|
230
243
|
return (
|
|
244
|
+
sessionRefusalOf(error, say) ??
|
|
231
245
|
sizeRangeRefusalOf(error, say) ??
|
|
232
246
|
inputRangeRefusalOf(error, say) ??
|
|
233
247
|
repeatPitchRefusalOf(error, say) ??
|
|
@@ -6,7 +6,8 @@ import { modellerPathOf } from '../modeller/modeller-route.js'
|
|
|
6
6
|
import { createFigureProductSession } from '../modeller/product-session.js'
|
|
7
7
|
import { figureProductStore } from '../graphql/store.js'
|
|
8
8
|
import { i18next } from '@operato/i18n'
|
|
9
|
-
import { fetchFigure, fetchFigureTypeNames,
|
|
9
|
+
import { fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
|
|
10
|
+
import { figureProposalOutcome } from '../figure-assistant.js'
|
|
10
11
|
import { availableRecipes, recipeGuide } from '../modeller/recipe-source.js'
|
|
11
12
|
import type { RecipeTemplate } from '../modeller/recipes.js'
|
|
12
13
|
import { occupancyFromParts } from '../modeller/occupancy.js'
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
import type { FirstSave } from '../modeller/first-save.js'
|
|
31
32
|
import { typeOf } from '../modeller/first-save.js'
|
|
32
33
|
import { editRefusalOf } from '../modeller/refusals.js'
|
|
33
|
-
import {
|
|
34
|
+
import { askingToLeave, takeFigureOffer, takePendingNewFigure } from '../modeller/ai-proposal.js'
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* The product page has exactly one authoritative workspace for UI and AI input transactions.
|
|
@@ -126,22 +127,23 @@ export class FigureModellerPage extends PageView {
|
|
|
126
127
|
/** The figure the AI dock was last told about - another id is another conversation. */
|
|
127
128
|
private assistantFor?: string
|
|
128
129
|
/*
|
|
129
|
-
The figure on screen is what the AI dock talks about (design/ai/shared-dock.md §3). A figure not saved yet
|
|
130
|
-
|
|
130
|
+
The figure on screen is what the AI dock talks about (design/ai/shared-dock.md §3). A figure not saved yet is named
|
|
131
|
+
by its creation id, the id its first save inserts the row under (ADR-0061 decision 3, ADR-0072 decision 6): the
|
|
132
|
+
anchor is the same before and after that save, so the conversation goes on, with nothing to bind again.
|
|
131
133
|
The draft is read at the moment of sending, never cached - the dock must not answer about a shape already changed.
|
|
132
134
|
*/
|
|
133
135
|
private provideAssistant = () => {
|
|
134
136
|
const view = this.session.read()
|
|
135
137
|
if (!this.active || view.loading || view.error || !view.context) return
|
|
136
|
-
|
|
138
|
+
const id = view.id ?? view.creationId
|
|
139
|
+
if (!id) {
|
|
137
140
|
this.withdrawAssistant()
|
|
138
141
|
return
|
|
139
142
|
}
|
|
140
|
-
if (this.assistantFor ===
|
|
143
|
+
if (this.assistantFor === id) {
|
|
141
144
|
notifyAssistantSurface(this)
|
|
142
145
|
return
|
|
143
146
|
}
|
|
144
|
-
const id = view.id
|
|
145
147
|
this.assistantFor = id
|
|
146
148
|
provideAssistantSurface(this, {
|
|
147
149
|
key: `figure:${id}`,
|
|
@@ -151,11 +153,10 @@ export class FigureModellerPage extends PageView {
|
|
|
151
153
|
detail: () => (this.session.read().dirty ? i18next.t('figure.text.unsaved-changes') : ''),
|
|
152
154
|
anchor: () => ({ anchorType: 'figure', anchorId: id }),
|
|
153
155
|
/* The card's "check the result": the server's record, never the dock's memory; nothing is applied again. */
|
|
154
|
-
proposalOutcome:
|
|
155
|
-
proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params)),
|
|
156
|
+
proposalOutcome: figureProposalOutcome,
|
|
156
157
|
hostContext: () => ({
|
|
157
158
|
surface: 'figure-modeller',
|
|
158
|
-
editorMode: 'existing',
|
|
159
|
+
editorMode: this.session.read().id ? 'existing' : 'new',
|
|
159
160
|
figureId: id,
|
|
160
161
|
draftSource: JSON.parse(this.session.source()),
|
|
161
162
|
/* The version the model sees; a proposal is applied on it, or refused if the figure moved on (ADR-0094). */
|
|
@@ -388,6 +389,8 @@ export class FigureModellerPage extends PageView {
|
|
|
388
389
|
The shell asks this while the edited model is still on screen. The app's own prompt, as every operato screen
|
|
389
390
|
asks -- not the browser's confirm box (user, 2026-09-24).
|
|
390
391
|
*/
|
|
392
|
+
/* A new figure the dock is opening waits for this answer, not against a clock (audit N4). */
|
|
393
|
+
const answered = askingToLeave()
|
|
391
394
|
this.discardApproved = !!(await OxPrompt.open({
|
|
392
395
|
type: 'warning',
|
|
393
396
|
title: i18next.t('figure.text.unsaved-changes'),
|
|
@@ -395,6 +398,7 @@ export class FigureModellerPage extends PageView {
|
|
|
395
398
|
confirmButton: { text: i18next.t('figure.button.leave-without-saving') },
|
|
396
399
|
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
397
400
|
}))
|
|
401
|
+
answered?.(this.discardApproved)
|
|
398
402
|
return this.discardApproved
|
|
399
403
|
}
|
|
400
404
|
async pageUpdated(changes: Record<string, unknown>, lifecycle: { resourceId?: string }) {
|
|
@@ -1 +1,7 @@
|
|
|
1
1
|
export declare const FIGURE_CANDIDATE_TOOL = "proposeFigureCandidate";
|
|
2
|
+
/**
|
|
3
|
+
* The dock's "check the result" of a figure proposal, on every screen that offers its card: what the server recorded of
|
|
4
|
+
* its save (ADR-0097 §5.6 item 5). The modeller asks it, and so does an app's home where a new figure's card is made --
|
|
5
|
+
* there it was missing, and a card cut off by a reload stayed "no answer" for good (figure audit 2026-10-16 N8).
|
|
6
|
+
*/
|
|
7
|
+
export declare function figureProposalOutcome(proposalKey: string): Promise<import("./modeller/ai-proposal.js").FigureProposalOutcome | undefined>;
|
|
@@ -9,9 +9,18 @@
|
|
|
9
9
|
import { i18next } from '@operato/i18n';
|
|
10
10
|
import { navigate } from '@operato/shell';
|
|
11
11
|
import { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js';
|
|
12
|
+
import { fetchProposalOutcome } from './graphql/index.js';
|
|
12
13
|
import { modellerPathOf } from './modeller/modeller-route.js';
|
|
13
|
-
import { pendNewFigure, runFigureProposal } from './modeller/ai-proposal.js';
|
|
14
|
+
import { pendNewFigure, proposalOutcomeFrom, runFigureProposal } from './modeller/ai-proposal.js';
|
|
14
15
|
export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate';
|
|
16
|
+
/**
|
|
17
|
+
* The dock's "check the result" of a figure proposal, on every screen that offers its card: what the server recorded of
|
|
18
|
+
* its save (ADR-0097 §5.6 item 5). The modeller asks it, and so does an app's home where a new figure's card is made --
|
|
19
|
+
* there it was missing, and a card cut off by a reload stayed "no answer" for good (figure audit 2026-10-16 N8).
|
|
20
|
+
*/
|
|
21
|
+
export async function figureProposalOutcome(proposalKey) {
|
|
22
|
+
return proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params));
|
|
23
|
+
}
|
|
15
24
|
registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
|
|
16
25
|
const outcome = await runFigureProposal(proposal, {
|
|
17
26
|
offer(detail) {
|
|
@@ -22,6 +31,10 @@ registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
|
|
|
22
31
|
const opened = pendNewFigure(steps, key);
|
|
23
32
|
navigate(modellerPathOf());
|
|
24
33
|
return opened;
|
|
34
|
+
},
|
|
35
|
+
savedInto: fetchProposalOutcome,
|
|
36
|
+
openSaved(id) {
|
|
37
|
+
navigate(modellerPathOf({ id }));
|
|
25
38
|
}
|
|
26
39
|
}, (key, params) => i18next.t(key, params));
|
|
27
40
|
if (outcome.note)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"figure-assistant.js","sourceRoot":"","sources":["../client/figure-assistant.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,mDAAmD,CAAA;AAE3F,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAA0B,MAAM,2BAA2B,CAAA;
|
|
1
|
+
{"version":3,"file":"figure-assistant.js","sourceRoot":"","sources":["../client/figure-assistant.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,mDAAmD,CAAA;AAE3F,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,iBAAiB,EAA0B,MAAM,2BAA2B,CAAA;AAEzH,MAAM,CAAC,MAAM,qBAAqB,GAAG,wBAAwB,CAAA;AAE7D;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,WAAmB;IAC7D,OAAO,mBAAmB,CAAC,MAAM,oBAAoB,CAAC,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAA;AAC9G,CAAC;AAED,uBAAuB,CAAC,qBAAqB,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE;IACzE,MAAM,OAAO,GAAG,MAAM,iBAAiB,CACrC,QAAQ,EACR;QACE,KAAK,CAAC,MAAyB;YAC7B,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,cAAc,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,CAAA;YACjE,OAAO,MAAM,CAAA;QACf,CAAC;QACD,OAAO,CAAC,KAAK,EAAE,GAAG;YAChB,MAAM,MAAM,GAAG,aAAa,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;YACxC,QAAQ,CAAC,cAAc,EAAE,CAAC,CAAA;YAC1B,OAAO,MAAM,CAAA;QACf,CAAC;QACD,SAAS,EAAE,oBAAoB;QAC/B,SAAS,CAAC,EAAE;YACV,QAAQ,CAAC,cAAc,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAA;QAClC,CAAC;KACF,EACD,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,CACxC,CAAA;IACD,IAAI,OAAO,CAAC,IAAI;QAAE,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;IACjG,OAAO,OAAO,CAAA;AAChB,CAAC,CAAC,CAAA","sourcesContent":["/*\n * What the AI dock does with a figure proposal - figure-ui declares it because figure-service declares the tool\n * (`proposeFigureCandidate`, design/ai/shared-dock.md §4).\n *\n * A proposal is a list of the modeller's own commands (ADR-0094). Running it applies them in the modeller as one step\n * of its history: a revision on the figure open there, at the version the model saw; a new figure on a new figure the\n * modeller opens. The outcome is said in words on every branch (`ai-proposal.ts`).\n */\nimport { i18next } from '@operato/i18n'\nimport { navigate } from '@operato/shell'\nimport { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js'\n\nimport { fetchProposalOutcome } from './graphql/index.js'\nimport { modellerPathOf } from './modeller/modeller-route.js'\nimport { pendNewFigure, proposalOutcomeFrom, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'\n\nexport const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'\n\n/**\n * The dock's \"check the result\" of a figure proposal, on every screen that offers its card: what the server recorded of\n * its save (ADR-0097 §5.6 item 5). The modeller asks it, and so does an app's home where a new figure's card is made --\n * there it was missing, and a card cut off by a reload stayed \"no answer\" for good (figure audit 2026-10-16 N8).\n */\nexport async function figureProposalOutcome(proposalKey: string) {\n return proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params))\n}\n\nregisterProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {\n const outcome = await runFigureProposal(\n proposal,\n {\n offer(detail: FigureApplyDetail) {\n window.dispatchEvent(new CustomEvent('figure-apply', { detail }))\n return detail\n },\n openNew(steps, key) {\n const opened = pendNewFigure(steps, key)\n navigate(modellerPathOf())\n return opened\n },\n savedInto: fetchProposalOutcome,\n openSaved(id) {\n navigate(modellerPathOf({ id }))\n }\n },\n (key, params) => i18next.t(key, params)\n )\n if (outcome.note) context.notify(outcome.verdict === 'accepted' ? 'info' : 'error', outcome.note)\n return outcome\n})\n"]}
|
|
@@ -19,6 +19,8 @@ export interface FigureApplyDetail extends FigureRefusalRecord {
|
|
|
19
19
|
key?: string;
|
|
20
20
|
steps: FigureProposalStep[];
|
|
21
21
|
create: boolean;
|
|
22
|
+
/** The figure a revision was made for: the one the model read. */
|
|
23
|
+
figureId?: string;
|
|
22
24
|
context?: string;
|
|
23
25
|
revision?: number;
|
|
24
26
|
applied?: boolean;
|
|
@@ -34,6 +36,14 @@ export interface FigureProposalTarget {
|
|
|
34
36
|
error?: string;
|
|
35
37
|
code?: string;
|
|
36
38
|
}>;
|
|
39
|
+
/** The figures the server recorded a save of this proposal into (`figureProposalOutcome`), first saved first. */
|
|
40
|
+
savedInto(key: string): Promise<ReadonlyArray<{
|
|
41
|
+
figureId: string;
|
|
42
|
+
type?: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
}>>;
|
|
45
|
+
/** Opens a saved figure in the modeller. */
|
|
46
|
+
openSaved(figureId: string): void;
|
|
37
47
|
}
|
|
38
48
|
export interface FigureProposalOutcome {
|
|
39
49
|
verdict: 'accepted' | 'refused' | 'unavailable';
|
|
@@ -64,18 +74,26 @@ export declare function takeFigureOffer(session: FigureProductSession, detail: F
|
|
|
64
74
|
say: Say;
|
|
65
75
|
}): boolean;
|
|
66
76
|
export declare function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome>;
|
|
77
|
+
type Settle = (result: {
|
|
78
|
+
applied: boolean;
|
|
79
|
+
error?: string;
|
|
80
|
+
code?: string;
|
|
81
|
+
}) => void;
|
|
67
82
|
export declare function pendNewFigure(steps: FigureProposalStep[], key?: string, ms?: number): Promise<{
|
|
68
83
|
applied: boolean;
|
|
69
84
|
error?: string;
|
|
70
85
|
code?: string;
|
|
71
86
|
}>;
|
|
87
|
+
/**
|
|
88
|
+
* For a page about to ask the person whether to leave unsaved edits: the new figure waiting to open stops its clock
|
|
89
|
+
* while they answer -- an answer is no absent page (figure audit 2026-10-16 N4). Give it the answer: leaving runs the
|
|
90
|
+
* clock again for the page that opens; staying says now that it was not opened. Nothing waiting: undefined.
|
|
91
|
+
*/
|
|
92
|
+
export declare function askingToLeave(): ((leaving: boolean) => void) | undefined;
|
|
72
93
|
/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
|
|
73
94
|
export declare function takePendingNewFigure(): {
|
|
74
95
|
steps: FigureProposalStep[];
|
|
75
96
|
key?: string;
|
|
76
|
-
settle:
|
|
77
|
-
applied: boolean;
|
|
78
|
-
error?: string;
|
|
79
|
-
code?: string;
|
|
80
|
-
}) => void;
|
|
97
|
+
settle: Settle;
|
|
81
98
|
} | undefined;
|
|
99
|
+
export {};
|