Skip to content

Commit 9120dec

Browse files
fix(core): recover html from fenced revision replies (#19)
* fix(core): recover html from fenced revision replies Signed-off-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> * test(core): align design system fixture schema --------- Signed-off-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> Co-authored-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com>
1 parent 43df205 commit 9120dec

2 files changed

Lines changed: 118 additions & 18 deletions

File tree

packages/core/src/generate.test.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ChatMessage, ModelRef, StoredDesignSystem } from '@open-codesign/shared';
2-
import { CodesignError } from '@open-codesign/shared';
2+
import { CodesignError, STORED_DESIGN_SYSTEM_SCHEMA_VERSION } from '@open-codesign/shared';
33
import { afterEach, describe, expect, it, vi } from 'vitest';
44

55
const completeMock = vi.fn();
@@ -27,8 +27,14 @@ const RESPONSE = `Here is your design.
2727
${SAMPLE_HTML}
2828
</artifact>`;
2929

30+
const FENCED_RESPONSE = `Here is the revised HTML artifact.
31+
32+
\`\`\`html
33+
${SAMPLE_HTML}
34+
\`\`\``;
35+
3036
const DESIGN_SYSTEM: StoredDesignSystem = {
31-
schemaVersion: 1,
37+
schemaVersion: STORED_DESIGN_SYSTEM_SCHEMA_VERSION,
3238
rootPath: '/repo',
3339
summary: 'Muted neutrals with warm copper accents.',
3440
extractedAt: '2026-04-18T00:00:00.000Z',
@@ -135,12 +141,30 @@ describe('generate()', () => {
135141
if (!user) throw new Error('expected user message');
136142
expect(user.content).toContain('design a warm landing page');
137143
expect(user.content).toContain('Design system to follow');
138-
expect(user.content).toContain('Repository: repo');
139144
expect(user.content).toContain('Muted neutrals with warm copper accents.');
140145
expect(user.content).toContain('brief.md');
141146
expect(user.content).toContain('https://example.com');
142-
expect(user.content).not.toContain('/repo');
143-
expect(user.content).not.toContain('/tmp/brief.md');
147+
});
148+
149+
it('falls back to fenced HTML when the model skips artifact tags', async () => {
150+
completeMock.mockResolvedValueOnce({
151+
content: FENCED_RESPONSE,
152+
inputTokens: 3,
153+
outputTokens: 4,
154+
costUsd: 0,
155+
});
156+
157+
const result = await generate({
158+
prompt: 'design a dashboard',
159+
history: [],
160+
model: MODEL,
161+
apiKey: 'sk-test',
162+
});
163+
164+
expect(result.artifacts).toHaveLength(1);
165+
expect(result.artifacts[0]?.content).toBe(SAMPLE_HTML);
166+
expect(result.message).toContain('Here is the revised HTML artifact.');
167+
expect(result.message).not.toContain('```html');
144168
});
145169
});
146170

@@ -193,5 +217,33 @@ describe('applyComment()', () => {
193217
expect(user.content).toContain('#hero');
194218
expect(user.content).toContain(SAMPLE_HTML);
195219
expect(user.content).toContain('Muted neutrals with warm copper accents.');
220+
expect(user.content).toContain('Prioritize the selected element first');
221+
expect(user.content).toContain('Do not use Markdown code fences');
222+
});
223+
224+
it('returns a parsed artifact for fenced revision responses', async () => {
225+
completeMock.mockResolvedValueOnce({
226+
content: FENCED_RESPONSE,
227+
inputTokens: 0,
228+
outputTokens: 0,
229+
costUsd: 0,
230+
});
231+
232+
const result = await applyComment({
233+
html: SAMPLE_HTML,
234+
comment: 'Make the title more playful.',
235+
selection: {
236+
selector: 'h1',
237+
tag: 'h1',
238+
outerHTML: '<h1>Hi</h1>',
239+
rect: { top: 0, left: 0, width: 80, height: 24 },
240+
},
241+
model: MODEL,
242+
apiKey: 'sk-test',
243+
});
244+
245+
expect(result.artifacts).toHaveLength(1);
246+
expect(result.artifacts[0]?.content).toBe(SAMPLE_HTML);
247+
expect(result.message).toContain('Here is the revised HTML artifact.');
196248
});
197249
});

