fix(desktop): keep CJK file names whole and stop flagging guessed images

Output cards scanned inline code spans as prose, which excludes CJK on
purpose, so `开题报告2.docx` became a `2.docx` card that opened a missing
file. Code spans are now parsed whole with the same Unicode-aware parser
the rendered chip uses.

An image the reply only named by a bare filename, with no changed file
corroborating its location, was resolved at the workdir root and showed a
red load error when it was not there. Such guesses now disappear on
failure; paths spelled out with a directory keep the error and retry.
This commit is contained in:
程序员阿江(Relakkes)
2026-10-03 21:30:05 +08:00
parent fa28fb8b21
commit a625912d29
5 changed files with 249 additions and 22 deletions
@@ -181,6 +181,60 @@ describe('InlineImageGallery', () => {
expect(srcs[0]).toBe('http://127.0.0.1:4321/preview-fs/s1/outputs/a/frame.png')
})
describe('an image the prose only names, without a path', () => {
// A read-only turn ("which commit swapped nodemaven_banner_sep.png?") names a
// file it never wrote. Nothing proves it sits at the workdir root, so a failed
// load is a wrong guess, not a broken deliverable worth a red error block.
it('does not raise the error block when a guessed bare name fails to load', async () => {
render(
<InlineImageGallery
text={'素材也换成 `nodemaven_banner_sep.png`,说明是按月续的'}
sessionId="s1"
workDir="/w"
changedFiles={[]}
/>,
)
fireEvent.error(screen.getByRole('img'))
await waitFor(() => expect(screen.queryByRole('img')).not.toBeInTheDocument())
expect(fetchServerImageBlobUrl).toHaveBeenCalled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByText('1 image')).not.toBeInTheDocument()
})
it('still raises the error block for a path the prose spelled out, though the checkpoint missed it', async () => {
// A shell-rendered image never reaches changedFiles; its failure is real.
render(
<InlineImageGallery
text={'渲染结果已保存到 outputs/a/frame.png'}
sessionId="s1"
workDir="/w"
changedFiles={[]}
/>,
)
fireEvent.error(screen.getByRole('img'))
expect(await screen.findByRole('alert')).toHaveTextContent('frame.png')
})
it('still raises the error block for a name the turn really wrote', async () => {
render(
<InlineImageGallery
text={'已生成 `banner.png`'}
sessionId="s1"
workDir="/w"
changedFiles={['/w/out/banner.png']}
/>,
)
fireEvent.error(screen.getByRole('img'))
expect(await screen.findByRole('alert')).toHaveTextContent('banner.png')
})
})
it('uses the absolute-file route for a changed image outside the workspace', () => {
render(
<InlineImageGallery
@@ -57,6 +57,16 @@ type GalleryImage = {
name: string
/** Where the file is, for "open in system app". Relative until the workdir is known. */
path: string
/**
* The prose gave only a bare name and nothing the turn wrote corroborates where
* the file is, so the URL is a guess. A failed load is then a wrong guess, not
* a broken deliverable.
*/
inferred?: boolean
}
function samePath(left: string, right: string): boolean {
return left.replaceAll('\\', '/').toLowerCase() === right.replaceAll('\\', '/').toLowerCase()
}
type Props = {
@@ -132,6 +142,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
// Dedup: an absolute path inside the workspace can be caught by BOTH sources.
// Skip a relative target whose basename already appears among the absolute
// images, and also collapse duplicate relative targets by resolved src.
const proseText = text.replaceAll('\\', '/')
const absoluteNames = new Set(absolute.map((img) => img.name))
const seenSrc = new Set(absolute.map((img) => img.src))
const relative: GalleryImage[] = []
@@ -149,23 +160,32 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
continue
}
seenSrc.add(src)
relative.push({ src, name, path: resolveAbsoluteOpenPath(relPath, workDir ?? undefined) })
const openPath = resolveAbsoluteOpenPath(relPath, workDir ?? undefined)
const corroborated = changedFileEvidence?.some((file) => samePath(file, openPath)) ?? false
// A path the prose spells out with its directory is a claim, even when the
// checkpoint missed it (shell writes are invisible there); only a bare name,
// placed at the root or in an inferred directory, is a guess.
const namedWithDirectory = /[\\/]/.test(relPath) && proseText.includes(relPath.replaceAll('\\', '/'))
relative.push({ src, name, path: openPath, inferred: !corroborated && !namedWithDirectory })
}
return [...absolute, ...relative]
}, [changedFileEvidence, imagePaths, markdownImageSources, sessionId, text, workDir])
if (images.length === 0) return null
// A guessed image that failed to load leaves no trace: there is nothing to retry
// when the file was never claimed to be there.
const visibleImages = images.filter((img) => !(img.inferred && failedSources.has(img.src)))
if (visibleImages.length === 0) return null
return (
<>
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-outline)]">
<span className="material-symbols-outlined text-[12px]">image</span>
{images.length === 1 ? '1 image' : `${images.length} images`}
{visibleImages.length === 1 ? '1 image' : `${visibleImages.length} images`}
</div>
<div className={`grid gap-2 ${images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
{images.map((img, i) => failedSources.has(img.src) ? (
<div className={`grid gap-2 ${visibleImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
{visibleImages.map((img, i) => failedSources.has(img.src) ? (
<ErrorState
key={img.src}
size="sm"
@@ -195,7 +215,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
alt={img.name}
loading="lazy"
className="w-full object-cover"
style={{ maxHeight: images.length === 1 ? 400 : 240 }}
style={{ maxHeight: visibleImages.length === 1 ? 400 : 240 }}
// img errors expose no HTTP status: a denied, missing or invalid
// image needs visible feedback without claiming a specific cause.
onFailure={() => setFailureState((previous) => ({ ...previous, sources: new Set(previous.sources).add(img.src) }))}
@@ -218,7 +238,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
{activeIndex !== null && activeIndex >= 0 && (
<ImageGalleryModal
open={activeIndex !== null}
images={images}
images={visibleImages}
activeIndex={activeIndex}
onClose={() => setActiveIndex(null)}
onSelect={setActiveIndex}
@@ -429,6 +429,45 @@ describe('extractAssistantOutputTargets with changedFiles reconciliation', () =>
.toEqual(['plan.md', 'out/report.docx'])
})
describe('a file named in an inline code span', () => {
// The span, not the sentence, bounds the name: a CJK basename inside backticks
// is one file. Scanning it as prose (CJK excluded on purpose) cut
// `开题报告2.docx` down to `2.docx` — a card that opens a file that is not there.
it('keeps the whole CJK basename', () => {
const targets = extractAssistantOutputTargets(
'- `开题报告2.docx` — 9/10\n- `开题报告3.docx` — 9/14\n- `开题报告_v2.docx` — 9/17',
{ workDir: '/w', changedFiles: [] },
)
expect(targets.map((target) => target.normalizedPath))
.toEqual(['开题报告2.docx', '开题报告3.docx', '开题报告_v2.docx'])
})
it('keeps a CJK directory', () => {
const targets = extractAssistantOutputTargets(
'见 `论文/开题报告终稿.docx`',
{ workDir: '/w', changedFiles: [] },
)
expect(targets.map((target) => target.normalizedPath)).toEqual(['论文/开题报告终稿.docx'])
})
it('does not turn a command into a file card', () => {
const targets = extractAssistantOutputTargets('运行 `open 开题报告.docx` 即可', {
workDir: '/w',
changedFiles: [],
})
expect(targets.map((target) => target.normalizedPath)).not.toContain('开题报告.docx')
})
it('still stops prose at the Chinese verb flush against an ASCII path', () => {
const targets = extractAssistantOutputTargets('生成了out/report.docx', { workDir: '/w', changedFiles: [] })
expect(targets.map((target) => target.normalizedPath)).toEqual(['out/report.docx'])
})
})
it('places a bare deliverable name in the directory the turn actually wrote into', () => {
// The real shape of a "generate three documents" turn: the prose gives the
// directory once and then lists basenames. Resolved against the work dir those
+61 -15
View File
@@ -1,6 +1,6 @@
import { resolveAssistantFileHref } from './assistantFileContext'
import { trimTrailingPunctuation } from './urlBoundary'
import { isLinkableFilePath, splitTextByFilePaths } from './filePathBoundary'
import { isLinkableFilePath, parseFilePathRef, splitTextByFilePaths } from './filePathBoundary'
import { isGeneratedArtifactFile, isOutputResourceFile, isShellProducedDeliverable } from './fileCapabilities'
export type AssistantOutputTargetKind =
@@ -206,6 +206,39 @@ export function extractAssistantOutputTargets(
}, createFileKey(fileTarget), treeMatch.position)
}
const queuePlainPath = (path: string, position: number) => {
const href = resolveAssistantFileHref(path, content)
const fileTarget = toWorkspaceFileTarget(href, workDir)
if (!fileTarget) {
return
}
queueTarget({
id: createId(fileTarget.kind, fileTarget.normalizedPath),
kind: fileTarget.kind,
title: getBasename(fileTarget.normalizedPath),
subtitle: fileTarget.normalizedPath,
href,
normalizedPath: fileTarget.normalizedPath,
confidence: 'high',
source: 'plain-path',
}, createFileKey(fileTarget), position)
}
// A code span is bounded by its backticks, not by the sentence, so a name in
// one is read whole — CJK included, as the rendered chip does. The prose scan
// below leaves CJK out on purpose (`修改了lib/foo.ts` must not swallow the
// verb), and run over `开题报告2.docx` it kept only `2.docx`.
const codeSpans = extractInlineCodeSpans(content, codeBlocks)
for (const span of codeSpans) {
if (isInMarkdownLink(span.start, markdownLinks)) {
continue
}
queuePlainPath(span.ref.path, span.start)
}
let plainTextPosition = 0
for (const segment of splitTextByFilePaths(content)) {
const position = plainTextPosition
@@ -223,23 +256,11 @@ export function extractAssistantOutputTargets(
continue
}
const href = resolveAssistantFileHref(segment.ref.path, content)
const fileTarget = toWorkspaceFileTarget(href, workDir)
if (!fileTarget) {
if (codeSpans.some((span) => position >= span.start && position < span.end)) {
continue
}
queueTarget({
id: createId(fileTarget.kind, fileTarget.normalizedPath),
kind: fileTarget.kind,
title: getBasename(fileTarget.normalizedPath),
subtitle: fileTarget.normalizedPath,
href,
normalizedPath: fileTarget.normalizedPath,
confidence: 'high',
source: 'plain-path',
}, createFileKey(fileTarget), position)
queuePlainPath(segment.ref.path, position)
}
candidates.sort((left, right) => {
@@ -629,6 +650,31 @@ function normalizeMarkdownDestination(destination: string): string {
return trimTrailingPunctuation(normalized)
}
type InlineCodeSpan = {
start: number
end: number
ref: NonNullable<ReturnType<typeof parseFilePathRef>>
}
/** Single-line `code` spans outside fenced blocks whose whole content is one file reference. */
function extractInlineCodeSpans(content: string, codeBlocks: FencedCodeBlock[]): InlineCodeSpan[] {
const spans: InlineCodeSpan[] = []
for (const match of content.matchAll(/(?<!`)`([^`\n]+)`(?!`)/g)) {
const start = match.index ?? 0
if (isInCodeBlock(start, codeBlocks)) {
continue
}
const ref = parseFilePathRef(match[1] ?? '')
if (ref) {
spans.push({ start, end: start + match[0].length, ref })
}
}
return spans
}
function isInMarkdownLink(position: number, markdownLinks: MarkdownLinkMatch[]): boolean {
return markdownLinks.some((link) => position >= link.start && position < link.end)
}
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { WorkspaceService } from '../services/workspaceService.js'
import { handlePreviewFs } from '../api/previewFs.js'
// Boundary shapes behind "the card opens, but the pane says the file is missing":
// CJK directories, Unicode normalization, and a deliverable named without its folder.
const cleanupDirs: string[] = []
async function makeWorkDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-edge-'))
cleanupDirs.push(dir)
return dir
}
afterEach(async () => {
await Promise.all(cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })))
})
describe('workspace file lookup edge cases', () => {
it('reads a docx inside a CJK directory next to a hidden folder', async () => {
const workDir = await makeWorkDir()
const folder = path.join(workDir, '开题报告')
await fs.mkdir(path.join(folder, '.workbuddy'), { recursive: true })
await fs.writeFile(path.join(folder, '2.docx'), 'PK')
const service = new WorkspaceService(async () => workDir)
await expect(service.readFile('s1', '开题报告/2.docx')).resolves.toMatchObject({ state: 'ok', previewType: 'docx' })
if (process.platform === 'win32') await expect(service.readFile('s1', '开题报告\\2.docx')).resolves.toMatchObject({ state: 'ok', previewType: 'docx' })
const raw = await service.resolveRawFile('s1', '开题报告/2.docx')
expect(raw.canonicalPath.endsWith('2.docx')).toBe(true)
})
it('finds a CJK-named file when the request uses the other Unicode normalization form', async () => {
const workDir = await makeWorkDir()
const name = '毕业设计(论文)开题报告 é.docx'
await fs.writeFile(path.join(workDir, name.normalize('NFD')), 'PK')
const service = new WorkspaceService(async () => workDir)
// Models emit NFC; some file systems and tools persist NFD (and vice versa).
const outcome = await service.readFile('s1', name.normalize('NFC'))
expect(outcome.state).not.toBe('missing')
})
it('serves a CJK path through the URL-encoded preview route', async () => {
const workDir = await makeWorkDir()
await fs.mkdir(path.join(workDir, '图片'), { recursive: true })
await fs.writeFile(path.join(workDir, '图片', '横幅 1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]))
const url = new URL(`http://127.0.0.1/preview-fs/s1/${encodeURIComponent('图片')}/${encodeURIComponent('横幅 1.png')}`)
const res = await handlePreviewFs(url, async () => workDir)
expect(res.status).toBe(200)
})
it('reports a bare name as missing when the file lives in a subdirectory (baseline)', async () => {
const workDir = await makeWorkDir()
await fs.mkdir(path.join(workDir, 'docs', 'static'), { recursive: true })
await fs.writeFile(path.join(workDir, 'docs', 'static', 'banner_sep.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]))
const service = new WorkspaceService(async () => workDir)
await expect(service.readFile('s1', 'banner_sep.png')).resolves.toMatchObject({ state: 'missing' })
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/banner_sep.png'), async () => workDir)
expect(res.status).toBe(404)
})
})