Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions libs/map-editor/src/lib/ai/ai-panel.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,16 @@ export function readableModel(model: string): string {
}}
</p>
}
@if (repairNote(turn)) {
<p
class="meta"
repaired
>
{{
repairNote(turn)
}}
</p>
}
@if (
turn.proposal.questions
.length
Expand Down Expand Up @@ -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 '';
Expand Down
3 changes: 3 additions & 0 deletions libs/map-editor/src/lib/ai/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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?',
Expand Down
12 changes: 12 additions & 0 deletions libs/map-editor/src/lib/ai/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -101,6 +111,8 @@ export interface MapAIJob {
server_issues: Record<string, string>;
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;
Expand Down
1 change: 1 addition & 0 deletions libs/map-editor/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const ATTR_ALLOW_LIST: ReadonlySet<string> = new Set([
'font-family',
'font-weight',
'text-anchor',
'dominant-baseline',
]);

/** Properties `set_style_rule` declarations may carry. */
Expand Down
2 changes: 1 addition & 1 deletion libs/map-editor/src/lib/ops-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
18 changes: 18 additions & 0 deletions libs/map-editor/src/lib/value-syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 31 additions & 1 deletion libs/map-editor/src/tests/ai-panel.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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') }),
Expand Down
27 changes: 27 additions & 0 deletions libs/map-editor/src/tests/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="bg"><rect x="0" y="0" width="100" height="100"/></g></svg>`,
{ 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',
Expand Down
Loading