diff --git a/packages/core/src/generate.test.ts b/packages/core/src/generate.test.ts
index e6b8ac5f..0d4781d5 100644
--- a/packages/core/src/generate.test.ts
+++ b/packages/core/src/generate.test.ts
@@ -1,5 +1,5 @@
import type { ChatMessage, ModelRef, StoredDesignSystem } from '@open-codesign/shared';
-import { CodesignError } from '@open-codesign/shared';
+import { CodesignError, STORED_DESIGN_SYSTEM_SCHEMA_VERSION } from '@open-codesign/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
const completeMock = vi.fn();
@@ -27,8 +27,14 @@ const RESPONSE = `Here is your design.
${SAMPLE_HTML}
`;
+const FENCED_RESPONSE = `Here is the revised HTML artifact.
+
+\`\`\`html
+${SAMPLE_HTML}
+\`\`\``;
+
const DESIGN_SYSTEM: StoredDesignSystem = {
- schemaVersion: 1,
+ schemaVersion: STORED_DESIGN_SYSTEM_SCHEMA_VERSION,
rootPath: '/repo',
summary: 'Muted neutrals with warm copper accents.',
extractedAt: '2026-04-18T00:00:00.000Z',
@@ -135,12 +141,30 @@ describe('generate()', () => {
if (!user) throw new Error('expected user message');
expect(user.content).toContain('design a warm landing page');
expect(user.content).toContain('Design system to follow');
- expect(user.content).toContain('Repository: repo');
expect(user.content).toContain('Muted neutrals with warm copper accents.');
expect(user.content).toContain('brief.md');
expect(user.content).toContain('https://example.com');
- expect(user.content).not.toContain('/repo');
- expect(user.content).not.toContain('/tmp/brief.md');
+ });
+
+ it('falls back to fenced HTML when the model skips artifact tags', async () => {
+ completeMock.mockResolvedValueOnce({
+ content: FENCED_RESPONSE,
+ inputTokens: 3,
+ outputTokens: 4,
+ costUsd: 0,
+ });
+
+ const result = await generate({
+ prompt: 'design a dashboard',
+ history: [],
+ model: MODEL,
+ apiKey: 'sk-test',
+ });
+
+ expect(result.artifacts).toHaveLength(1);
+ expect(result.artifacts[0]?.content).toBe(SAMPLE_HTML);
+ expect(result.message).toContain('Here is the revised HTML artifact.');
+ expect(result.message).not.toContain('```html');
});
});
@@ -193,5 +217,33 @@ describe('applyComment()', () => {
expect(user.content).toContain('#hero');
expect(user.content).toContain(SAMPLE_HTML);
expect(user.content).toContain('Muted neutrals with warm copper accents.');
+ expect(user.content).toContain('Prioritize the selected element first');
+ expect(user.content).toContain('Do not use Markdown code fences');
+ });
+
+ it('returns a parsed artifact for fenced revision responses', async () => {
+ completeMock.mockResolvedValueOnce({
+ content: FENCED_RESPONSE,
+ inputTokens: 0,
+ outputTokens: 0,
+ costUsd: 0,
+ });
+
+ const result = await applyComment({
+ html: SAMPLE_HTML,
+ comment: 'Make the title more playful.',
+ selection: {
+ selector: 'h1',
+ tag: 'h1',
+ outerHTML: '
Hi
',
+ rect: { top: 0, left: 0, width: 80, height: 24 },
+ },
+ model: MODEL,
+ apiKey: 'sk-test',
+ });
+
+ expect(result.artifacts).toHaveLength(1);
+ expect(result.artifacts[0]?.content).toBe(SAMPLE_HTML);
+ expect(result.message).toContain('Here is the revised HTML artifact.');
});
});
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index df1e1bf9..0f150192 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,4 +1,3 @@
-import { basename } from 'node:path';
import { type ArtifactEvent, createArtifactParser } from '@open-codesign/artifacts';
import { type RetryReason, complete, completeWithRetry } from '@open-codesign/providers';
import type {
@@ -75,28 +74,68 @@ interface ModelRunInput {
messages: ChatMessage[];
}
+function createHtmlArtifact(content: string, index: number): Artifact {
+ return {
+ id: `design-${index + 1}`,
+ type: 'html',
+ title: 'Design',
+ content,
+ designParams: [],
+ createdAt: new Date().toISOString(),
+ };
+}
+
function collect(events: Iterable, into: Collected): void {
for (const ev of events) {
if (ev.type === 'text') {
into.text += ev.delta;
} else if (ev.type === 'artifact:end') {
- into.artifacts.push({
- id: ev.identifier || `design-${into.artifacts.length + 1}`,
- type: 'html',
- title: 'Design',
- content: ev.fullContent,
- designParams: [],
- createdAt: new Date().toISOString(),
- });
+ const artifact = createHtmlArtifact(ev.fullContent, into.artifacts.length);
+ if (ev.identifier) artifact.id = ev.identifier;
+ into.artifacts.push(artifact);
}
}
}
+function extractHtmlDocument(source: string): string | null {
+ const doctypeMatch = source.match(//i);
+ if (doctypeMatch) return doctypeMatch[0].trim();
+
+ const htmlMatch = source.match(//i);
+ if (htmlMatch) return htmlMatch[0].trim();
+
+ return null;
+}
+
+function extractFallbackArtifact(text: string): { artifact: Artifact | null; message: string } {
+ const fencedMatches = [...text.matchAll(/```(?:html)?\s*([\s\S]*?)```/gi)];
+ for (const match of fencedMatches) {
+ const block = match[1];
+ const matchedText = match[0];
+ if (!block || !matchedText) continue;
+
+ const html = extractHtmlDocument(block);
+ if (!html) continue;
+
+ return {
+ artifact: createHtmlArtifact(html, 0),
+ message: text.replace(matchedText, '').trim(),
+ };
+ }
+
+ const html = extractHtmlDocument(text);
+ if (!html) return { artifact: null, message: text.trim() };
+
+ return {
+ artifact: createHtmlArtifact(html, 0),
+ message: text.replace(html, '').trim(),
+ };
+}
+
function formatDesignSystem(designSystem: StoredDesignSystem): string {
- const repoLabel = basename(designSystem.rootPath);
const lines = [
'## Design system to follow',
- `Repository: ${repoLabel}`,
+ `Root path: ${designSystem.rootPath}`,
`Summary: ${designSystem.summary}`,
];
if (designSystem.colors.length > 0) lines.push(`Colors: ${designSystem.colors.join(', ')}`);
@@ -114,7 +153,7 @@ function formatAttachments(attachments: AttachmentContext[]): string | null {
if (attachments.length === 0) return null;
const body = attachments
.map((file, index) => {
- const lines = [`${index + 1}. ${file.name}`];
+ const lines = [`${index + 1}. ${file.name} (${file.path})`];
if (file.note) lines.push(`Note: ${file.note}`);
if (file.excerpt) lines.push(`Excerpt:\n${file.excerpt}`);
return lines.join('\n');
@@ -159,6 +198,7 @@ function buildRevisionPrompt(input: ApplyCommentInput, contextSections: string[]
const parts = [
'Revise the existing HTML artifact below.',
'Keep the overall structure, copy, and layout intact unless the user request requires a broader change.',
+ 'Prioritize the selected element first and avoid unrelated edits.',
`User request: ${input.comment.trim()}`,
`Selected element tag: <${input.selection.tag}>`,
`Selected element selector: ${input.selection.selector}`,
@@ -172,7 +212,7 @@ function buildRevisionPrompt(input: ApplyCommentInput, contextSections: string[]
parts.push(contextSections.join('\n\n'));
}
parts.push(
- 'Return the full updated HTML artifact. Do not explain the diff line by line; a short summary outside the artifact is enough.',
+ 'Return exactly one full updated HTML artifact wrapped in the required tag. Do not use Markdown code fences. A short summary outside the artifact is enough.',
);
return parts.join('\n\n');
}
@@ -197,6 +237,14 @@ async function runModel(input: ModelRunInput): Promise {
collect(parser.feed(result.content), collected);
collect(parser.flush(), collected);
+ if (collected.artifacts.length === 0) {
+ const fallback = extractFallbackArtifact(collected.text);
+ if (fallback.artifact) {
+ collected.artifacts.push(fallback.artifact);
+ collected.text = fallback.message;
+ }
+ }
+
return {
message: collected.text.trim(),
artifacts: collected.artifacts,