@things-factory/figure-ui 10.1.168 → 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 +10 -1
- package/client/modeller/ai-proposal.ts +40 -9
- package/client/modeller/product-session.ts +23 -9
- package/client/modeller/refusals.ts +14 -0
- package/client/pages/figure-modeller-page.ts +7 -4
- package/dist-client/figure-assistant.d.ts +6 -0
- package/dist-client/figure-assistant.js +9 -1
- package/dist-client/figure-assistant.js.map +1 -1
- package/dist-client/modeller/ai-proposal.d.ts +15 -5
- package/dist-client/modeller/ai-proposal.js +39 -10
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/product-session.d.ts +0 -1
- package/dist-client/modeller/product-session.js +22 -9
- 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 +7 -3
- 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 +6 -6
- package/test/ai-proposal.test.ts +43 -4
- package/test/figure-assistant-surface.test.ts +38 -0
- package/translations/en.json +6 -0
- package/translations/es.json +6 -0
- package/translations/ja.json +6 -0
- package/translations/ko.json +6 -0
- package/translations/ms.json +6 -0
- package/translations/zh.json +6 -0
|
@@ -12,10 +12,19 @@ import { registerProposalHandler } from '@things-factory/ai-assistant/dist-clien
|
|
|
12
12
|
|
|
13
13
|
import { fetchProposalOutcome } from './graphql/index.js'
|
|
14
14
|
import { modellerPathOf } from './modeller/modeller-route.js'
|
|
15
|
-
import { pendNewFigure, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'
|
|
15
|
+
import { pendNewFigure, proposalOutcomeFrom, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'
|
|
16
16
|
|
|
17
17
|
export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'
|
|
18
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
|
+
|
|
19
28
|
registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
|
|
20
29
|
const outcome = await runFigureProposal(
|
|
21
30
|
proposal,
|
|
@@ -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
|
|
@@ -82,7 +84,8 @@ function refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): F
|
|
|
82
84
|
? (error ?? '')
|
|
83
85
|
: clips
|
|
84
86
|
? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })
|
|
85
|
-
:
|
|
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 ?? '') })
|
|
86
89
|
return { verdict: 'refused', errorCode: code ?? 'refused', note }
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -99,6 +102,12 @@ export function takeFigureOffer(
|
|
|
99
102
|
): boolean {
|
|
100
103
|
const view = session.read()
|
|
101
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
|
|
102
111
|
if (detail.create && view.dirty) {
|
|
103
112
|
/* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */
|
|
104
113
|
detail.applied = false
|
|
@@ -128,7 +137,7 @@ export function takeFigureOffer(
|
|
|
128
137
|
|
|
129
138
|
export async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {
|
|
130
139
|
const steps = card?.steps
|
|
131
|
-
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') }
|
|
132
141
|
const create = card.create === true
|
|
133
142
|
/*
|
|
134
143
|
A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,
|
|
@@ -147,7 +156,7 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
|
|
|
147
156
|
note: say('figure.text.proposal-applied', { n: String(steps.length) })
|
|
148
157
|
})
|
|
149
158
|
|
|
150
|
-
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 })
|
|
151
160
|
if (detail.applied) return accepted()
|
|
152
161
|
if (detail.applied === false) return refused(detail, say)
|
|
153
162
|
|
|
@@ -166,11 +175,14 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
|
|
|
166
175
|
modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new
|
|
167
176
|
figure must not receive steps nobody is waiting for.
|
|
168
177
|
*/
|
|
169
|
-
|
|
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
|
|
170
180
|
|
|
171
181
|
export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {
|
|
172
182
|
pending?.settle({ applied: false, code: 'not-opened' })
|
|
173
183
|
return new Promise(resolve => {
|
|
184
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
185
|
+
const lapse = () => mine.settle({ applied: false, code: 'not-opened' })
|
|
174
186
|
const mine = {
|
|
175
187
|
steps,
|
|
176
188
|
key,
|
|
@@ -178,17 +190,36 @@ export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10
|
|
|
178
190
|
clearTimeout(timer)
|
|
179
191
|
if (pending === mine) pending = undefined
|
|
180
192
|
resolve(result)
|
|
193
|
+
},
|
|
194
|
+
hold: () => clearTimeout(timer),
|
|
195
|
+
resume: () => {
|
|
196
|
+
clearTimeout(timer)
|
|
197
|
+
timer = setTimeout(lapse, ms)
|
|
181
198
|
}
|
|
182
199
|
}
|
|
183
|
-
|
|
200
|
+
mine.resume()
|
|
184
201
|
pending = mine
|
|
185
202
|
})
|
|
186
203
|
}
|
|
187
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
|
+
|
|
188
221
|
/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
|
|
189
|
-
export function takePendingNewFigure():
|
|
190
|
-
| { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }
|
|
191
|
-
| undefined {
|
|
222
|
+
export function takePendingNewFigure(): { steps: FigureProposalStep[]; key?: string; settle: Settle } | undefined {
|
|
192
223
|
const taken = pending
|
|
193
224
|
pending = undefined
|
|
194
225
|
return taken
|
|
@@ -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
|
|
@@ -110,13 +119,18 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
110
119
|
}
|
|
111
120
|
return { dropClips: given.dropClips }
|
|
112
121
|
}
|
|
113
|
-
|
|
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() {
|
|
114
124
|
const view = workspace.read()
|
|
115
|
-
if (loading || error || !view.context || view.revision === null) throw
|
|
116
|
-
// Serializing writes also makes create retries safe: no second create while the first is pending.
|
|
117
|
-
if (saving) throw Error('저장이 끝난 뒤 편집해 주세요.')
|
|
125
|
+
if (loading || error || !view.context || view.revision === null) throw refusal('SESSION_NOT_OPEN', '먼저 모델을 정상적으로 열어 주세요.')
|
|
118
126
|
return { context: view.context, expectedRevision: view.revision, origin: 'ui' as const }
|
|
119
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
|
+
}
|
|
120
134
|
function clear() {
|
|
121
135
|
workspace.close({ discardDesignChanges: true })
|
|
122
136
|
id = undefined
|
|
@@ -179,7 +193,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
179
193
|
const control = writable()
|
|
180
194
|
// AI must supply the context AND revision captured when it started. Never substitute current values.
|
|
181
195
|
if (origin === 'ai' && (context === undefined || expectedRevision === undefined))
|
|
182
|
-
throw
|
|
196
|
+
throw refusal('SESSION_VERSION_MISSING', 'AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.')
|
|
183
197
|
return tracked('do', () =>
|
|
184
198
|
workspace.apply({
|
|
185
199
|
...control,
|
|
@@ -206,8 +220,8 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
206
220
|
* new figure not yet touched -- one being made is the person's, and keeps its own.
|
|
207
221
|
*/
|
|
208
222
|
createUnder(key: string) {
|
|
209
|
-
if (id || workspace.read().dirty) throw
|
|
210
|
-
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', '제안 식별자가 올바르지 않습니다.')
|
|
211
225
|
creationId = key
|
|
212
226
|
},
|
|
213
227
|
/** The AI proposals whose steps are in the model now, each once. */
|
|
@@ -229,7 +243,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
229
243
|
/** What a command would do, on a copy (ADR-0097 §5.5): nothing is written, and the answer names its version. */
|
|
230
244
|
previewCommand(action: FigureAuthoringAction) {
|
|
231
245
|
const view = workspace.read()
|
|
232
|
-
if (loading || error || !view.context || view.revision === null) throw
|
|
246
|
+
if (loading || error || !view.context || view.revision === null) throw refusal('SESSION_NOT_OPEN', '먼저 모델을 정상적으로 열어 주세요.')
|
|
233
247
|
return workspace.previewCommand({ context: view.context, action })
|
|
234
248
|
},
|
|
235
249
|
/** One authoring command — what a person means, not a graph edit — as one step of the same history. */
|
|
@@ -256,7 +270,7 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
256
270
|
return tracked('redo', () => workspace.redo(writable()))
|
|
257
271
|
},
|
|
258
272
|
source() {
|
|
259
|
-
const { context } =
|
|
273
|
+
const { context } = readable()
|
|
260
274
|
return workspace.export({ context }).text
|
|
261
275
|
},
|
|
262
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.
|
|
@@ -152,8 +153,7 @@ export class FigureModellerPage extends PageView {
|
|
|
152
153
|
detail: () => (this.session.read().dirty ? i18next.t('figure.text.unsaved-changes') : ''),
|
|
153
154
|
anchor: () => ({ anchorType: 'figure', anchorId: id }),
|
|
154
155
|
/* The card's "check the result": the server's record, never the dock's memory; nothing is applied again. */
|
|
155
|
-
proposalOutcome:
|
|
156
|
-
proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params)),
|
|
156
|
+
proposalOutcome: figureProposalOutcome,
|
|
157
157
|
hostContext: () => ({
|
|
158
158
|
surface: 'figure-modeller',
|
|
159
159
|
editorMode: this.session.read().id ? 'existing' : 'new',
|
|
@@ -389,6 +389,8 @@ export class FigureModellerPage extends PageView {
|
|
|
389
389
|
The shell asks this while the edited model is still on screen. The app's own prompt, as every operato screen
|
|
390
390
|
asks -- not the browser's confirm box (user, 2026-09-24).
|
|
391
391
|
*/
|
|
392
|
+
/* A new figure the dock is opening waits for this answer, not against a clock (audit N4). */
|
|
393
|
+
const answered = askingToLeave()
|
|
392
394
|
this.discardApproved = !!(await OxPrompt.open({
|
|
393
395
|
type: 'warning',
|
|
394
396
|
title: i18next.t('figure.text.unsaved-changes'),
|
|
@@ -396,6 +398,7 @@ export class FigureModellerPage extends PageView {
|
|
|
396
398
|
confirmButton: { text: i18next.t('figure.button.leave-without-saving') },
|
|
397
399
|
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
398
400
|
}))
|
|
401
|
+
answered?.(this.discardApproved)
|
|
399
402
|
return this.discardApproved
|
|
400
403
|
}
|
|
401
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>;
|
|
@@ -11,8 +11,16 @@ import { navigate } from '@operato/shell';
|
|
|
11
11
|
import { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js';
|
|
12
12
|
import { fetchProposalOutcome } from './graphql/index.js';
|
|
13
13
|
import { modellerPathOf } from './modeller/modeller-route.js';
|
|
14
|
-
import { pendNewFigure, runFigureProposal } from './modeller/ai-proposal.js';
|
|
14
|
+
import { pendNewFigure, proposalOutcomeFrom, runFigureProposal } from './modeller/ai-proposal.js';
|
|
15
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
|
+
}
|
|
16
24
|
registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
|
|
17
25
|
const outcome = await runFigureProposal(proposal, {
|
|
18
26
|
offer(detail) {
|
|
@@ -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,oBAAoB,EAAE,MAAM,oBAAoB,CAAA;AACzD,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;
|
|
@@ -72,18 +74,26 @@ export declare function takeFigureOffer(session: FigureProductSession, detail: F
|
|
|
72
74
|
say: Say;
|
|
73
75
|
}): boolean;
|
|
74
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;
|
|
75
82
|
export declare function pendNewFigure(steps: FigureProposalStep[], key?: string, ms?: number): Promise<{
|
|
76
83
|
applied: boolean;
|
|
77
84
|
error?: string;
|
|
78
85
|
code?: string;
|
|
79
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;
|
|
80
93
|
/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
|
|
81
94
|
export declare function takePendingNewFigure(): {
|
|
82
95
|
steps: FigureProposalStep[];
|
|
83
96
|
key?: string;
|
|
84
|
-
settle:
|
|
85
|
-
applied: boolean;
|
|
86
|
-
error?: string;
|
|
87
|
-
code?: string;
|
|
88
|
-
}) => void;
|
|
97
|
+
settle: Settle;
|
|
89
98
|
} | undefined;
|
|
99
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clipLossOf, refusalRecordOf } from './refusals.js';
|
|
1
|
+
import { clipLossOf, editRefusalOf, refusalRecordOf } from './refusals.js';
|
|
2
2
|
/**
|
|
3
3
|
* The dock's "check the result" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:
|
|
4
4
|
* accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,
|
|
@@ -25,7 +25,8 @@ function refused(refusal, say) {
|
|
|
25
25
|
? (error ?? '')
|
|
26
26
|
: clips
|
|
27
27
|
? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })
|
|
28
|
-
:
|
|
28
|
+
: /* A refusal with a code is said from it, as the modeller says an edit it refuses; never its message (audit N6). */
|
|
29
|
+
say('figure.text.proposal-refused', { reason: code ? editRefusalOf(refusal, (key, params) => say(key, params)) : (error ?? '') });
|
|
29
30
|
return { verdict: 'refused', errorCode: code ?? 'refused', note };
|
|
30
31
|
}
|
|
31
32
|
/**
|
|
@@ -38,6 +39,13 @@ export function takeFigureOffer(session, detail, { onSavedFigure, say }) {
|
|
|
38
39
|
const view = session.read();
|
|
39
40
|
if (detail.create && onSavedFigure)
|
|
40
41
|
return false;
|
|
42
|
+
/*
|
|
43
|
+
A revision is for the figure the model read; with another open it is left untaken, and the handler says it needs that
|
|
44
|
+
figure open. The version is no proof of the figure: two figures opened in two page loads stood at the same one
|
|
45
|
+
(figure audit 2026-10-16 N3).
|
|
46
|
+
*/
|
|
47
|
+
if (!detail.create && typeof detail.figureId === 'string' && detail.figureId !== (view.id ?? view.creationId))
|
|
48
|
+
return false;
|
|
41
49
|
if (detail.create && view.dirty) {
|
|
42
50
|
/* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */
|
|
43
51
|
detail.applied = false;
|
|
@@ -71,7 +79,7 @@ export function takeFigureOffer(session, detail, { onSavedFigure, say }) {
|
|
|
71
79
|
export async function runFigureProposal(card, target, say) {
|
|
72
80
|
const steps = card?.steps;
|
|
73
81
|
if (!Array.isArray(steps) || !steps.length)
|
|
74
|
-
return { verdict: 'unavailable', errorCode: 'no-steps' };
|
|
82
|
+
return { verdict: 'unavailable', errorCode: 'no-steps', note: say('figure.text.proposal-empty') };
|
|
75
83
|
const create = card.create === true;
|
|
76
84
|
/*
|
|
77
85
|
A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,
|
|
@@ -89,7 +97,7 @@ export async function runFigureProposal(card, target, say) {
|
|
|
89
97
|
applied: steps.length,
|
|
90
98
|
note: say('figure.text.proposal-applied', { n: String(steps.length) })
|
|
91
99
|
});
|
|
92
|
-
const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision });
|
|
100
|
+
const detail = target.offer({ key: card.key, steps, create, figureId: create ? undefined : card.figureId, context: card.context, revision: card.revision });
|
|
93
101
|
if (detail.applied)
|
|
94
102
|
return accepted();
|
|
95
103
|
if (detail.applied === false)
|
|
@@ -104,15 +112,12 @@ export async function runFigureProposal(card, target, say) {
|
|
|
104
112
|
? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }
|
|
105
113
|
: refused(opened, say);
|
|
106
114
|
}
|
|
107
|
-
/*
|
|
108
|
-
A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the
|
|
109
|
-
modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new
|
|
110
|
-
figure must not receive steps nobody is waiting for.
|
|
111
|
-
*/
|
|
112
115
|
let pending;
|
|
113
116
|
export function pendNewFigure(steps, key, ms = 10_000) {
|
|
114
117
|
pending?.settle({ applied: false, code: 'not-opened' });
|
|
115
118
|
return new Promise(resolve => {
|
|
119
|
+
let timer;
|
|
120
|
+
const lapse = () => mine.settle({ applied: false, code: 'not-opened' });
|
|
116
121
|
const mine = {
|
|
117
122
|
steps,
|
|
118
123
|
key,
|
|
@@ -121,12 +126,36 @@ export function pendNewFigure(steps, key, ms = 10_000) {
|
|
|
121
126
|
if (pending === mine)
|
|
122
127
|
pending = undefined;
|
|
123
128
|
resolve(result);
|
|
129
|
+
},
|
|
130
|
+
hold: () => clearTimeout(timer),
|
|
131
|
+
resume: () => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
timer = setTimeout(lapse, ms);
|
|
124
134
|
}
|
|
125
135
|
};
|
|
126
|
-
|
|
136
|
+
mine.resume();
|
|
127
137
|
pending = mine;
|
|
128
138
|
});
|
|
129
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* For a page about to ask the person whether to leave unsaved edits: the new figure waiting to open stops its clock
|
|
142
|
+
* while they answer -- an answer is no absent page (figure audit 2026-10-16 N4). Give it the answer: leaving runs the
|
|
143
|
+
* clock again for the page that opens; staying says now that it was not opened. Nothing waiting: undefined.
|
|
144
|
+
*/
|
|
145
|
+
export function askingToLeave() {
|
|
146
|
+
const waiting = pending;
|
|
147
|
+
if (!waiting)
|
|
148
|
+
return undefined;
|
|
149
|
+
waiting.hold();
|
|
150
|
+
return leaving => {
|
|
151
|
+
if (pending !== waiting)
|
|
152
|
+
return;
|
|
153
|
+
if (leaving)
|
|
154
|
+
waiting.resume();
|
|
155
|
+
else
|
|
156
|
+
waiting.settle({ applied: false, code: 'not-opened' });
|
|
157
|
+
};
|
|
158
|
+
}
|
|
130
159
|
/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
|
|
131
160
|
export function takePendingNewFigure() {
|
|
132
161
|
const taken = pending;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-proposal.js","sourceRoot":"","sources":["../../client/modeller/ai-proposal.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * An AI proposal reaching the modeller (ADR-0094). The dock's card carries the modeller's own commands; running it\n * applies them as one step of the modeller's history, undone whole, on the version the model saw. Nothing half made\n * is applied, and every outcome is said: applied, refused with the reason, or not possible here.\n *\n * Kept free of Lit, i18n and the shell so node tests read it directly; the handler and the page pass in what they own.\n */\nimport type { FigureProposalStep } from '@hatiolab/figure-model'\nimport type { FigureProductSession } from './product-session.js'\nimport { clipLossOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'\n\n/** What the card carries, as `proposeFigureCandidate` returned it. */\nexport interface FigureProposalCard {\n /** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */\n key?: string\n steps?: FigureProposalStep[]\n create?: boolean\n figureId?: string\n context?: string\n revision?: number\n}\n\n/**\n * The `figure-apply` event's detail. The page that takes it sets `applied`, and when refused `error` and the refusal in\n * figure-model's shape (`code`, `details`, the `clips` it would stop).\n */\nexport interface FigureApplyDetail extends FigureRefusalRecord {\n key?: string\n steps: FigureProposalStep[]\n create: boolean\n context?: string\n revision?: number\n applied?: boolean\n error?: string\n}\n\nexport type Say = (key: string, params?: Record<string, string>) => string\n\nexport interface FigureProposalTarget {\n /** Offers the proposal to a modeller on screen. It comes back untouched when none took it. */\n offer(detail: FigureApplyDetail): FigureApplyDetail\n /** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */\n openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>\n /** The figures the server recorded a save of this proposal into (`figureProposalOutcome`), first saved first. */\n savedInto(key: string): Promise<ReadonlyArray<{ figureId: string; type?: string; name?: string }>>\n /** Opens a saved figure in the modeller. */\n openSaved(figureId: string): void\n}\n\nexport interface FigureProposalOutcome {\n verdict: 'accepted' | 'refused' | 'unavailable'\n errorCode?: string\n applied?: number\n note?: string\n}\n\n/**\n * The dock's \"check the result\" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:\n * accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,\n * which the dock keeps as \"not known\" and never turns into a refusal. Nothing is run again.\n */\nexport function proposalOutcomeFrom(saved: ReadonlyArray<{ figureId: string; type?: string; name?: string }>, say: Say): FigureProposalOutcome | undefined {\n if (!saved.length) return undefined\n const names = saved.map(f => f.name || f.type || f.figureId).join(', ')\n return { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names }) }\n}\n\n/** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */\nconst STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT'])\n\nfunction refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): FigureProposalOutcome {\n const { code, error } = refusal\n const clips = clipLossOf(refusal)\n /*\n A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not\n asked about -- the person edits by hand if they mean it (designer 2026-09-27).\n */\n const note =\n code && STALE.has(code)\n ? say('figure.text.proposal-stale')\n : code === 'NEW_FIGURE_UNSAVED'\n ? (error ?? '')\n : clips\n ? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })\n : say('figure.text.proposal-refused', { reason: error ?? '' })\n return { verdict: 'refused', errorCode: code ?? 'refused', note }\n}\n\n/**\n * The modeller page's way of taking an offer from the dock (`figure-apply`): input changes, or a proposal's commands\n * as one step (ADR-0094). A revision is applied on the version the model saw; a new figure's proposal only on a new\n * figure not yet touched, and a page on a saved figure leaves it untaken. Never with a consent to let clips go: a\n * proposal that would stop one is refused whole, the clips named (designer 2026-09-27). True when it was applied.\n */\nexport function takeFigureOffer(\n session: FigureProductSession,\n detail: FigureApplyDetail & { changes?: unknown },\n { onSavedFigure, say }: { onSavedFigure: boolean; say: Say }\n): boolean {\n const view = session.read()\n if (detail.create && onSavedFigure) return false\n if (detail.create && view.dirty) {\n /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */\n detail.applied = false\n detail.code = 'NEW_FIGURE_UNSAVED'\n detail.error = say('figure.text.proposal-new-figure-unsaved')\n return false\n }\n try {\n if (detail.create) {\n /*\n Taken on the new figure the modeller has open, whose conversation asked for it: the figure keeps its own creation\n id, the anchor of that conversation (ADR-0061 decision 3, ruled 2026-10-16). One proposal stays one figure by the\n save's record of it, which `runFigureProposal` reads before the card runs again.\n */\n session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)\n } else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)\n else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)\n detail.applied = true\n return true\n } catch (error) {\n detail.applied = false\n Object.assign(detail, refusalRecordOf(error))\n detail.error = error instanceof Error ? error.message : String(error)\n return false\n }\n}\n\nexport async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {\n const steps = card?.steps\n if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps' }\n const create = card.create === true\n /*\n A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,\n 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.\n */\n if (create && card.key) {\n const saved = await target.savedInto(card.key)\n if (saved.length) {\n target.openSaved(saved[0]!.figureId)\n return proposalOutcomeFrom(saved, say)!\n }\n }\n const accepted = (): FigureProposalOutcome => ({\n verdict: 'accepted',\n applied: steps.length,\n note: say('figure.text.proposal-applied', { n: String(steps.length) })\n })\n\n const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision })\n if (detail.applied) return accepted()\n if (detail.applied === false) return refused(detail, say)\n\n /* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */\n if (!create) return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') }\n\n const opened = await target.openNew(steps, card.key)\n if (opened.applied) return accepted()\n return opened.code === 'not-opened'\n ? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }\n : refused(opened, say)\n}\n\n/*\n A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the\n modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new\n figure must not receive steps nobody is waiting for.\n*/\nlet pending: { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined\n\nexport function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {\n pending?.settle({ applied: false, code: 'not-opened' })\n return new Promise(resolve => {\n const mine = {\n steps,\n key,\n settle: (result: { applied: boolean; error?: string; code?: string }) => {\n clearTimeout(timer)\n if (pending === mine) pending = undefined\n resolve(result)\n }\n }\n const timer = setTimeout(() => mine.settle({ applied: false, code: 'not-opened' }), ms)\n pending = mine\n })\n}\n\n/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */\nexport function takePendingNewFigure():\n | { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }\n | undefined {\n const taken = pending\n pending = undefined\n return taken\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ai-proposal.js","sourceRoot":"","sources":["../../client/modeller/ai-proposal.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * An AI proposal reaching the modeller (ADR-0094). The dock's card carries the modeller's own commands; running it\n * applies them as one step of the modeller's history, undone whole, on the version the model saw. Nothing half made\n * is applied, and every outcome is said: applied, refused with the reason, or not possible here.\n *\n * Kept free of Lit, i18n and the shell so node tests read it directly; the handler and the page pass in what they own.\n */\nimport type { FigureProposalStep } from '@hatiolab/figure-model'\nimport type { FigureProductSession } from './product-session.js'\nimport { clipLossOf, editRefusalOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'\n\n/** What the card carries, as `proposeFigureCandidate` returned it. */\nexport interface FigureProposalCard {\n /** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */\n key?: string\n steps?: FigureProposalStep[]\n create?: boolean\n figureId?: string\n context?: string\n revision?: number\n}\n\n/**\n * The `figure-apply` event's detail. The page that takes it sets `applied`, and when refused `error` and the refusal in\n * figure-model's shape (`code`, `details`, the `clips` it would stop).\n */\nexport interface FigureApplyDetail extends FigureRefusalRecord {\n key?: string\n steps: FigureProposalStep[]\n create: boolean\n /** The figure a revision was made for: the one the model read. */\n figureId?: string\n context?: string\n revision?: number\n applied?: boolean\n error?: string\n}\n\nexport type Say = (key: string, params?: Record<string, string>) => string\n\nexport interface FigureProposalTarget {\n /** Offers the proposal to a modeller on screen. It comes back untouched when none took it. */\n offer(detail: FigureApplyDetail): FigureApplyDetail\n /** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */\n openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>\n /** The figures the server recorded a save of this proposal into (`figureProposalOutcome`), first saved first. */\n savedInto(key: string): Promise<ReadonlyArray<{ figureId: string; type?: string; name?: string }>>\n /** Opens a saved figure in the modeller. */\n openSaved(figureId: string): void\n}\n\nexport interface FigureProposalOutcome {\n verdict: 'accepted' | 'refused' | 'unavailable'\n errorCode?: string\n applied?: number\n note?: string\n}\n\n/**\n * The dock's \"check the result\" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:\n * accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,\n * which the dock keeps as \"not known\" and never turns into a refusal. Nothing is run again.\n */\nexport function proposalOutcomeFrom(saved: ReadonlyArray<{ figureId: string; type?: string; name?: string }>, say: Say): FigureProposalOutcome | undefined {\n if (!saved.length) return undefined\n const names = saved.map(f => f.name || f.type || f.figureId).join(', ')\n return { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names }) }\n}\n\n/** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */\nconst STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT'])\n\nfunction refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): FigureProposalOutcome {\n const { code, error } = refusal\n const clips = clipLossOf(refusal)\n /*\n A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not\n asked about -- the person edits by hand if they mean it (designer 2026-09-27).\n */\n const note =\n code && STALE.has(code)\n ? say('figure.text.proposal-stale')\n : code === 'NEW_FIGURE_UNSAVED'\n ? (error ?? '')\n : clips\n ? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })\n : /* A refusal with a code is said from it, as the modeller says an edit it refuses; never its message (audit N6). */\n say('figure.text.proposal-refused', { reason: code ? editRefusalOf(refusal, (key, params) => say(key, params)) : (error ?? '') })\n return { verdict: 'refused', errorCode: code ?? 'refused', note }\n}\n\n/**\n * The modeller page's way of taking an offer from the dock (`figure-apply`): input changes, or a proposal's commands\n * as one step (ADR-0094). A revision is applied on the version the model saw; a new figure's proposal only on a new\n * figure not yet touched, and a page on a saved figure leaves it untaken. Never with a consent to let clips go: a\n * proposal that would stop one is refused whole, the clips named (designer 2026-09-27). True when it was applied.\n */\nexport function takeFigureOffer(\n session: FigureProductSession,\n detail: FigureApplyDetail & { changes?: unknown },\n { onSavedFigure, say }: { onSavedFigure: boolean; say: Say }\n): boolean {\n const view = session.read()\n if (detail.create && onSavedFigure) return false\n /*\n A revision is for the figure the model read; with another open it is left untaken, and the handler says it needs that\n figure open. The version is no proof of the figure: two figures opened in two page loads stood at the same one\n (figure audit 2026-10-16 N3).\n */\n if (!detail.create && typeof detail.figureId === 'string' && detail.figureId !== (view.id ?? view.creationId)) return false\n if (detail.create && view.dirty) {\n /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */\n detail.applied = false\n detail.code = 'NEW_FIGURE_UNSAVED'\n detail.error = say('figure.text.proposal-new-figure-unsaved')\n return false\n }\n try {\n if (detail.create) {\n /*\n Taken on the new figure the modeller has open, whose conversation asked for it: the figure keeps its own creation\n id, the anchor of that conversation (ADR-0061 decision 3, ruled 2026-10-16). One proposal stays one figure by the\n save's record of it, which `runFigureProposal` reads before the card runs again.\n */\n session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)\n } else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)\n else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)\n detail.applied = true\n return true\n } catch (error) {\n detail.applied = false\n Object.assign(detail, refusalRecordOf(error))\n detail.error = error instanceof Error ? error.message : String(error)\n return false\n }\n}\n\nexport async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {\n const steps = card?.steps\n if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps', note: say('figure.text.proposal-empty') }\n const create = card.create === true\n /*\n A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,\n 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.\n */\n if (create && card.key) {\n const saved = await target.savedInto(card.key)\n if (saved.length) {\n target.openSaved(saved[0]!.figureId)\n return proposalOutcomeFrom(saved, say)!\n }\n }\n const accepted = (): FigureProposalOutcome => ({\n verdict: 'accepted',\n applied: steps.length,\n note: say('figure.text.proposal-applied', { n: String(steps.length) })\n })\n\n const detail = target.offer({ key: card.key, steps, create, figureId: create ? undefined : card.figureId, context: card.context, revision: card.revision })\n if (detail.applied) return accepted()\n if (detail.applied === false) return refused(detail, say)\n\n /* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */\n if (!create) return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') }\n\n const opened = await target.openNew(steps, card.key)\n if (opened.applied) return accepted()\n return opened.code === 'not-opened'\n ? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }\n : refused(opened, say)\n}\n\n/*\n A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the\n modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new\n figure must not receive steps nobody is waiting for.\n*/\ntype Settle = (result: { applied: boolean; error?: string; code?: string }) => void\nlet pending: { steps: FigureProposalStep[]; key?: string; settle: Settle; hold(): void; resume(): void } | undefined\n\nexport function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {\n pending?.settle({ applied: false, code: 'not-opened' })\n return new Promise(resolve => {\n let timer: ReturnType<typeof setTimeout> | undefined\n const lapse = () => mine.settle({ applied: false, code: 'not-opened' })\n const mine = {\n steps,\n key,\n settle: (result: { applied: boolean; error?: string; code?: string }) => {\n clearTimeout(timer)\n if (pending === mine) pending = undefined\n resolve(result)\n },\n hold: () => clearTimeout(timer),\n resume: () => {\n clearTimeout(timer)\n timer = setTimeout(lapse, ms)\n }\n }\n mine.resume()\n pending = mine\n })\n}\n\n/**\n * For a page about to ask the person whether to leave unsaved edits: the new figure waiting to open stops its clock\n * while they answer -- an answer is no absent page (figure audit 2026-10-16 N4). Give it the answer: leaving runs the\n * clock again for the page that opens; staying says now that it was not opened. Nothing waiting: undefined.\n */\nexport function askingToLeave(): ((leaving: boolean) => void) | undefined {\n const waiting = pending\n if (!waiting) return undefined\n waiting.hold()\n return leaving => {\n if (pending !== waiting) return\n if (leaving) waiting.resume()\n else waiting.settle({ applied: false, code: 'not-opened' })\n }\n}\n\n/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */\nexport function takePendingNewFigure(): { steps: FigureProposalStep[]; key?: string; settle: Settle } | undefined {\n const taken = pending\n pending = undefined\n return taken\n}\n"]}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { type FigureRevision } from './revision.js';
|
|
2
2
|
import { type FigureInputChange, type FigurePrimitiveCreation, type FigurePartEdit, type FigureAuthoringAction, type FigureProposalStep, type FigureClipConsent } from '@hatiolab/figure-model';
|
|
3
|
-
/** Consent to let clips go, and the figure and version it was given on. */
|
|
4
3
|
export interface FigureClipConsentFor extends FigureClipConsent {
|
|
5
4
|
context: string;
|
|
6
5
|
revision: number;
|