diff --git a/libs/map-editor/src/lib/ai/ai-panel.component.ts b/libs/map-editor/src/lib/ai/ai-panel.component.ts index 384e721917..4360470507 100644 --- a/libs/map-editor/src/lib/ai/ai-panel.component.ts +++ b/libs/map-editor/src/lib/ai/ai-panel.component.ts @@ -268,6 +268,16 @@ export function readableModel(model: string): string { }}

} + @if (repairNote(turn)) { +

+ {{ + repairNote(turn) + }} +

+ } @if ( turn.proposal.questions .length @@ -2247,6 +2257,18 @@ export class MapEditorAiPanelComponent implements OnInit, OnDestroy { } } + /** How many rejected changes the server's repair round corrected, when it did. */ + public repairNote(turn: ThreadTurn): string { + const thread = this.thread(); + if (!thread || turn.kind !== 'proposal') return ''; + const repair = turn.proposal.job.repair; + if (!repair?.used) return ''; + const corrected = repair.rejected - (repair.remaining ?? 0); + return corrected > 0 + ? format(thread.strings.repaired_note, { count: corrected }) + : ''; + } + public outcomeText(turn: ThreadTurn): string { const thread = this.thread(); if (!thread || turn.kind !== 'proposal') return ''; diff --git a/libs/map-editor/src/lib/ai/strings.ts b/libs/map-editor/src/lib/ai/strings.ts index b3e2acef8c..e80c4cd6fe 100644 --- a/libs/map-editor/src/lib/ai/strings.ts +++ b/libs/map-editor/src/lib/ai/strings.ts @@ -45,6 +45,8 @@ export interface MapEditorAiStrings { questions_title: string; /** Shown under a question from the model. */ questions_hint: string; + /** `{count}` changes the server rejected and the AI corrected when asked again. */ + repaired_note: string; keep: string; undo: string; reason_prompt: string; @@ -189,6 +191,7 @@ export const DEFAULT_AI_STRINGS: MapEditorAiStrings = { questions_title: 'The AI has a question', questions_hint: 'Click the element on the map to select it, then answer below; the selection is sent with your reply.', + repaired_note: 'Rejected changes corrected automatically: {count}', keep: 'Keep', undo: 'Undo', reason_prompt: 'What was wrong?', diff --git a/libs/map-editor/src/lib/ai/types.ts b/libs/map-editor/src/lib/ai/types.ts index 6022edf3c8..18ffa6da1c 100644 --- a/libs/map-editor/src/lib/ai/types.ts +++ b/libs/map-editor/src/lib/ai/types.ts @@ -80,6 +80,16 @@ export interface MapAIUsageInfo { attempts?: number | null; } +/** The server's repair round: one more call naming the ops it rejected and why. */ +export interface MapAIRepairInfo { + /** Ops the server rejected in the first answer. */ + rejected: number; + /** Ops it rejected in the second answer; null when that answer was unusable. */ + remaining: number | null; + /** Whether the second answer replaced the first. */ + used: boolean; +} + /** A map edit job as the editor sees it. */ export interface MapAIJob { id: string; @@ -101,6 +111,8 @@ export interface MapAIJob { server_issues: Record; questions: string[]; usage: MapAIUsageInfo | null; + /** Absent unless the server asked the model again about the ops it rejected. */ + repair?: MapAIRepairInfo | null; resolution: MapAIResolution | null; error_kind: MapAIErrorKind | string | null; error_message: string | null; diff --git a/libs/map-editor/src/lib/constants.ts b/libs/map-editor/src/lib/constants.ts index c9f3ec518d..848df15074 100644 --- a/libs/map-editor/src/lib/constants.ts +++ b/libs/map-editor/src/lib/constants.ts @@ -26,6 +26,7 @@ export const ATTR_ALLOW_LIST: ReadonlySet = new Set([ 'font-family', 'font-weight', 'text-anchor', + 'dominant-baseline', ]); /** Properties `set_style_rule` declarations may carry. */ diff --git a/libs/map-editor/src/lib/ops-schema.json b/libs/map-editor/src/lib/ops-schema.json index 791efac7a3..bdddc75a93 100644 --- a/libs/map-editor/src/lib/ops-schema.json +++ b/libs/map-editor/src/lib/ops-schema.json @@ -33,7 +33,7 @@ }, "name": { "type": "string", - "description": "fill, stroke, stroke-width, stroke-dasharray, fill-opacity, stroke-opacity, opacity, class, visibility, rx, ry, font-size, font-family, font-weight, text-anchor or data-*" + "description": "fill, stroke, stroke-width, stroke-dasharray, fill-opacity, stroke-opacity, opacity, class, visibility, rx, ry, font-size, font-family, font-weight, text-anchor, dominant-baseline or data-*" }, "value": { "type": ["string", "null"], diff --git a/libs/map-editor/src/lib/value-syntax.ts b/libs/map-editor/src/lib/value-syntax.ts index 55f8e820d3..fdf8540ad0 100644 --- a/libs/map-editor/src/lib/value-syntax.ts +++ b/libs/map-editor/src/lib/value-syntax.ts @@ -30,6 +30,20 @@ const NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/; const LENGTH = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:px|pt|pc|mm|cm|in|em|rem|ex|%)?$/; const CLASS_TOKEN = /^-?[A-Za-z_][A-Za-z0-9_-]*$/; +/** `dominant-baseline` keywords from SVG 2, plus the two SVG 1.1 edge keywords browsers still honour. */ +const DOMINANT_BASELINES = new Set([ + 'auto', + 'alphabetic', + 'central', + 'hanging', + 'ideographic', + 'mathematical', + 'middle', + 'text-after-edge', + 'text-before-edge', + 'text-bottom', + 'text-top', +]); const UNSAFE_TEXT = /url\(|expression\(|behavior:|javascript:|<|>|[\r\n]/i; function cssSupports(property: string, value: string): boolean | null { @@ -141,6 +155,10 @@ export function attributeValueIssue( return ['start', 'middle', 'end'].includes(value.trim()) ? null : `"${value}" is not a text anchor`; + case 'dominant-baseline': + return DOMINANT_BASELINES.has(value.trim()) + ? null + : `"${value}" is not a dominant baseline`; default: if (name.startsWith('data-')) { return /[<>]/.test(value) diff --git a/libs/map-editor/src/tests/ai-panel.component.spec.ts b/libs/map-editor/src/tests/ai-panel.component.spec.ts index d2008f1924..1c8aa6b28a 100644 --- a/libs/map-editor/src/tests/ai-panel.component.spec.ts +++ b/libs/map-editor/src/tests/ai-panel.component.spec.ts @@ -3,7 +3,7 @@ import { MapEditorAiPanelComponent, readableModel, } from '../lib/ai/ai-panel.component'; -import { MapAIEditRequest } from '../lib/ai/types'; +import { MapAIEditRequest, MapAIJob } from '../lib/ai/types'; import { MapDocument } from '../lib/map-document'; import { MapOp } from '../lib/ops'; import { @@ -158,6 +158,36 @@ describe('MapEditorAiPanelComponent', () => { expect(markers).toEqual(['1:true 2:true', '1:true 2:false']); }); + it('notes the rejected changes the server had the AI correct, and only when it used them', async () => { + const proposal = async (repair: MapAIJob['repair']) => { + const sequence = jobSequence({ + summary: 'Recoloured.', + ops: [recolour(doc, 'area-1.01-status', '#d1fae5')], + repair, + }); + const fixture = await mount( + new FakeTransport({ + edit: sequence.accepted, + polls: sequence.polls, + }), + ); + await fixture.componentInstance.thread()!.submit('Recolour'); + await settle(fixture); + const note = text(fixture, '[proposal-card] [repaired]'); + TestBed.resetTestingModule(); + doc = MapDocument.load(OFFICE); + session = new FakeSession(doc); + return note; + }; + expect(await proposal({ rejected: 3, remaining: 1, used: true })).toBe( + 'Rejected changes corrected automatically: 2', + ); + expect(await proposal({ rejected: 2, remaining: 2, used: false })).toBe( + '', + ); + expect(await proposal(undefined)).toBe(''); + }); + it('renders nothing visible when the deployment has no map AI', async () => { const fixture = await mount( new FakeTransport({ capabilities: httpError(404, 'not found') }), diff --git a/libs/map-editor/src/tests/validate.spec.ts b/libs/map-editor/src/tests/validate.spec.ts index f341fb0614..98fcaca5b2 100644 --- a/libs/map-editor/src/tests/validate.spec.ts +++ b/libs/map-editor/src/tests/validate.spec.ts @@ -126,6 +126,33 @@ describe('validateProposal', () => { } }); + it('accepts a dominant-baseline keyword on an inserted label and rejects any other value', async () => { + const label = (baseline: string): MapOp => ({ + op: 'insert_shape', + why: '', + kind: 'text', + text: 'Sydney Room 1', + parent: { id: 'bg' }, + attrs: [ + { name: 'x', value: '50' }, + { name: 'y', value: '50' }, + { name: 'text-anchor', value: 'middle' }, + { name: 'dominant-baseline', value: baseline }, + ], + new_id: 'ai-label', + }); + const doc = MapDocument.load( + ``, + { geometry }, + ); + const ok = await single(label('middle'), {}, doc); + expect(ok.codes).toEqual([]); + expect(ok.row.locked).toBe(false); + const bad = await single(label('centre'), {}, doc); + expect(bad.codes).toEqual(['bad_value']); + expect(bad.row.locked).toBe(true); + }); + it('rejects a bad colour such as "#f28c28."', async () => { const bad = await single({ op: 'set_attr',