fix(web): avoid reusing unrelated html artifact groups

This commit is contained in:
116405 2026-05-31 00:15:25 +08:00
parent 361c18a414
commit 4ecdb4a15c
2 changed files with 89 additions and 20 deletions

View file

@ -1144,22 +1144,26 @@ export function ProjectView({
// keeping index.html stable for multi-page HTML artifacts.
const currentProjectFiles = projectFilesSnapshot ?? projectFilesRef.current;
const existing = new Set(currentProjectFiles.map((f) => f.name));
const fileName = resolveHtmlArtifactFileName({
baseName,
ext,
existingFileNames: existing,
savedArtifactName: savedArtifactRef.current,
canOverwriteExistingEntry: canOverwriteHtmlArtifactEntry({
const canOverwriteExistingEntry = canOverwriteHtmlArtifactEntry({
baseName,
ext,
projectFiles: currentProjectFiles,
savedArtifactName: savedArtifactRef.current,
artifactIdentifier: art.identifier,
}),
});
const fileName = resolveHtmlArtifactFileName({
baseName,
ext,
existingFileNames: existing,
savedArtifactName: savedArtifactRef.current,
canOverwriteExistingEntry,
});
const artifactGroupIdentifier =
ext === '.html'
? htmlArtifactGroupIdentifierFor(art, currentProjectFiles, fileName)
? htmlArtifactGroupIdentifierFor(art, currentProjectFiles, fileName, {
canReuseExistingGroup:
canOverwriteExistingEntry || savedArtifactRef.current === fileName,
})
: undefined;
const html =
ext === '.html'
@ -4640,7 +4644,17 @@ function htmlArtifactGroupIdentifierFor(
art: Artifact,
projectFiles: ProjectFile[],
fileName: string,
options: { canReuseExistingGroup: boolean },
): string {
const existingFile = projectFiles.find((file) => file.name === fileName || file.path === fileName);
const existingFileGroup = existingArtifactGroupIdentifier(
existingFile?.artifactManifest?.metadata?.artifactGroupIdentifier,
);
if (options.canReuseExistingGroup && existingFileGroup) {
return existingFileGroup;
}
if (options.canReuseExistingGroup) {
const identifier = normalizeArtifactGroupSegment(art.identifier);
if (identifier) {
const existingGroup = projectFiles
@ -4654,9 +4668,16 @@ function htmlArtifactGroupIdentifierFor(
});
if (existingGroup) return existingGroup;
}
}
return `html-artifact:${fileName}`;
}
function existingArtifactGroupIdentifier(value: unknown): string | null {
if (typeof value !== 'string') return null;
return normalizeArtifactGroupSegment(value).length > 0 ? value : null;
}
function normalizeArtifactGroupSegment(value: unknown): string {
if (typeof value !== 'string') return '';
return value

View file

@ -643,6 +643,54 @@ describe('ProjectView API empty response handling', () => {
expect(content).toContain('href="about-2.html"');
});
it('starts a fresh group when a new index artifact is relocated away from an existing owner', async () => {
mockedFetchProjectFiles.mockResolvedValue([
htmlProjectFile('index.html', 10, {
artifactIdentifier: 'Index',
artifactGroupIdentifier: 'site-a',
}),
htmlProjectFile('about.html', 20, {
artifactIdentifier: 'about',
artifactGroupIdentifier: 'site-a',
}),
htmlProjectFile('about-2.html', 40, {
artifactIdentifier: 'about',
artifactGroupIdentifier: 'site-a',
}),
] as never);
mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index-2.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-2.html');
expect(content).toContain('href="about.html"');
expect(content).not.toContain('href="about-2.html"');
expect(options?.artifactManifest?.metadata?.artifactGroupIdentifier).toBe(
'html-artifact:index-2.html',
);
});
it('rewrites child links for legacy multipage artifacts without group metadata', async () => {
mockedFetchProjectFiles.mockResolvedValue([
htmlProjectFile('index.html', 10, { artifactIdentifier: 'index' }),