diff --git a/desktop/src/lib/assistantOutputTargets.test.ts b/desktop/src/lib/assistantOutputTargets.test.ts new file mode 100644 index 00000000..1fe3427a --- /dev/null +++ b/desktop/src/lib/assistantOutputTargets.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest' +import { extractAssistantOutputTargets } from './assistantOutputTargets' + +const workDir = '/Users/nanmi/project/demo' + +describe('extractAssistantOutputTargets', () => { + it('extracts markdown links for workspace html, markdown, and images', () => { + const content = [ + '已完成:', + '- [index.html](/Users/nanmi/project/demo/index.html)', + '- [notes](docs/result.md)', + '- [preview](assets/hero.png)', + ].join('\n') + + const targets = extractAssistantOutputTargets(content, { workDir }) + + expect(targets.map((target) => [target.kind, target.href, target.normalizedPath])).toEqual([ + ['local-html', '/Users/nanmi/project/demo/index.html', 'index.html'], + ['markdown', 'docs/result.md', 'docs/result.md'], + ['image', 'assets/hero.png', 'assets/hero.png'], + ]) + }) + + it('normalizes markdown destinations with angle brackets, spaces, and line suffixes', () => { + const content = [ + '[html]()', + '[lined](/Users/nanmi/project/demo/index.html:12)', + ].join('\n') + + const targets = extractAssistantOutputTargets(content, { workDir }) + + expect(targets.map((target) => [target.href, target.normalizedPath])).toEqual([ + ['/Users/nanmi/project/demo/My Page/index.html', 'My Page/index.html'], + ['/Users/nanmi/project/demo/index.html', 'index.html'], + ]) + }) + + it('accepts safe Windows workspace paths with case-insensitive segments', () => { + const targets = extractAssistantOutputTargets( + '[Preview](C:/users/nanmi/project/demo/out/index.html)', + { workDir: 'C:/Users/nanmi/project/demo' }, + ) + + expect(targets).toMatchObject([ + { + kind: 'local-html', + href: 'C:/users/nanmi/project/demo/out/index.html', + normalizedPath: 'out/index.html', + }, + ]) + }) + + it('accepts absolute paths when the workdir is filesystem root', () => { + const targets = extractAssistantOutputTargets( + '[Preview](/tmp/demo/index.html)', + { workDir: '/' }, + ) + + expect(targets).toMatchObject([ + { + kind: 'local-html', + href: '/tmp/demo/index.html', + normalizedPath: 'tmp/demo/index.html', + }, + ]) + }) + + it('extracts naked localhost and loopback URLs', () => { + const targets = extractAssistantOutputTargets( + 'Open http://localhost:5173 and http://127.0.0.1:3000/app and http://[::1]:4173/app now.', + { workDir }, + ) + + expect(targets).toMatchObject([ + { kind: 'localhost-url', href: 'http://localhost:5173' }, + { kind: 'localhost-url', href: 'http://127.0.0.1:3000/app' }, + { kind: 'localhost-url', href: 'http://[::1]:4173/app' }, + ]) + }) + + it('trims markdown/code punctuation around naked localhost URLs', () => { + const targets = extractAssistantOutputTargets( + '地址:`http://localhost:9527/`,备用:http://127.0.0.1:3000/app)。', + { workDir }, + ) + + expect(targets.map((target) => target.href)).toEqual([ + 'http://localhost:9527/', + 'http://127.0.0.1:3000/app', + ]) + }) + + it('keeps markdown localhost links as markdown-link targets with authored labels', () => { + const targets = extractAssistantOutputTargets( + '[Preview](http://localhost:4173) then http://localhost:4173', + { workDir }, + ) + + expect(targets).toMatchObject([ + { + kind: 'localhost-url', + href: 'http://localhost:4173', + title: 'Preview', + source: 'markdown-link', + }, + ]) + expect(targets).toHaveLength(1) + }) + + it('rejects paths outside the active workspace', () => { + const targets = extractAssistantOutputTargets( + '[secret](/Users/nanmi/private/secret.html) [ok](/Users/nanmi/project/demo/public/index.html)', + { workDir }, + ) + + expect(targets).toHaveLength(1) + expect(targets[0]).toMatchObject({ + kind: 'local-html', + normalizedPath: 'public/index.html', + }) + }) + + it('deduplicates repeated targets while preserving order', () => { + const content = [ + '[index](index.html)', + 'Again: http://localhost:5173', + '[index copy](./index.html)', + 'Again: http://localhost:5173', + ].join('\n') + + const targets = extractAssistantOutputTargets(content, { workDir }) + + expect(targets.map((target) => target.href)).toEqual(['index.html', 'http://localhost:5173']) + }) + + it('extracts files from absolute-root directory trees inside code blocks', () => { + const targets = extractAssistantOutputTargets( + [ + '目录结构', + '```', + '/Users/nanmi/project/demo/generated/', + '├── README.md # Markdown 说明文件', + '├── index.html # 静态页面', + '└── todo-app/', + ' └── index.html', + '```', + ].join('\n'), + { workDir }, + ) + + expect(targets.map((target) => [target.href, target.normalizedPath])).toEqual([ + ['/Users/nanmi/project/demo/generated/README.md', 'generated/README.md'], + ['/Users/nanmi/project/demo/generated/index.html', 'generated/index.html'], + ['/Users/nanmi/project/demo/generated/todo-app/index.html', 'generated/todo-app/index.html'], + ]) + }) + + it('ignores orphan preview file names inside code blocks', () => { + const targets = extractAssistantOutputTargets( + ['```', 'index.html', 'README.md', '```'].join('\n'), + { workDir }, + ) + + expect(targets).toEqual([]) + }) + + it('preserves first-seen order across mixed target types', () => { + const targets = extractAssistantOutputTargets( + 'Open http://localhost:5173 first, then [index](index.html), then docs/guide.md.', + { workDir }, + ) + + expect(targets.map((target) => target.href)).toEqual([ + 'http://localhost:5173', + 'index.html', + 'docs/guide.md', + ]) + }) + + it('limits the result set to high-confidence preview targets', () => { + const targets = extractAssistantOutputTargets( + 'Read https://example.com and maybe file:///etc/passwd, but use report.pdf only externally.', + { workDir }, + ) + + expect(targets).toEqual([]) + }) + + it('caps results at 6 by default', () => { + const targets = extractAssistantOutputTargets( + [ + '[one](one.html)', + '[two](two.html)', + '[three](three.html)', + '[four](four.html)', + '[five](five.html)', + '[six](six.html)', + '[seven](seven.html)', + ].join('\n'), + { workDir }, + ) + + expect(targets.map((target) => target.href)).toEqual([ + 'one.html', + 'two.html', + 'three.html', + 'four.html', + 'five.html', + 'six.html', + ]) + }) + + it('respects an explicit limit override', () => { + const targets = extractAssistantOutputTargets( + '[one](one.html) [two](two.html) [three](three.html)', + { workDir, limit: 2 }, + ) + + expect(targets.map((target) => target.href)).toEqual(['one.html', 'two.html']) + }) +}) diff --git a/desktop/src/lib/assistantOutputTargets.ts b/desktop/src/lib/assistantOutputTargets.ts new file mode 100644 index 00000000..34768447 --- /dev/null +++ b/desktop/src/lib/assistantOutputTargets.ts @@ -0,0 +1,666 @@ +export type AssistantOutputTargetKind = + | 'local-html' + | 'localhost-url' + | 'image' + | 'markdown' + +export type AssistantOutputTargetSource = + | 'markdown-link' + | 'plain-url' + | 'plain-path' + +export type AssistantOutputTarget = { + id: string + kind: AssistantOutputTargetKind + title: string + subtitle?: string + href: string + normalizedPath?: string + confidence: 'high' + source: AssistantOutputTargetSource +} + +export type ExtractAssistantOutputTargetOptions = { + workDir?: string | null + limit?: number +} + +type FileTargetMatch = { + kind: Exclude + normalizedPath: string +} + +type LocalhostTargetMatch = { + kind: 'localhost-url' + href: string +} + +type CandidateTarget = { + key: string + position: number + order: number + target: AssistantOutputTarget +} + +type MarkdownLinkMatch = { + title: string + href: string + start: number + end: number +} + +type FencedCodeBlock = { + start: number + end: number + contentStart: number + text: string +} + +type DirectoryTreeFileMatch = { + href: string + position: number +} + +const localhostUrlPattern = + /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s`"'<>,。;、)\])}]*)?/gi +const previewablePathPattern = + /(^|[\s("'`[])((?:\.{1,2}\/)?(?:[\w.-]+\/)*[\w.-]+\.(?:html?|md|markdown|png|jpe?g|gif|webp|svg))(?![\w/-])/gi + +export function extractAssistantOutputTargets( + content: string, + options: ExtractAssistantOutputTargetOptions = {}, +): AssistantOutputTarget[] { + const workDir = options.workDir ? resolveFilePath(options.workDir) : null + const limit = options.limit ?? 6 + const candidates: CandidateTarget[] = [] + const results: AssistantOutputTarget[] = [] + const seen = new Set() + const markdownLinks = extractMarkdownLinks(content) + const codeBlocks = extractFencedCodeBlocks(content) + let order = 0 + + const queueTarget = (target: AssistantOutputTarget, key: string, position: number) => { + candidates.push({ + key, + position, + order, + target, + }) + order += 1 + } + + for (const match of markdownLinks) { + const title = match.title + const href = match.href + const localhostTarget = toLocalhostTarget(href) + const fileTarget = toWorkspaceFileTarget(href, workDir) + + if (!title) { + continue + } + + if (localhostTarget) { + queueTarget({ + id: createId(localhostTarget.kind, localhostTarget.href), + kind: localhostTarget.kind, + title, + href: localhostTarget.href, + confidence: 'high', + source: 'markdown-link', + }, createLocalhostKey(localhostTarget.href), match.start) + continue + } + + if (!fileTarget) { + continue + } + + queueTarget({ + id: createId(fileTarget.kind, fileTarget.normalizedPath), + kind: fileTarget.kind, + title, + subtitle: fileTarget.normalizedPath, + href, + normalizedPath: fileTarget.normalizedPath, + confidence: 'high', + source: 'markdown-link', + }, createFileKey(fileTarget), match.start) + } + + for (const match of content.matchAll(localhostUrlPattern)) { + const position = match.index ?? 0 + + if (isInMarkdownLink(position, markdownLinks)) { + continue + } + + const href = trimTrailingPunctuation(match[0] ?? '') + + if (!href) { + continue + } + + queueTarget({ + id: createId('localhost-url', href), + kind: 'localhost-url', + title: href, + href, + confidence: 'high', + source: 'plain-url', + }, createLocalhostKey(href), position) + } + + for (const treeMatch of extractDirectoryTreeFileMatches(codeBlocks)) { + const fileTarget = toWorkspaceFileTarget(treeMatch.href, workDir) + + if (!fileTarget) { + continue + } + + queueTarget({ + id: createId(fileTarget.kind, fileTarget.normalizedPath), + kind: fileTarget.kind, + title: getBasename(fileTarget.normalizedPath), + subtitle: fileTarget.normalizedPath, + href: treeMatch.href, + normalizedPath: fileTarget.normalizedPath, + confidence: 'high', + source: 'plain-path', + }, createFileKey(fileTarget), treeMatch.position) + } + + for (const match of content.matchAll(previewablePathPattern)) { + const position = match.index ?? 0 + + if (isInMarkdownLink(position, markdownLinks)) { + continue + } + + if (isInCodeBlock(position, codeBlocks)) { + continue + } + + const href = trimTrailingPunctuation(match[2] ?? '') + const fileTarget = toWorkspaceFileTarget(href, workDir) + + if (!fileTarget) { + 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) + } + + candidates.sort((left, right) => { + if (left.position !== right.position) { + return left.position - right.position + } + + return left.order - right.order + }) + + for (const candidate of candidates) { + if (results.length >= limit || seen.has(candidate.key)) { + continue + } + + seen.add(candidate.key) + results.push(candidate.target) + } + + return results +} + +function toWorkspaceFileTarget(candidate: string, workDir: string | null): FileTargetMatch | null { + if (!candidate || candidate.startsWith('file://') || isExternalUrl(candidate)) { + return null + } + + const kind = classifyFileTarget(candidate) + + if (!kind) { + return null + } + + if (isAbsoluteFilePath(candidate)) { + if (!workDir) { + return null + } + + const absoluteCandidate = resolveFilePath(candidate) + + if (!isWithinWorkDir(absoluteCandidate, workDir)) { + return null + } + + return { + kind, + normalizedPath: relativeFilePath(workDir, absoluteCandidate), + } + } + + const baseDir = workDir ?? '.' + const resolvedCandidate = resolveFilePath(candidate, baseDir) + + if (workDir && !isWithinWorkDir(resolvedCandidate, workDir)) { + return null + } + + const normalizedPath = workDir + ? relativeFilePath(workDir, resolvedCandidate) + : normalizeFilePath(candidate) + + if (!normalizedPath || normalizedPath === '.' || normalizedPath.startsWith('../')) { + return null + } + + return { + kind, + normalizedPath, + } +} + +function classifyFileTarget(candidate: string): FileTargetMatch['kind'] | null { + const extension = getExtension(candidate).toLowerCase() + + if (extension === '.html' || extension === '.htm') { + return 'local-html' + } + + if (extension === '.md' || extension === '.markdown') { + return 'markdown' + } + + if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(extension)) { + return 'image' + } + + return null +} + +function toLocalhostTarget(candidate: string): LocalhostTargetMatch | null { + const href = trimTrailingPunctuation(candidate) + + if (!localhostUrlPattern.test(href) || localhostUrlPattern.lastIndex !== href.length) { + localhostUrlPattern.lastIndex = 0 + return null + } + + localhostUrlPattern.lastIndex = 0 + + return { + kind: 'localhost-url', + href, + } +} + +function isExternalUrl(candidate: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(candidate) +} + +function isWithinWorkDir(candidatePath: string, workDir: string): boolean { + const relativePath = relativeFilePath(workDir, candidatePath) + + return relativePath === '' || (!relativePath.startsWith('..') && !isAbsoluteFilePath(relativePath)) +} + +function toPosixPath(value: string): string { + return value.replace(/\\/g, '/') +} + +function trimTrailingPunctuation(value: string): string { + return value.replace(/[`'")\]\}>,.;!?,。;、)】》]+$/g, '') +} + +function extractMarkdownLinks(content: string): MarkdownLinkMatch[] { + const matches: MarkdownLinkMatch[] = [] + + for (let index = 0; index < content.length; index += 1) { + if (content[index] !== '[') { + continue + } + + const labelEnd = content.indexOf(']', index + 1) + + if (labelEnd === -1 || content[labelEnd + 1] !== '(') { + continue + } + + const destinationEnd = findMarkdownDestinationEnd(content, labelEnd + 2) + + if (destinationEnd === -1) { + continue + } + + const title = content.slice(index + 1, labelEnd).trim() + const rawDestination = content.slice(labelEnd + 2, destinationEnd) + const href = normalizeMarkdownDestination(rawDestination) + + matches.push({ + title, + href, + start: index, + end: destinationEnd + 1, + }) + + index = destinationEnd + } + + return matches +} + +function findMarkdownDestinationEnd(content: string, start: number): number { + let inAngleBrackets = false + + for (let index = start; index < content.length; index += 1) { + const character = content[index] + + if (character === '<') { + inAngleBrackets = true + continue + } + + if (character === '>' && inAngleBrackets) { + inAngleBrackets = false + continue + } + + if (character === ')' && !inAngleBrackets) { + return index + } + } + + return -1 +} + +function normalizeMarkdownDestination(destination: string): string { + let normalized = destination.trim() + + if (normalized.startsWith('<') && normalized.endsWith('>')) { + normalized = normalized.slice(1, -1).trim() + } + + const lineSuffixMatch = normalized.match(/^(.*\.(?:html?|md|markdown|png|jpe?g|gif|webp|svg)):\d+$/i) + const lineSuffixedPath = lineSuffixMatch?.[1] + + if (lineSuffixedPath && !isExternalUrl(lineSuffixedPath)) { + normalized = lineSuffixedPath + } + + return trimTrailingPunctuation(normalized) +} + +function isInMarkdownLink(position: number, markdownLinks: MarkdownLinkMatch[]): boolean { + return markdownLinks.some((link) => position >= link.start && position < link.end) +} + +function isInCodeBlock(position: number, codeBlocks: FencedCodeBlock[]): boolean { + return codeBlocks.some((block) => position >= block.start && position < block.end) +} + +function extractFencedCodeBlocks(content: string): FencedCodeBlock[] { + const blocks: FencedCodeBlock[] = [] + const lines = content.match(/[^\n]*\n|[^\n]+$/g) ?? [] + let offset = 0 + let active: + | { marker: '```' | '~~~'; start: number; contentStart: number } + | null = null + + for (const line of lines) { + const trimmed = line.trimStart() + + if (!active && (trimmed.startsWith('```') || trimmed.startsWith('~~~'))) { + active = { + marker: trimmed.startsWith('```') ? '```' : '~~~', + start: offset, + contentStart: offset + line.length, + } + offset += line.length + continue + } + + if (active && trimmed.startsWith(active.marker)) { + blocks.push({ + start: active.start, + end: offset + line.length, + contentStart: active.contentStart, + text: content.slice(active.contentStart, offset), + }) + active = null + } + + offset += line.length + } + + return blocks +} + +function extractDirectoryTreeFileMatches(codeBlocks: FencedCodeBlock[]): DirectoryTreeFileMatch[] { + const matches: DirectoryTreeFileMatch[] = [] + + for (const block of codeBlocks) { + matches.push(...extractDirectoryTreeFileMatchesFromBlock(block)) + } + + return matches +} + +function extractDirectoryTreeFileMatchesFromBlock(block: FencedCodeBlock): DirectoryTreeFileMatch[] { + const matches: DirectoryTreeFileMatch[] = [] + const lines = block.text.match(/[^\n]*\n|[^\n]+$/g) ?? [] + const directoryStack: string[] = [] + let rootPath: string | null = null + let offset = 0 + + for (const line of lines) { + const trimmed = line.trim() + + if (!rootPath) { + const rootMatch = trimmed.match(/^((?:\/|[A-Za-z]:\/).*[\\/])$/) + if (rootMatch) { + rootPath = normalizeFilePath(rootMatch[1] ?? '') + } + offset += line.length + continue + } + + const entryMatch = line.match(/^(\s*)(?:[│|]\s*)*[├└]──\s+(.+?)\s*$/u) + if (!entryMatch) { + offset += line.length + continue + } + + const rawPrefix = entryMatch[1] ?? '' + const level = Math.floor(rawPrefix.replace(/[│|]/g, ' ').length / 4) + const entryName = stripTreeEntryComment(entryMatch[2] ?? '') + + if (!entryName || entryName === '.' || entryName === '..') { + offset += line.length + continue + } + + if (entryName.endsWith('/')) { + directoryStack[level] = entryName.replace(/\/+$/g, '') + directoryStack.length = level + 1 + offset += line.length + continue + } + + const relativeSegments = [...directoryStack.slice(0, level), entryName] + const href = resolveFilePath(relativeSegments.join('/'), rootPath) + if (classifyFileTarget(href)) { + matches.push({ + href, + position: block.contentStart + offset, + }) + } + + offset += line.length + } + + return matches +} + +function stripTreeEntryComment(value: string): string { + return value.replace(/\s+#.*$/g, '').trim() +} + +function createFileKey(target: FileTargetMatch): string { + return `file:${target.kind}:${target.normalizedPath}` +} + +function createLocalhostKey(href: string): string { + return `url:${href}` +} + +function createId(kind: AssistantOutputTargetKind, value: string): string { + return `${kind}:${value}` +} + +function isAbsoluteFilePath(value: string): boolean { + return value.startsWith('/') || /^[A-Za-z]:\//.test(toPosixPath(value)) +} + +function normalizeFilePath(value: string): string { + const slashed = toPosixPath(value) + const { prefix, segments } = splitFilePath(slashed) + const normalizedSegments: string[] = [] + + for (const segment of segments) { + if (!segment || segment === '.') { + continue + } + + if (segment === '..') { + const previous = normalizedSegments[normalizedSegments.length - 1] + + if (previous && previous !== '..') { + normalizedSegments.pop() + } else if (!prefix) { + normalizedSegments.push('..') + } + + continue + } + + normalizedSegments.push(segment) + } + + if (!prefix && normalizedSegments.length === 0) { + return '.' + } + + if (normalizedSegments.length === 0) { + return prefix + } + + return `${prefix}${normalizedSegments.join('/')}` +} + +function resolveFilePath(value: string, baseDir = '/'): string { + if (isAbsoluteFilePath(value)) { + return normalizeFilePath(value) + } + + const normalizedBaseDir = normalizeFilePath(baseDir) + const separator = normalizedBaseDir === '/' || normalizedBaseDir.endsWith('/') ? '' : '/' + + return normalizeFilePath(`${normalizedBaseDir}${separator}${value}`) +} + +function relativeFilePath(from: string, to: string): string { + const fromParts = splitFilePath(normalizeFilePath(from)) + const toParts = splitFilePath(normalizeFilePath(to)) + + if (fromParts.prefix.toLowerCase() !== toParts.prefix.toLowerCase()) { + return normalizeFilePath(to) + } + + let sharedLength = 0 + + while ( + sharedLength < fromParts.segments.length && + sharedLength < toParts.segments.length && + arePathSegmentsEqual( + fromParts.segments[sharedLength], + toParts.segments[sharedLength], + fromParts.prefix, + ) + ) { + sharedLength += 1 + } + + const parentSegments = Array.from( + { length: fromParts.segments.length - sharedLength }, + () => '..', + ) + const childSegments = toParts.segments.slice(sharedLength) + + return [...parentSegments, ...childSegments].join('/') +} + +function getExtension(value: string): string { + const basename = getBasename(value) + const extensionIndex = basename.lastIndexOf('.') + + if (extensionIndex <= 0) { + return '' + } + + return basename.slice(extensionIndex) +} + +function getBasename(value: string): string { + const normalized = toPosixPath(value).replace(/\/+$/g, '') + const segments = normalized.split('/') + + return segments[segments.length - 1] || '' +} + +function splitFilePath(value: string): { prefix: string, segments: string[] } { + const slashed = toPosixPath(value) + + if (slashed.startsWith('/')) { + return { + prefix: '/', + segments: slashed.slice(1).split('/').filter(Boolean), + } + } + + const driveMatch = slashed.match(/^([A-Za-z]:)\/?(.*)$/) + + if (driveMatch) { + const drivePrefix = driveMatch[1] ?? '' + const driveSegments = driveMatch[2] ?? '' + + return { + prefix: `${drivePrefix}/`, + segments: driveSegments.split('/').filter(Boolean), + } + } + + return { + prefix: '', + segments: slashed.split('/').filter(Boolean), + } +} + +function arePathSegmentsEqual(left: string | undefined, right: string | undefined, prefix: string): boolean { + if (!left || !right) { + return left === right + } + + if (/^[A-Za-z]:\/$/.test(prefix)) { + return left.toLowerCase() === right.toLowerCase() + } + + return left === right +}