fix(web): support legacy html artifact rewrites

This commit is contained in:
116405 2026-05-30 23:22:39 +08:00
parent c48aee0aa6
commit 361c18a414
3 changed files with 113 additions and 18 deletions

View file

@ -84,38 +84,61 @@ function buildLatestHtmlFileIndex(
projectFiles: readonly HtmlLinkProjectFile[],
options: HtmlLinkRewriteOptions,
): Map<string, string> {
const latest = new Map<string, HtmlLinkProjectFile>();
const groupedLatest = new Map<string, HtmlLinkProjectFile>();
const legacyLatest = new Map<string, HtmlLinkProjectFile>();
const groupedFamilies = new Set<string>();
const artifactGroupIdentifier = normalizeOptionalIdentifier(options.artifactGroupIdentifier);
for (const file of projectFiles) {
if (!isHtmlProjectFile(file)) continue;
const key = htmlFileFamilyKey(file.name);
if (!key) continue;
if (!htmlFileManifestMatchesFamily(file, key, artifactGroupIdentifier)) continue;
const current = latest.get(key);
if (!current || file.mtime > current.mtime) latest.set(key, file);
const manifestMatch = htmlFileManifestFamilyMatch(file, key);
if (!manifestMatch) continue;
if (manifestMatch.artifactGroupIdentifier !== null) {
groupedFamilies.add(key);
}
if (
artifactGroupIdentifier !== null &&
manifestMatch.artifactGroupIdentifier === artifactGroupIdentifier
) {
const current = groupedLatest.get(key);
if (!current || file.mtime > current.mtime) groupedLatest.set(key, file);
continue;
}
if (manifestMatch.artifactGroupIdentifier === null) {
const current = legacyLatest.get(key);
if (!current || file.mtime > current.mtime) legacyLatest.set(key, file);
}
}
const latest = new Map<string, HtmlLinkProjectFile>();
for (const [key, file] of legacyLatest) {
if (!groupedFamilies.has(key)) latest.set(key, file);
}
for (const [key, file] of groupedLatest) {
latest.set(key, file);
}
return new Map(Array.from(latest, ([key, file]) => [key, file.name]));
}
function htmlFileManifestMatchesFamily(
function htmlFileManifestFamilyMatch(
file: HtmlLinkProjectFile,
familyKey: string,
artifactGroupIdentifier: string | null,
): boolean {
): { artifactGroupIdentifier: string | null } | null {
const identifier = file.artifactManifest?.metadata?.identifier;
if (typeof identifier !== 'string') return false;
if (typeof identifier !== 'string') return null;
const normalizedIdentifier = normalizeArtifactIdentifier(identifier);
if (!normalizedIdentifier) return false;
if (normalizedIdentifier !== htmlFileFamilyIdentifier(familyKey)) return false;
if (!normalizedIdentifier) return null;
if (normalizedIdentifier !== htmlFileFamilyIdentifier(familyKey)) return null;
const fileGroupIdentifier = normalizeOptionalIdentifier(
file.artifactManifest?.metadata?.artifactGroupIdentifier,
);
return (
artifactGroupIdentifier !== null &&
fileGroupIdentifier !== null &&
artifactGroupIdentifier === fileGroupIdentifier
);
return {
artifactGroupIdentifier: normalizeOptionalIdentifier(
file.artifactManifest?.metadata?.artifactGroupIdentifier,
),
};
}
function isHtmlProjectFile(file: HtmlLinkProjectFile): boolean {

View file

@ -146,6 +146,40 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => {
expect(out).toContain('href="about.html"');
expect(out).not.toContain('href="about-2.html"');
});
it('falls back to legacy same-identifier files when no group-tagged candidates exist', () => {
const html =
'<!doctype html><html><body>' +
'<a href="about.html">About</a>' +
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('about.html', 10, { artifactIdentifier: 'about' }),
htmlFile('about-2.html', 40, { artifactIdentifier: 'about' }),
], { artifactGroupIdentifier: 'site-a' });
expect(out).toContain('href="about-2.html"');
});
it('does not fall back to legacy files when a group-tagged candidate family exists', () => {
const html =
'<!doctype html><html><body>' +
'<a href="about.html">About</a>' +
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('about.html', 10, { artifactIdentifier: 'about' }),
htmlFile('about-2.html', 20, { artifactIdentifier: 'about' }),
htmlFile('about-3.html', 40, {
artifactIdentifier: 'about',
artifactGroupIdentifier: 'site-b',
}),
], { artifactGroupIdentifier: 'site-a' });
expect(out).toContain('href="about.html"');
expect(out).not.toContain('href="about-2.html"');
expect(out).not.toContain('href="about-3.html"');
});
});
function htmlFile(

View file

@ -643,6 +643,44 @@ describe('ProjectView API empty response handling', () => {
expect(content).toContain('href="about-2.html"');
});
it('rewrites child links for legacy multipage artifacts without group metadata', async () => {
mockedFetchProjectFiles.mockResolvedValue([
htmlProjectFile('index.html', 10, { artifactIdentifier: 'index' }),
htmlProjectFile('about.html', 20, { artifactIdentifier: 'about' }),
htmlProjectFile('about-2.html', 40, { artifactIdentifier: 'about' }),
] as never);
mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index.html', 50) as never);
const artifact =
'<artifact identifier="index" type="text/html" title="Multipage Site">' +
'<!doctype html><html><head><title>Home</title></head><body>' +
'<main><h1>Home</h1><a href="about.html">About</a></main>' +
'</body></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(mockedWriteProjectTextFile).toHaveBeenCalled();
});
const [, fileName, content, options] = mockedWriteProjectTextFile.mock.calls.at(-1) ?? [];
expect(fileName).toBe('index.html');
expect(content).toContain('href="about-2.html"');
expect(options?.artifactManifest?.metadata?.artifactGroupIdentifier).toBe(
'html-artifact:index.html',
);
});
it('injects ElevenLabs voice options into API-mode audio project prompts', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);