From 5dcf85473a1197a9fc5f7d4e1f04d270b07625f0 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:32:07 +0900 Subject: [PATCH 1/2] fix(tui): keep unavailable Windows shares as ordinary text Windows reports an unreachable host or a missing share as UNKNOWN rather than ENOENT, so mentioning a UNC path made the whole message fail instead of being sent as plain text. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +++ tui/src/input-parts.mjs | 15 +++++++++++---- tui/test/input-parts.test.mjs | 21 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4682b82..a377395b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ (如 `C:\docs\(draft)\a.md`)或 `\\server\share` 共享路径被误当作 shell 转义、 文件没有作为附件发送的问题;转义解析找不到文件时会改用原样粘贴的路径, 并支持在文字中识别共享路径、保留其原始分隔符。 +- 修复 TUI 中提到不可达主机或不存在共享的 Windows 路径(如 `\\server\share\a.pdf`) + 时整条消息报 `UNKNOWN: unknown error` 发不出去的问题;取不到的共享路径与本地 + 缺失路径一样按普通文字发送。 - WebUI 与共享移动端现在会在后台任务卡片中展示类型化 Artifact;支持远程媒体 的显式加载、内联图片预览、结构化数据和可打开或下载的文件产物,并在语音播报 完成或 Gateway 重连后继续保留产物入口。 diff --git a/tui/src/input-parts.mjs b/tui/src/input-parts.mjs index bc987e91..cc0d5366 100644 --- a/tui/src/input-parts.mjs +++ b/tui/src/input-parts.mjs @@ -107,6 +107,13 @@ function pastedFilePaths(text) { return isAbsolute(value) || /^\.\.?[\\/]/.test(value) ? [value, literal] : [] } +// 路径取不到,只说明这段文字不是可用附件。Windows 上不可达的主机或不存在的 +// 共享(\\server\share)来自 ERROR_BAD_NETPATH,Node 映射为 UNKNOWN 而非 +// ENOENT,同样不能让普通文字发不出去。 +function unavailablePath(error) { + return ['ENOENT', 'ENOTDIR', 'UNKNOWN'].includes(error?.code) +} + // Windows 路径以 \ 分隔,C:\docs\(draft)\a.md 或 \\server\share 中的 \( 与 \\ // 并不是 shell 转义。UNC 路径保留原样;其他路径优先按转义解析(如 cat\ image.png), // 该路径不存在时再尝试原样粘贴的文本。 @@ -116,7 +123,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 +169,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 +186,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 +215,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..c048a615 100644 --- a/tui/test/input-parts.test.mjs +++ b/tui/test/input-parts.test.mjs @@ -141,3 +141,24 @@ 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('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}` }], + ) +}) From 2d8abcb7089f4a5e43fcc5406a7938ecf3078a25 Mon Sep 17 00:00:00 2001 From: "xu.li" <62833389+x-lixu@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:39:53 +0800 Subject: [PATCH 2/2] fix(tui): limit unknown path fallback to Windows UNC stat --- CHANGELOG.md | 3 ++- tui/src/input-parts.mjs | 11 ++++++---- tui/test/input-parts.test.mjs | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d87f51bf..94ab7686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,8 @@ 并支持在文字中识别共享路径、保留其原始分隔符。 - 修复 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 cc0d5366..bba07662 100644 --- a/tui/src/input-parts.mjs +++ b/tui/src/input-parts.mjs @@ -107,11 +107,14 @@ function pastedFilePaths(text) { return isAbsolute(value) || /^\.\.?[\\/]/.test(value) ? [value, literal] : [] } -// 路径取不到,只说明这段文字不是可用附件。Windows 上不可达的主机或不存在的 -// 共享(\\server\share)来自 ERROR_BAD_NETPATH,Node 映射为 UNKNOWN 而非 -// ENOENT,同样不能让普通文字发不出去。 +// Windows 不可用共享的 stat 可能返回 UNKNOWN,而不是 ENOENT。 +// 只放行 UNC 查找失败;本地路径、权限及实际读取错误仍须报告。 function unavailablePath(error) { - return ['ENOENT', 'ENOTDIR', 'UNKNOWN'].includes(error?.code) + 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 中的 \( 与 \\ diff --git a/tui/test/input-parts.test.mjs b/tui/test/input-parts.test.mjs index c048a615..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' @@ -142,6 +144,41 @@ test('keeps ordinary @mentions as text when they are not paths', async () => { 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 () => { @@ -161,4 +198,6 @@ test('keeps an unavailable Windows share as ordinary text', { await inputPartsFromText(`总结 @${share}`), [{ type: 'text', text: `总结 @${share}` }], ) + // Explicit attachment selection must still surface the missing share. + await assert.rejects(filePartFromPath(share)) })