Skip to content

Commit 7991a7b

Browse files
committed
fix: Agent 对话历史里出现的文件路径解析和预览失效的问题
1 parent d5c746b commit 7991a7b

5 files changed

Lines changed: 122 additions & 43 deletions

File tree

apps/electron/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@proma/electron",
3-
"version": "0.9.20",
3+
"version": "0.9.21",
44
"description": "Proma next gen ai software with general agents - Electron App",
55
"main": "dist/main.cjs",
66
"author": {

apps/electron/src/main/lib/file-preview-service.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,22 +1455,81 @@ function watchExternalChange(previewWindow: BrowserWindow, state: PreviewWindowS
14551455
}
14561456
}
14571457

1458+
/**
1459+
* 在目录中递归搜索指定文件名(用于路径失效时的 fallback)
1460+
* 限制搜索深度和目录数量,避免阻塞主进程
1461+
*/
1462+
function searchFileInDir(dir: string, targetName: string, maxDepth = 8): string | null {
1463+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '__pycache__', '.venv', 'build', '.cache', 'target'])
1464+
let scanned = 0
1465+
const MAX_SCANNED = 500
1466+
1467+
function walk(current: string, depth: number): string | null {
1468+
if (depth > maxDepth || scanned > MAX_SCANNED) return null
1469+
try {
1470+
const entries = require('fs').readdirSync(current, { withFileTypes: true }) as import('fs').Dirent[]
1471+
for (const entry of entries) {
1472+
if (entry.isFile() && entry.name === targetName) {
1473+
return join(current, entry.name)
1474+
}
1475+
}
1476+
for (const entry of entries) {
1477+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
1478+
scanned++
1479+
const found = walk(join(current, entry.name), depth + 1)
1480+
if (found) return found
1481+
}
1482+
}
1483+
} catch { /* permission denied etc */ }
1484+
return null
1485+
}
1486+
1487+
return walk(dir, 0)
1488+
}
1489+
14581490
/**
14591491
* 解析待预览的文件路径
1460-
* - 绝对路径:直接 resolve
1461-
* - 相对路径:依次尝试 basePaths,返回第一个存在的;都不存在则返回基于第一个 base 的拼接结果
1462-
* (让后续 statSync 抛出更明确的错误,而不是被相对 process.cwd 误导)
1492+
* - 绝对路径:直接 resolve,不存在时 fallback 搜索
1493+
* - 相对路径:依次尝试 basePaths,返回第一个存在的;都不存在则 fallback 搜索
14631494
*/
14641495
function resolveTargetPath(filePath: string, basePaths?: string[]): string {
1496+
// 直接解析
14651497
if (filePath.startsWith('/') || /^[A-Za-z]:[\\/]/.test(filePath)) {
1466-
return resolve(filePath)
1498+
const direct = resolve(filePath)
1499+
if (existsSync(direct)) return direct
1500+
// fallback: 用文件名在 basePaths 中搜索
1501+
const name = basename(direct)
1502+
if (basePaths) {
1503+
for (const base of basePaths) {
1504+
if (!base) continue
1505+
const found = searchFileInDir(base, name)
1506+
if (found) return found
1507+
}
1508+
}
1509+
// 从路径中提取 agent-workspaces 根目录作为搜索范围
1510+
const awIdx = filePath.indexOf('agent-workspaces')
1511+
if (awIdx !== -1) {
1512+
const wsRoot = filePath.slice(0, awIdx + 'agent-workspaces'.length)
1513+
if (existsSync(wsRoot)) {
1514+
const found = searchFileInDir(wsRoot, name)
1515+
if (found) return found
1516+
}
1517+
}
1518+
return direct
14671519
}
14681520
if (basePaths && basePaths.length > 0) {
14691521
for (const base of basePaths) {
14701522
if (!base) continue
14711523
const candidate = resolve(base, filePath)
14721524
if (existsSync(candidate)) return candidate
14731525
}
1526+
// fallback: 用文件名搜索
1527+
const name = basename(filePath)
1528+
for (const base of basePaths) {
1529+
if (!base) continue
1530+
const found = searchFileInDir(base, name)
1531+
if (found) return found
1532+
}
14741533
return resolve(basePaths[0]!, filePath)
14751534
}
14761535
return resolve(filePath)
@@ -1489,17 +1548,29 @@ export function openFilePreview(filePath: string, basePaths?: string[]): void {
14891548
const ext = extname(safePath).toLowerCase()
14901549
const previewType = getPreviewType(safePath, ext)
14911550

1551+
if (!existsSync(safePath)) {
1552+
dialog.showErrorBox('文件不存在', `找不到文件:\n${safePath}\n\n原始路径:${filePath}`)
1553+
return
1554+
}
1555+
14921556
// 不支持的类型,直接用系统默认应用打开
14931557
if (previewType === 'unsupported') {
1494-
shell.openPath(safePath)
1558+
shell.openPath(safePath).then(err => { if (err) console.error('[文件预览] 系统打开失败:', err) })
14951559
return
14961560
}
14971561

14981562
// 检查文件大小
1499-
const stat = statSync(safePath)
1563+
let stat: ReturnType<typeof statSync>
1564+
try {
1565+
stat = statSync(safePath)
1566+
} catch (err) {
1567+
console.error('[文件预览] 读取文件信息失败:', err)
1568+
shell.openPath(safePath).then(e => { if (e) console.error('[文件预览] 系统打开失败:', e) })
1569+
return
1570+
}
15001571
if (stat.size > MAX_FILE_SIZE) {
15011572
console.warn(`[文件预览] 文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),使用系统应用打开`)
1502-
shell.openPath(safePath)
1573+
shell.openPath(safePath).then(err => { if (err) console.error('[文件预览] 系统打开失败:', err) })
15031574
return
15041575
}
15051576

apps/electron/src/renderer/components/agent/tool-result-renderers/write-result.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import * as React from 'react'
66
import { cn } from '@/lib/utils'
7+
import { FilePathChip } from '@/components/ai-elements/file-path-chip'
78

89
interface WriteResultRendererProps {
910
result: string
@@ -24,10 +25,9 @@ export function WriteResultRenderer({ result, isError, input }: WriteResultRende
2425

2526
if (!content) {
2627
const filePath = typeof input.file_path === 'string' ? input.file_path : ''
27-
const filename = filePath.split(/[/\\]/).pop() ?? filePath
2828
return (
29-
<div className="text-[12px] text-muted-foreground">
30-
已写入 <span className="font-mono text-foreground/70">{filename || '文件'}</span>
29+
<div className="text-[12px] text-muted-foreground flex items-center gap-1">
30+
已写入 {filePath ? <FilePathChip filePath={filePath} /> : <span className="font-mono text-foreground/70">文件</span>}
3131
</div>
3232
)
3333
}

apps/electron/src/renderer/components/ai-elements/file-path-chip.tsx

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*/
88

99
import * as React from 'react'
10-
import { FileText, FileImage, FileVideo, FileCode } from 'lucide-react'
1110
import { cn } from '@/lib/utils'
11+
import { FileTypeIcon } from '@/components/file-browser/FileTypeIcon'
1212

1313
/** 图片扩展名 */
1414
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'])
@@ -52,17 +52,20 @@ function getExtension(filename: string): string {
5252
return filename.slice(dot + 1).toLowerCase()
5353
}
5454

55-
/** 根据扩展名获取文件图标 */
56-
function getFileIcon(ext: string): React.ReactElement {
57-
const iconClass = 'size-3 shrink-0'
58-
if (IMAGE_EXTS.has(ext)) return <FileImage className={iconClass} />
59-
if (VIDEO_EXTS.has(ext)) return <FileVideo className={iconClass} />
60-
if (CODE_EXTS.has(ext)) return <FileCode className={iconClass} />
61-
return <FileText className={iconClass} />
55+
/**
56+
* 从路径中剥离末尾的行号/列号后缀(如 :42 或 :42:15)
57+
* Agent 模式下模型常输出 file_path:line_number 格式
58+
*/
59+
function stripLineCol(filePath: string): { path: string; suffix: string } {
60+
const m = filePath.match(/^(.+?)(:\d+(?::\d+)?)$/)
61+
if (m && !m[1]!.endsWith(':')) {
62+
return { path: m[1]!, suffix: m[2]! }
63+
}
64+
return { path: filePath, suffix: '' }
6265
}
6366

6467
interface FilePathChipProps {
65-
/** 文件路径(绝对或相对) */
68+
/** 文件路径(绝对或相对,可能带行号后缀) */
6669
filePath: string
6770
/** 基础目录路径(向后兼容,单值) */
6871
basePath?: string
@@ -73,11 +76,12 @@ interface FilePathChipProps {
7376

7477
/** 文件路径芯片 — 可点击,触发文件预览 */
7578
export function FilePathChip({ filePath, basePath, basePaths, className }: FilePathChipProps): React.ReactElement {
76-
const filename = getFileName(filePath)
77-
const ext = getExtension(filename)
78-
7979
const trimmedPath = filePath.trim()
80-
const isAbsolute = trimmedPath.startsWith('/') || /^[A-Z]:\\/.test(trimmedPath)
80+
const { path: cleanPath, suffix: lineColSuffix } = stripLineCol(trimmedPath)
81+
82+
const filename = getFileName(cleanPath)
83+
84+
const isAbsolute = cleanPath.startsWith('/') || /^[A-Z]:\\/.test(cleanPath)
8185

8286
// 候选基础目录列表:优先使用 basePaths;否则退化到 basePath 单值
8387
const candidateBases = React.useMemo<string[]>(() => {
@@ -91,19 +95,17 @@ export function FilePathChip({ filePath, basePath, basePaths, className }: FileP
9195
if (isAbsolute) return trimmedPath
9296
if (candidateBases.length > 0) {
9397
const base = candidateBases[0]!
94-
return base.endsWith('/') ? `${base}${trimmedPath}` : `${base}/${trimmedPath}`
98+
return base.endsWith('/') ? `${base}${cleanPath}` : `${base}/${cleanPath}`
9599
}
96100
return trimmedPath
97-
}, [trimmedPath, isAbsolute, candidateBases])
101+
}, [trimmedPath, cleanPath, isAbsolute, candidateBases])
98102

99103
const handleClick = React.useCallback(() => {
100-
// 绝对路径直接预览;相对路径把候选 basePaths 交给主进程依次尝试
101-
const target = isAbsolute ? trimmedPath : trimmedPath
102-
const bases = isAbsolute ? undefined : (candidateBases.length > 0 ? candidateBases : undefined)
103-
window.electronAPI.previewFile(target, bases).catch((error: unknown) => {
104+
const bases = candidateBases.length > 0 ? candidateBases : undefined
105+
window.electronAPI.previewFile(cleanPath, bases).catch((error: unknown) => {
104106
console.error('[FilePathChip] 预览文件失败:', error)
105107
})
106-
}, [trimmedPath, isAbsolute, candidateBases])
108+
}, [cleanPath, candidateBases])
107109

108110
return (
109111
<button
@@ -118,8 +120,8 @@ export function FilePathChip({ filePath, basePath, basePaths, className }: FileP
118120
className
119121
)}
120122
>
121-
{getFileIcon(ext)}
122-
<span className="truncate max-w-[240px]">{filename}</span>
123+
<FileTypeIcon name={filename} isDirectory={false} size={14} />
124+
<span className="truncate max-w-[240px]">{filename}{lineColSuffix}</span>
123125
</button>
124126
)
125127
}
@@ -135,15 +137,18 @@ export function isAbsoluteFilePath(text: string): boolean {
135137
const trimmed = text.trim()
136138
if (trimmed.length < 2) return false
137139

140+
// 剥离末尾行号后缀再检测
141+
const { path: clean } = stripLineCol(trimmed)
142+
138143
// macOS/Linux 绝对路径:以 / 开头,至少两级
139-
if (trimmed.startsWith('/') && /^\/[^\n]+\/[^\n]+$/.test(trimmed)) {
144+
if (clean.startsWith('/') && /^\/[^\n]+\/[^\n]+$/.test(clean)) {
140145
// 排除常见的非路径模式(如 /regex/ 模式)
141-
if (trimmed.endsWith('/') && !trimmed.includes('.')) return false
146+
if (clean.endsWith('/') && !clean.includes('.')) return false
142147
return true
143148
}
144149

145150
// Windows 绝对路径
146-
if (/^[A-Z]:\\/.test(trimmed)) return true
151+
if (/^[A-Z]:\\/.test(clean)) return true
147152

148153
return false
149154
}
@@ -160,16 +165,19 @@ export function isRelativeFilePath(text: string): boolean {
160165
const trimmed = text.trim()
161166
if (trimmed.length < 3) return false
162167

168+
// 剥离末尾行号后缀再检测
169+
const { path: clean } = stripLineCol(trimmed)
170+
163171
// 提取扩展名
164-
const ext = getExtension(trimmed)
172+
const ext = getExtension(clean)
165173
if (!ext || !ALL_PREVIEWABLE_EXTS.has(ext)) return false
166174

167175
// 必须看起来像文件路径:允许 字母数字、点、横线、下划线、斜杠
168176
// 排除含空格或特殊字符的(太可能是其他内容)
169-
if (!/^[\w./@-]+$/.test(trimmed)) return false
177+
if (!/^[\w./@-]+$/.test(clean)) return false
170178

171179
// 排除以点开头的隐藏文件(如 .gitignore),但保留含子路径的目录相对路径(如 .context/file.md)
172-
if (trimmed.startsWith('.') && !trimmed.startsWith('./') && !trimmed.includes('/')) return false
180+
if (clean.startsWith('.') && !clean.startsWith('./') && !clean.includes('/')) return false
173181

174182
return true
175183
}

apps/electron/src/renderer/components/ai-elements/message.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -471,10 +471,7 @@ const MarkdownInlineCode = React.memo(function MarkdownInlineCode({
471471
const text = typeof codeChildren === 'string' ? codeChildren : ''
472472

473473
if (text) {
474-
if (isAbsoluteFilePath(text)) {
475-
return <FilePathChip filePath={text.trim()} />
476-
}
477-
// 相对路径:合并 basePath(主 cwd)+ basePaths(props 或 context 提供的附加目录)作为候选
474+
// 合并 basePath(主 cwd)+ basePaths(props 或 context 提供的附加目录)作为候选
478475
const merged: string[] = []
479476
if (basePath) merged.push(basePath)
480477
const allExtra = basePaths || ctxBasePaths
@@ -483,6 +480,9 @@ const MarkdownInlineCode = React.memo(function MarkdownInlineCode({
483480
if (p && !merged.includes(p)) merged.push(p)
484481
}
485482
}
483+
if (isAbsoluteFilePath(text)) {
484+
return <FilePathChip filePath={text.trim()} basePaths={merged.length > 0 ? merged : undefined} />
485+
}
486486
if (merged.length > 0 && isRelativeFilePath(text)) {
487487
return <FilePathChip filePath={text.trim()} basePaths={merged} />
488488
}

0 commit comments

Comments
 (0)