Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 重连后继续保留产物入口。
Expand Down
18 changes: 14 additions & 4 deletions tui/src/input-parts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
// 该路径不存在时再尝试原样粘贴的文本。
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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([
Expand Down
60 changes: 60 additions & 0 deletions tui/test/input-parts.test.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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))
})
Loading