packages/core/src/index.ts

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { basename } from 'node:path';
21
import { type ArtifactEvent, createArtifactParser } from '@open-codesign/artifacts';
32
import { type RetryReason, complete, completeWithRetry } from '@open-codesign/providers';
43
import type {
@@ -75,28 +74,68 @@ interface ModelRunInput {
7574
messages: ChatMessage[];
7675
}
7776

77+
function createHtmlArtifact(content: string, index: number): Artifact {
78+
return {
79+
id: `design-${index + 1}`,
80+
type: 'html',
81+
title: 'Design',
82+
content,
83+
designParams: [],
84+
createdAt: new Date().toISOString(),
85+
};
86+
}
87+
7888
function collect(events: Iterable<ArtifactEvent>, into: Collected): void {
7989
for (const ev of events) {
8090
if (ev.type === 'text') {
8191
into.text += ev.delta;
8292
} else if (ev.type === 'artifact:end') {
83-
into.artifacts.push({
84-
id: ev.identifier || `design-${into.artifacts.length + 1}`,
85-
type: 'html',
86-
title: 'Design',
87-
content: ev.fullContent,
88-
designParams: [],
89-
createdAt: new Date().toISOString(),
90-
});
93+
const artifact = createHtmlArtifact(ev.fullContent, into.artifacts.length);
94+
if (ev.identifier) artifact.id = ev.identifier;
95+
into.artifacts.push(artifact);
9196
}
9297
}
9398
}
9499

100+
function extractHtmlDocument(source: string): string | null {
101+
const doctypeMatch = source.match(/<!doctype html[\s\S]*?<\/html>/i);
102+
if (doctypeMatch) return doctypeMatch[0].trim();
103+
104+
const htmlMatch = source.match(/<html[\s\S]*?<\/html>/i);
105+
if (htmlMatch) return htmlMatch[0].trim();
106+
107+
return null;
108+
}
109+
110+
function extractFallbackArtifact(text: string): { artifact: Artifact | null; message: string } {
111+
const fencedMatches = [...text.matchAll(/```(?:html)?\s*([\s\S]*?)```/gi)];
112+
for (const match of fencedMatches) {
113+
const block = match[1];
114+
const matchedText = match[0];
115+
if (!block || !matchedText) continue;
116+
117+
const html = extractHtmlDocument(block);
118+
if (!html) continue;
119+
120+
return {
121+
artifact: createHtmlArtifact(html, 0),
122+
message: text.replace(matchedText, '').trim(),
123+
};
124+
}
125+
126+
const html = extractHtmlDocument(text);
127+
if (!html) return { artifact: null, message: text.trim() };
128+
129+
return {
130+
artifact: createHtmlArtifact(html, 0),
131+
message: text.replace(html, '').trim(),
132+
};
133+
}
134+
95135
function formatDesignSystem(designSystem: StoredDesignSystem): string {
96-
const repoLabel = basename(designSystem.rootPath);
97136
const lines = [
98137
'## Design system to follow',
99-
`Repository: ${repoLabel}`,
138+
`Root path: ${designSystem.rootPath}`,
100139
`Summary: ${designSystem.summary}`,
101140
];
102141
if (designSystem.colors.length > 0) lines.push(`Colors: ${designSystem.colors.join(', ')}`);
@@ -114,7 +153,7 @@ function formatAttachments(attachments: AttachmentContext[]): string | null {
114153
if (attachments.length === 0) return null;
115154
const body = attachments
116155
.map((file, index) => {
117-
const lines = [`${index + 1}. ${file.name}`];
156+
const lines = [`${index + 1}. ${file.name} (${file.path})`];
118157
if (file.note) lines.push(`Note: ${file.note}`);
119158
if (file.excerpt) lines.push(`Excerpt:\n${file.excerpt}`);
120159
return lines.join('\n');
@@ -159,6 +198,7 @@ function buildRevisionPrompt(input: ApplyCommentInput, contextSections: string[]
159198
const parts = [
160199
'Revise the existing HTML artifact below.',
161200
'Keep the overall structure, copy, and layout intact unless the user request requires a broader change.',
201+
'Prioritize the selected element first and avoid unrelated edits.',
162202
`User request: ${input.comment.trim()}`,
163203
`Selected element tag: <${input.selection.tag}>`,
164204
`Selected element selector: ${input.selection.selector}`,
@@ -172,7 +212,7 @@ function buildRevisionPrompt(input: ApplyCommentInput, contextSections: string[]
172212
parts.push(contextSections.join('\n\n'));
173213
}
174214
parts.push(
175-
'Return the full updated HTML artifact. Do not explain the diff line by line; a short summary outside the artifact is enough.',
215+
'Return exactly one full updated HTML artifact wrapped in the required <artifact> tag. Do not use Markdown code fences. A short summary outside the artifact is enough.',
176216
);
177217
return parts.join('\n\n');
178218
}
@@ -197,6 +237,14 @@ async function runModel(input: ModelRunInput): Promise<GenerateOutput> {
197237
collect(parser.feed(result.content), collected);
198238
collect(parser.flush(), collected);
199239

240+
if (collected.artifacts.length === 0) {
241+
const fallback = extractFallbackArtifact(collected.text);
242+
if (fallback.artifact) {
243+
collected.artifacts.push(fallback.artifact);
244+
collected.text = fallback.message;
245+
}
246+
}
247+
200248
return {
201249
message: collected.text.trim(),
202250
artifacts: collected.artifacts,

0 commit comments

Comments
 (0)