diff --git a/CHANGELOG.md b/CHANGELOG.md index 74833bc5..94ab7686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ (如 `C:\docs\(draft)\a.md`)或 `\\server\share` 共享路径被误当作 shell 转义、 文件没有作为附件发送的问题;转义解析找不到文件时会改用原样粘贴的路径, 并支持在文字中识别共享路径、保留其原始分隔符。 +- 修复 TUI 中提到不可达主机或不存在共享的 Windows 路径(如 `\\server\share\a.pdf`) + 时整条消息报 `UNKNOWN: unknown error` 发不出去的问题;取不到的共享路径与本地 + 缺失路径一样按普通文字发送;仅放行 Windows UNC 的 stat 查找失败,实际读取和 + 权限错误仍正常提示。 - WebUI 与共享移动端现在会在后台任务卡片中展示类型化 Artifact;支持远程媒体 的显式加载、内联图片预览、结构化数据和可打开或下载的文件产物,并在语音播报 完成或 Gateway 重连后继续保留产物入口。 diff --git a/tui/src/input-parts.mjs b/tui/src/input-parts.mjs index bc987e91..bba07662 100644 --- a/tui/src/input-parts.mjs +++ b/tui/src/input-parts.mjs @@ -107,6 +107,16 @@ function pastedFilePaths(text) { return isAbsolute(value) || /^\.\.?[\\/]/.test(value) ? [value, literal] : [] } +// Windows 不可用共享的 stat 可能返回 UNKNOWN,而不是 ENOENT。 +// 只放行 UNC 查找失败;本地路径、权限及实际读取错误仍须报告。 +function unavailablePath(error) { + if (['ENOENT', 'ENOTDIR'].includes(error?.code)) return true + return process.platform === 'win32' + && error?.code === 'UNKNOWN' + && error.syscall === 'stat' + && /^\\\\(?![?.]\\)[^\\]+\\[^\\]+(?:\\|$)/.test(error.path || '') +} + // Windows 路径以 \ 分隔,C:\docs\(draft)\a.md 或 \\server\share 中的 \( 与 \\ // 并不是 shell 转义。UNC 路径保留原样;其他路径优先按转义解析(如 cat\ image.png), // 该路径不存在时再尝试原样粘贴的文本。 @@ -116,7 +126,7 @@ async function filePartFromCandidates(paths, index) { try { return await filePartFromPath(path, index) } catch (error) { - if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error + if (!unavailablePath(error)) throw error missing ??= error } } @@ -162,7 +172,7 @@ export async function inputPartsFromText( } catch (error) { // A missing pasted path may still be intentional text. Existing paths // that are directories, too large, or unreadable remain real errors. - if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error + if (!unavailablePath(error)) throw error } } if (!paths.length) { @@ -179,7 +189,7 @@ export async function inputPartsFromText( value: part.source.text.value, }) } catch (error) { - if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error + if (!unavailablePath(error)) throw error } } if (replacements.length) { @@ -208,7 +218,7 @@ export async function inputPartsFromText( // A literal @mention is still ordinary text. Only an existing path is // promoted into a file part; explicit attachment selection still // surfaces invalid paths to the user through filePartFromPath(). - if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error + if (!unavailablePath(error)) throw error } } return withAttachmentAnchors([ diff --git a/tui/test/input-parts.test.mjs b/tui/test/input-parts.test.mjs index 4be37f77..adc82b5f 100644 --- a/tui/test/input-parts.test.mjs +++ b/tui/test/input-parts.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict' import { mkdtemp, writeFile } from 'node:fs/promises' +import fs from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' @@ -141,3 +143,61 @@ test('keeps ordinary @mentions as text when they are not paths', async () => { const parts = await inputPartsFromText('请问 @designer 的意见') assert.deepEqual(parts, [{ type: 'text', text: '请问 @designer 的意见' }]) }) + +test('only tolerates UNKNOWN for Windows UNC stat failures', async t => { + let failure + t.mock.method(fs, 'stat', async () => { + if (failure.syscall === 'stat') throw failure + return { isFile: () => true, size: 12 } + }) + t.mock.method(fs, 'readFile', async () => { throw failure }) + syncBuiltinESMExports() + t.after(() => { + t.mock.restoreAll() + syncBuiltinESMExports() + }) + + const local = join(tmpdir(), 'qwa-input-error.pdf') + const share = String.raw`\\server\share\report.pdf` + for (const path of [local, share]) { + for (const syscall of ['stat', 'read']) { + for (const code of ['UNKNOWN', 'EACCES']) { + failure = Object.assign(new Error('simulated filesystem error'), { + code, syscall, path, + }) + // Cover direct paste, inline paste and explicit @mention on every OS. + for (const text of [local, `总结 ${local}`, `总结 @${local}`]) { + if (process.platform === 'win32' && path === share + && syscall === 'stat' && code === 'UNKNOWN') { + assert.deepEqual(await inputPartsFromText(text), [{ type: 'text', text }]) + } else { + await assert.rejects(inputPartsFromText(text), error => error === failure) + } + } + } + } + } +}) + +test('keeps an unavailable Windows share as ordinary text', { + skip: process.platform !== 'win32', +}, async () => { + // 本机一定可达,但共享名不存在:Windows 报 ERROR_BAD_NETPATH, + // Node 把它映射成 UNKNOWN 而不是 ENOENT。 + const share = String.raw`\\127.0.0.1\qwen-audio-agent-missing-share\report.pdf` + assert.deepEqual( + await inputPartsFromText(share), + [{ type: 'text', text: share }], + ) + const inline = `请看 ${share} 的数据` + assert.deepEqual( + await inputPartsFromText(inline), + [{ type: 'text', text: inline }], + ) + assert.deepEqual( + await inputPartsFromText(`总结 @${share}`), + [{ type: 'text', text: `总结 @${share}` }], + ) + // Explicit attachment selection must still surface the missing share. + await assert.rejects(filePartFromPath(share)) +})