fix(web): resolve pointer html artifact previews

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
bulai0408
2026-05-18 17:27:30 +08:00
parent 1f66c53203
commit 8c7ee5f38d
4 changed files with 217 additions and 14 deletions

View File

@@ -0,0 +1,80 @@
interface HtmlPointerArtifactTargetInput {
content: string;
candidateFileName: string;
projectFiles: Array<{ name: string; path?: string }>;
}
const MAX_POINTER_TEXT_BYTES = 100;
const POINTER_TARGET_RE =
/^(?:|see)(?:\s*[:]\s*|\s+)[`"']?(.+?\.html?)[`"']?[.]?$/iu;
const SCRIPT_OR_STYLE_RE = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
const HTML_TAG_RE = /<[^>]+>/g;
export function resolveHtmlPointerArtifactTarget(
input: HtmlPointerArtifactTargetInput,
): string | null {
const pointerText = visiblePointerText(input.content);
if (!pointerText || utf8ByteLength(pointerText) > MAX_POINTER_TEXT_BYTES) {
return null;
}
const match = POINTER_TARGET_RE.exec(pointerText);
if (!match) return null;
const target = normalizeTarget(match[1] ?? '');
if (!target || target === input.candidateFileName || !isSafeHtmlTarget(target)) {
return null;
}
const projectFileNames = input.projectFiles
.map((file) => file.path || file.name)
.filter((name) => name.toLowerCase().endsWith('.html') || name.toLowerCase().endsWith('.htm'));
if (projectFileNames.includes(target)) return target;
const basenameMatches = projectFileNames.filter((name) => basename(name) === target);
const [basenameMatch] = basenameMatches;
if (basenameMatches.length === 1 && basenameMatch && basenameMatch !== input.candidateFileName) {
return basenameMatch;
}
return null;
}
function visiblePointerText(content: string): string {
const stripped = content
.replace(/^\uFEFF/, '')
.replace(SCRIPT_OR_STYLE_RE, ' ')
.replace(HTML_TAG_RE, ' ');
return decodeBasicHtmlEntities(stripped).replace(/\s+/g, ' ').trim();
}
function normalizeTarget(value: string): string {
return value.trim().replace(/^[`"'“”‘’]+|[`"'“”‘’]+$/g, '');
}
function isSafeHtmlTarget(value: string): boolean {
if (!/\.(?:html?|HTML?)$/.test(value)) return false;
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return false;
if (value.startsWith('/') || value.includes('\\') || value.includes('\0')) return false;
return value.split('/').every((segment) => segment && segment !== '.' && segment !== '..');
}
function basename(value: string): string {
const i = value.lastIndexOf('/');
return i >= 0 ? value.slice(i + 1) : value;
}
function utf8ByteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
function decodeBasicHtmlEntities(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}

View File

@@ -9,6 +9,7 @@ import {
type PointerEvent as ReactPointerEvent,
} from 'react';
import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/manifest';
import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer';
import { validateHtmlArtifact } from '../artifacts/validate';
import { createArtifactParser } from '../artifacts/parser';
import { useT } from '../i18n';
@@ -372,6 +373,7 @@ export function ProjectView({
const [artifact, setArtifact] = useState<Artifact | null>(null);
const [filesRefresh, setFilesRefresh] = useState(0);
const [projectFiles, setProjectFiles] = useState<ProjectFile[]>([]);
const projectFilesRef = useRef<ProjectFile[]>([]);
const [liveArtifacts, setLiveArtifacts] = useState<LiveArtifactSummary[]>([]);
const [liveArtifactEvents, setLiveArtifactEvents] = useState<LiveArtifactEventItem[]>([]);
const [workspaceFocused, setWorkspaceFocused] = useState(false);
@@ -812,10 +814,15 @@ export function ProjectView({
const refreshProjectFiles = useCallback(async (): Promise<ProjectFile[]> => {
const next = await fetchProjectFiles(project.id);
projectFilesRef.current = next;
setProjectFiles(next);
return next;
}, [project.id]);
useEffect(() => {
projectFilesRef.current = projectFiles;
}, [projectFiles]);
const refreshLiveArtifacts = useCallback(async (): Promise<LiveArtifactSummary[]> => {
const next = await fetchLiveArtifacts(project.id);
setLiveArtifacts(next);
@@ -1820,7 +1827,7 @@ export function ProjectView({
// up as a real tab (not just the synthetic "live" stream).
setArtifact((prev) => {
if (!prev || !prev.html) return prev;
void persistArtifact(prev);
void refreshProjectFiles().then((nextFiles) => persistArtifact(prev, nextFiles));
return prev;
});
// Refetch the file list directly (rather than just bumping the
@@ -2044,13 +2051,37 @@ export function ProjectView({
);
const persistArtifact = useCallback(
async (art: Artifact) => {
async (art: Artifact, projectFilesSnapshot?: ProjectFile[]) => {
const baseName = (art.identifier || art.title || 'artifact')
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'artifact';
const ext = artifactExtensionFor(art);
// Pick a name that doesn't collide with an existing project file.
// The first run uses `<base>.<ext>`; subsequent runs append `-2`, `-3`…
// so prior artifacts aren't silently overwritten.
const currentProjectFiles = projectFilesSnapshot ?? projectFilesRef.current;
const existing = new Set(currentProjectFiles.map((f) => f.name));
let fileName = `${baseName}${ext}`;
let n = 2;
while (existing.has(fileName) && savedArtifactRef.current !== fileName) {
fileName = `${baseName}-${n}${ext}`;
n += 1;
}
if (ext === '.html') {
const pointerTarget = resolveHtmlPointerArtifactTarget({
content: art.html,
candidateFileName: fileName,
projectFiles: currentProjectFiles,
});
if (pointerTarget) {
if (savedArtifactRef.current === pointerTarget) return;
savedArtifactRef.current = pointerTarget;
requestOpenFile(pointerTarget);
return;
}
}
// Pre-write structural gate for HTML artifacts (#50, #1143). Reject
// bodies that obviously aren't a complete document — usually a one-line
// prose summary the model emitted inside `<artifact type="text/html">`
@@ -2063,16 +2094,6 @@ export function ProjectView({
return;
}
}
// Pick a name that doesn't collide with an existing project file.
// The first run uses `<base>.<ext>`; subsequent runs append `-2`, `-3`…
// so prior artifacts aren't silently overwritten.
const existing = new Set(projectFiles.map((f) => f.name));
let fileName = `${baseName}${ext}`;
let n = 2;
while (existing.has(fileName) && savedArtifactRef.current !== fileName) {
fileName = `${baseName}-${n}${ext}`;
n += 1;
}
if (savedArtifactRef.current === fileName) return;
savedArtifactRef.current = fileName;
const title = art.title || art.identifier || fileName;
@@ -2133,7 +2154,7 @@ export function ProjectView({
);
}
},
[project.id, projectFiles, requestOpenFile],
[project.id, project.designSystemId, project.skillId, requestOpenFile],
);
const handleContinueRemainingTasks = useCallback(

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { resolveHtmlPointerArtifactTarget } from '../../src/artifacts/pointer';
describe('resolveHtmlPointerArtifactTarget', () => {
it('resolves a short Chinese pointer artifact to an existing HTML file', () => {
const result = resolveHtmlPointerArtifactTarget({
content: '见 worker-edition-v2.html',
candidateFileName: 'worker-edition-v2-3.html',
projectFiles: [
{ name: 'worker-edition-v2.html' },
{ name: 'worker-edition-v2-3.html' },
],
});
expect(result).toBe('worker-edition-v2.html');
});
it('resolves a minimal HTML wrapper whose visible body only points elsewhere', () => {
const result = resolveHtmlPointerArtifactTarget({
content:
'<!doctype html><html><body>见 <a href="worker-edition-v2.html">worker-edition-v2.html</a></body></html>',
candidateFileName: 'worker-edition-v2-3.html',
projectFiles: [{ name: 'worker-edition-v2.html' }],
});
expect(result).toBe('worker-edition-v2.html');
});
it('returns null when the pointer target is not an existing project HTML file', () => {
const result = resolveHtmlPointerArtifactTarget({
content: '见 worker-edition-v2.html',
candidateFileName: 'worker-edition-v2-3.html',
projectFiles: [{ name: 'summary.html' }],
});
expect(result).toBeNull();
});
it('returns null when a basename pointer would match multiple nested files', () => {
const result = resolveHtmlPointerArtifactTarget({
content: 'see index.html',
candidateFileName: 'index-2.html',
projectFiles: [
{ name: 'desktop/index.html' },
{ name: 'mobile/index.html' },
],
});
expect(result).toBeNull();
});
it('does not treat real prose that mentions an HTML file as a pointer artifact', () => {
const result = resolveHtmlPointerArtifactTarget({
content: 'I updated worker-edition-v2.html with the final responsive layout and accessibility fixes.',
candidateFileName: 'worker-edition-v2-3.html',
projectFiles: [{ name: 'worker-edition-v2.html' }],
});
expect(result).toBeNull();
});
});

View File

@@ -118,7 +118,9 @@ vi.mock('../../src/components/AvatarMenu', () => ({
}));
vi.mock('../../src/components/FileWorkspace', () => ({
FileWorkspace: () => <div data-testid="file-workspace" />,
FileWorkspace: ({ openRequest }: { openRequest?: { name: string; nonce: number } | null }) => (
<div data-testid="file-workspace" data-open-request-name={openRequest?.name ?? ''} />
),
}));
vi.mock('../../src/components/Loading', () => ({
@@ -463,6 +465,44 @@ describe('ProjectView API empty response handling', () => {
expect(screen.queryByText('empty_response:deepseek-chat')).toBeNull();
});
it('opens the real HTML page instead of saving a pointer artifact as the preview entry', async () => {
const realPage = {
name: 'worker-edition-v2.html',
path: 'worker-edition-v2.html',
kind: 'html',
mime: 'text/html',
size: 60_000,
mtime: 1,
};
mockedFetchProjectFiles.mockResolvedValue([realPage] as never);
const artifact =
'<artifact identifier="worker-edition-v2" type="text/html" title="合同审查报告">' +
'见 worker-edition-v2.html' +
'</artifact>';
mockedStreamMessage.mockImplementation(async (
_cfg: AppConfig,
_system: string,
_history: ChatMessage[],
_signal: AbortSignal,
handlers: StreamHandlers,
) => {
handlers.onDelta(artifact);
handlers.onDone('');
});
renderProjectView();
await sendTestPrompt();
await waitFor(() => {
expect(hasSavedAssistantMessage((message) => message.runStatus === 'succeeded')).toBe(true);
});
await waitFor(() => {
expect(screen.getByTestId('file-workspace').dataset.openRequestName).toBe('worker-edition-v2.html');
});
expect(mockedWriteProjectTextFile).not.toHaveBeenCalled();
expect(screen.queryByText(/Refused to save artifact/i)).toBeNull();
});
it('injects ElevenLabs voice options into API-mode audio project prompts', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);