mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-07 14:38:42 +08:00
fix(web): resolve pointer html artifact previews
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
80
apps/web/src/artifacts/pointer.ts
Normal file
80
apps/web/src/artifacts/pointer.ts
Normal 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(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
62
apps/web/tests/artifacts/pointer.test.ts
Normal file
62
apps/web/tests/artifacts/pointer.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user