✨ feat(vscode): 侧栏的文件和文件夹可以问 AI

This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent fc403d5b20
commit 14450c90bc
6 files changed
+627 -6

No files matched your search

@@ -0,0 +1,415 @@
/**
* 侧栏文件行、文件夹行的「问 AI」:单独一个面板,不属于任何一个文件的预览。
* 先问问题,再在同一面板里把回答流出来;不写进某一块代码的问答记录。
*/
import type { WebviewPanel } from 'vscode'
import type { AskProfiles } from '../qa/askProfiles'
import type { PathAskSubject, PathChild } from '../qa/pathAskPrompt'
import type { ExcludeSetting } from './fileEntries'
import type { FileNode } from './fileTree'
import * as path from 'node:path'
import { commands, Disposable, FileType, ViewColumn, window, workspace } from 'vscode'
import { ClaudeCancelledError } from '../../cc/spawnClaude'
import { makeNonce } from '../../webview/html'
import { askClaude, askFailure } from '../qa/claudeAsk'
import { readFileHead } from '../qa/pathAskContext'
import { pathAskPrompt } from '../qa/pathAskPrompt'
import { createExcludeMatcher, sortEntries } from './fileEntries'
export const ASK_PATH_COMMAND = 'superpowers.codeReader.askPath'
const VIEW_TYPE = 'superpowers.codeReader.pathAsk'
type PathTarget = Pick<FileNode, 'uri' | 'folder' | 'isDirectory'> & { relativePath: string }
interface ResetMessage { type: 'reset', path: string, kind: 'file' | 'folder' }
interface AskedMessage { type: 'asked', question: string }
interface StreamMessage { type: 'stream', text?: string, activity?: string }
interface EndMessage { type: 'end', status: 'done' | 'failed' | 'stopped', error?: string }
type ToPanel = ResetMessage | AskedMessage | StreamMessage | EndMessage
/** 打开或切到问路径的面板。点侧栏另一行时换到那一行,正在答的停掉。 */
export function registerPathAsk(profiles: AskProfiles): Disposable {
const panel = new PathAskPanel(profiles)
return Disposable.from(
commands.registerCommand(ASK_PATH_COMMAND, (node?: FileNode) => {
void panel.open(node)
}),
panel,
)
}
class PathAskPanel implements Disposable {
private panel: WebviewPanel | undefined
private whenReady = Promise.resolve()
private target: PathTarget | undefined
/** 换了路径就加一,旧的回答不再往面板上画 */
private generation = 0
private controller: AbortController | undefined
constructor(private readonly profiles: AskProfiles) {}
async open(node?: FileNode): Promise<void> {
if (!node || node.isRoot)
return
const gen = ++this.generation
this.controller?.abort()
this.controller = undefined
const relativePath = path.posix.relative(node.folder.uri.path, node.uri.path)
this.target = { uri: node.uri, folder: node.folder, isDirectory: node.isDirectory, relativePath }
const title = `问 AI · ${relativePath}`
if (!this.panel) {
this.create(title)
}
else {
this.panel.title = title
this.panel.reveal(ViewColumn.Active, false)
}
await this.whenReady
if (gen !== this.generation || !this.panel)
return
this.post({ type: 'reset', path: relativePath, kind: node.isDirectory ? 'folder' : 'file' })
}
dispose(): void {
this.generation++
this.controller?.abort()
this.controller = undefined
this.panel?.dispose()
this.panel = undefined
}
private create(title: string): void {
const panel = window.createWebviewPanel(VIEW_TYPE, title, { viewColumn: ViewColumn.Active, preserveFocus: false }, {
enableScripts: true,
retainContextWhenHidden: true,
})
this.panel = panel
let markReady: () => void = () => {}
this.whenReady = new Promise((resolve) => {
markReady = resolve
})
const nonce = makeNonce()
panel.webview.html = pathAskHtml(nonce)
panel.webview.onDidReceiveMessage((msg: unknown) => {
if (!isRecord(msg) || typeof msg.type !== 'string')
return
if (msg.type === 'ready')
markReady()
else if (msg.type === 'ask' && typeof msg.question === 'string')
void this.submit(msg.question)
else if (msg.type === 'stop')
this.controller?.abort()
})
panel.onDidDispose(() => {
if (this.panel !== panel)
return
this.panel = undefined
this.generation++
this.controller?.abort()
this.controller = undefined
})
}
/** 空问题不发。回答过程里的字和「正在读哪个文件」直接画在这个面板上。 */
private async submit(question: string): Promise<void> {
const text = question.trim()
const target = this.target
if (!text || !target || this.controller)
return
const gen = this.generation
const controller = new AbortController()
this.controller = controller
this.post({ type: 'asked', question: text })
try {
const described = await describePath(target)
if (gen !== this.generation)
return
const profile = await this.profiles.current()
if (gen !== this.generation)
return
const prompt = pathAskPrompt(described, text)
await askClaude({
prompt,
images: [],
...(profile && { profilePath: profile.path }),
cwd: target.folder.uri.fsPath,
signal: controller.signal,
onEvent: (event) => {
if (gen !== this.generation)
return
if (event.kind === 'text')
this.post({ type: 'stream', text: event.text })
else if (event.kind === 'activity')
this.post({ type: 'stream', activity: event.label })
},
})
if (gen !== this.generation)
return
this.post({ type: 'end', status: 'done' })
}
catch (err) {
if (gen !== this.generation)
return
if (err instanceof ClaudeCancelledError)
this.post({ type: 'end', status: 'stopped' })
else
this.post({ type: 'end', status: 'failed', error: askFailure(err) })
}
finally {
if (this.controller === controller)
this.controller = undefined
}
}
private post(message: ToPanel): void {
void this.panel?.webview.postMessage(message).then(undefined, () => {})
}
}
/** 文件带开头,文件夹带这一层(和侧栏同一套排除、同一排序)。 */
async function describePath(target: PathTarget): Promise<PathAskSubject> {
if (target.isDirectory)
return { kind: 'folder', relativePath: target.relativePath, children: await folderChildren(target) }
return { kind: 'file', relativePath: target.relativePath, head: await readFileHead(target.uri.fsPath) }
}
async function folderChildren(target: PathTarget): Promise<PathChild[] | { unavailable: string }> {
let entries: [string, FileType][]
try {
entries = await workspace.fs.readDirectory(target.uri)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { unavailable: `列不出来(${message})。` }
}
const excluded = createExcludeMatcher(workspace.getConfiguration('files', target.folder).get<ExcludeSetting>('exclude'))
const names = new Set(entries.map(([name]) => name))
const dir = target.relativePath
return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 })))
.filter(entry => !excluded(dir ? `${dir}/${entry.name}` : entry.name, names))
.map(entry => ({ name: entry.name, kind: entry.isDirectory ? 'folder' : 'file' }))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** 面板里的一页:暗色遮罩铺满,中间一张卡片。问、流式回答、停止都在这张卡片上。 */
function pathAskHtml(nonce: string): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问 AI</title>
<style>
html, body { height: 100%; margin: 0; }
body {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
color: var(--vscode-foreground);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
.card {
display: flex;
flex-direction: column;
width: min(40rem, calc(100% - 2rem));
max-height: calc(100% - 2rem);
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
border: 1px solid var(--vscode-panel-border, var(--vscode-widget-border, transparent));
border-radius: 8px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
header { padding: 14px 16px 0; }
.kind { opacity: 0.7; font-size: 12px; }
h1 {
margin: 2px 0 0;
font-size: 13px;
font-weight: 600;
word-break: break-all;
}
.thread { overflow: auto; padding: 12px 16px; min-height: 0; }
.question { margin: 0 0 8px; font-weight: 600; white-space: pre-wrap; word-break: break-word; }
.answer {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--vscode-editor-font-family, var(--vscode-font-family));
}
.status { margin: 8px 0 0; opacity: 0.75; }
.error { margin: 8px 0 0; color: var(--vscode-errorForeground); white-space: pre-wrap; }
form { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px 16px; }
textarea {
resize: vertical;
min-height: 4.5rem;
padding: 8px;
border-radius: 4px;
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font: inherit;
}
.actions { display: flex; justify-content: flex-end; gap: 8px; }
button {
border: none;
border-radius: 4px;
padding: 4px 12px;
cursor: pointer;
font: inherit;
}
button.primary { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
button.primary:hover { background: var(--vscode-button-hoverBackground); }
button.secondary { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); }
button:disabled { opacity: 0.45; cursor: default; }
</style>
</head>
<body>
<div class="card" role="dialog" aria-labelledby="path">
<header>
<div class="kind" id="kind"></div>
<h1 id="path"></h1>
</header>
<div class="thread" id="thread" hidden>
<p class="question" id="q"></p>
<pre class="answer" id="answer" hidden></pre>
<p class="status" id="status" hidden></p>
<p class="error" id="error" hidden></p>
</div>
<form id="form">
<textarea id="question" rows="3" placeholder="想问什么,比如这个名字是什么意思"></textarea>
<div class="actions">
<button type="button" class="secondary" id="stop" hidden>停止</button>
<button type="submit" class="primary" id="send" disabled>发送</button>
</div>
</form>
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi()
const pathEl = document.getElementById('path')
const kindEl = document.getElementById('kind')
const thread = document.getElementById('thread')
const questionEl = document.getElementById('q')
const answerEl = document.getElementById('answer')
const statusEl = document.getElementById('status')
const errorEl = document.getElementById('error')
const form = document.getElementById('form')
const input = document.getElementById('question')
const sendBtn = document.getElementById('send')
const stopBtn = document.getElementById('stop')
let running = false
let text = ''
let activity = ''
let frame
const paint = () => {
frame = undefined
answerEl.textContent = text
answerEl.hidden = text === ''
if (!running)
return
statusEl.hidden = false
statusEl.textContent = activity || (text === '' ? '正在想…' : '正在回答…')
}
const setRunning = (on) => {
running = on
input.disabled = on
stopBtn.hidden = !on
if (!on)
statusEl.hidden = true
sendBtn.disabled = on || input.value.trim() === ''
}
window.addEventListener('message', (event) => {
const msg = event.data
if (!msg || typeof msg.type !== 'string')
return
if (msg.type === 'reset') {
clearTimeout(frame)
text = ''
activity = ''
pathEl.textContent = typeof msg.path === 'string' ? msg.path : ''
kindEl.textContent = msg.kind === 'folder' ? '文件夹' : '文件'
questionEl.textContent = ''
answerEl.textContent = ''
answerEl.hidden = true
errorEl.hidden = true
errorEl.textContent = ''
thread.hidden = true
input.value = ''
setRunning(false)
input.focus()
return
}
if (msg.type === 'asked') {
thread.hidden = false
questionEl.textContent = typeof msg.question === 'string' ? msg.question : ''
text = ''
activity = ''
answerEl.textContent = ''
answerEl.hidden = true
errorEl.hidden = true
errorEl.textContent = ''
setRunning(true)
paint()
return
}
if (msg.type === 'stream') {
if (typeof msg.text === 'string') {
text = msg.text
activity = ''
}
if (typeof msg.activity === 'string')
activity = msg.activity
frame ??= setTimeout(paint, 80)
return
}
if (msg.type === 'end') {
clearTimeout(frame)
paint()
setRunning(false)
if (msg.status === 'failed') {
errorEl.hidden = false
errorEl.textContent = typeof msg.error === 'string' && msg.error ? msg.error : '没答成。'
}
else if (msg.status === 'stopped') {
errorEl.hidden = false
errorEl.textContent = '已停止。'
}
}
})
const send = () => {
const question = input.value.trim()
if (!question || running)
return
vscode.postMessage({ type: 'ask', question })
}
form.addEventListener('submit', (event) => {
event.preventDefault()
send()
})
input.addEventListener('input', () => {
sendBtn.disabled = running || input.value.trim() === ''
})
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault()
send()
}
})
stopBtn.addEventListener('click', () => vscode.postMessage({ type: 'stop' }))
vscode.postMessage({ type: 'ready' })
</script>
</body>
</html>`
}
@@ -27,6 +27,7 @@ const root = entryContext({ isDirectory: true, isRoot: true })
/** 「未提交的更改」里「更改」组的文件行 */
const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER)
const STAGE = 'superpowers.codeReader.stage'
const ASK = 'superpowers.codeReader.askPath'
const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue
const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0)
@@ -56,17 +57,17 @@ const FOLDER_COPIES = [
]
describe('侧栏行尾按钮', () => {
it('先「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => {
it('先「问 AI」再「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => {
for (const context of [file, folder]) {
expect(itemsFor('view/item/context', 'inline', context)).toEqual([COPY_MENU, ADD_NOTE])
expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([COPY_MENU, EDIT_NOTE])
expect(itemsFor('view/item/context', 'inline', context)).toEqual([ASK, COPY_MENU, ADD_NOTE])
expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([ASK, COPY_MENU, EDIT_NOTE])
}
expect(itemsFor('view/item/context', 'inline', root)).toEqual([])
})
it('「更改」组的文件行多一个「暂存」,排在最后;别的行都没有', () => {
expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([COPY_MENU, ADD_NOTE, STAGE])
expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([COPY_MENU, EDIT_NOTE, STAGE])
expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([ASK, COPY_MENU, ADD_NOTE, STAGE])
expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([ASK, COPY_MENU, EDIT_NOTE, STAGE])
for (const context of [file, folder, root, noted(file), noted(folder)])
expect(itemsFor('view/item/context', 'inline', context)).not.toContain(STAGE)
})
@@ -82,6 +83,21 @@ describe('侧栏行尾按钮', () => {
expect(commands.find(item => item.command === ADD_NOTE)?.icon).toBe('$(note)')
expect(commands.find(item => item.command === EDIT_NOTE)?.icon).toBe('$(edit)')
})
it('「问 AI」在复制左边,文件和文件夹(含未提交、有注释)都有,审查时也不藏,命令面板里没有', () => {
const ask = menus['view/item/context'].filter(item => item.command === ASK)
expect(ask).toEqual([{
command: ASK,
when: 'view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/',
group: 'inline@0',
}])
expect(ask[0].when.includes('filter')).toBe(false)
for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)])
expect(itemsFor('view/item/context', 'inline', context)[0]).toBe(ASK)
expect(itemsFor('view/item/context', 'inline', root)).not.toContain(ASK)
expect(commands.find(item => item.command === ASK)).toMatchObject({ title: '问 AI', icon: '$(sparkle)' })
expect(menus.commandPalette.find(item => item.command === ASK)?.when).toBe('false')
})
})
describe('复制菜单', () => {
+3 -1
View File
@@ -13,6 +13,7 @@ import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor
import { showPreview, showSource } from './editor/switchEditor'
import { registerCopyCommands } from './explorer/copyCommands'
import { FileTreeProvider } from './explorer/fileTree'
import { registerPathAsk } from './explorer/pathAskPanel'
import { TreeFilters } from './explorer/treeFilters'
import { createBashAdapter } from './languages/bash'
import { createDockerfileAdapter } from './languages/dockerfile'
@@ -67,7 +68,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
}
/**
* 接入代码阅读:注册表、侧栏文件树(复制、文件注释、筛选:按审查工单只看 PR 改过的文件、只看未提交的更改)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。
* 接入代码阅读:注册表、侧栏文件树(问路径、复制、文件注释、筛选:按审查工单只看 PR 改过的文件、只看未提交的更改)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。
* 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。
*/
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
@@ -101,6 +102,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
indexes,
registerNoteCommands(notes),
registerCopyCommands(),
registerPathAsk(qa.profiles),
registry.onDidChange(() => {
void commands.executeCommand('setContext', LANGUAGE_IDS_CONTEXT, registry.languageIds())
previews.refresh()
@@ -0,0 +1,51 @@
/**
* 问路径时带上的上下文:文件只读开头若干行,到字节上限为止,避免整份大文件读进内存。
*/
import type { PathHead } from './pathAskPrompt'
import { Buffer } from 'node:buffer'
import { open } from 'node:fs/promises'
import { PATH_ASK_HEAD_LINES } from './pathAskPrompt'
/** 开头最多读这么多字节;一行没写完的尾巴丢掉。 */
const HEAD_BYTES = 64 * 1024
/** 文件开头最多 maxLines 行。二进制或读失败时不附内容,只留原因。 */
export async function readFileHead(fsPath: string, maxLines = PATH_ASK_HEAD_LINES): Promise<PathHead> {
let handle: Awaited<ReturnType<typeof open>> | undefined
try {
handle = await open(fsPath, 'r')
const buf = Buffer.alloc(HEAD_BYTES)
const { bytesRead } = await handle.read(buf, 0, HEAD_BYTES, 0)
if (bytesRead === 0)
return { lines: [], truncated: false }
const chunk = buf.subarray(0, bytesRead)
if (chunk.includes(0))
return { unavailable: '这是二进制文件,没有附上内容。' }
const more = (await handle.stat()).size > bytesRead
return headLines(chunk.toString('utf8'), more, maxLines)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { unavailable: `开头读不了(${message})。` }
}
finally {
await handle?.close()
}
}
/**
* 从已经读到的开头文本里取出最多 maxLines 行。
* more 表示文件在这段后面还有:这段不以换行结束时,最后一行是被截断的,丢掉。
*/
export function headLines(text: string, more: boolean, maxLines: number): PathHead {
if (text === '')
return { lines: [], truncated: more }
let lines = text.split(/\r?\n/)
if (text.endsWith('\n'))
lines = lines.slice(0, -1)
else if (more)
lines = lines.slice(0, -1)
const truncated = more || lines.length > maxLines
return { lines: lines.slice(0, maxLines), truncated }
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { headLines } from './pathAskContext'
import { PATH_ASK_HEAD_LINES, pathAskPrompt } from './pathAskPrompt'
const FILE = 'app/[[...section]]/page.tsx'
const FOLDER = 'app/[[...section]]'
describe('问路径时发给模型的话', () => {
it('文件:写明是文件,带相对路径和开头', () => {
const prompt = pathAskPrompt({
kind: 'file',
relativePath: FILE,
head: { lines: ['export default function Page() {', ' return null', '}'], truncated: false },
}, '这个名字是什么意思')
expect(prompt).toContain(`这是一个文件,相对路径:${FILE}`)
expect(prompt).toContain('1 | export default function Page() {')
expect(prompt).toContain('3 | }')
expect(prompt).toContain('问题:这个名字是什么意思')
expect(prompt).toContain('只读,不要改任何东西')
expect(prompt).not.toContain('剩下的需要时自己读')
})
it('文件开头超过上限时说明只贴了开头,相对路径仍在', () => {
const lines = Array.from({ length: PATH_ASK_HEAD_LINES }, (_, index) => `line ${index}`)
const prompt = pathAskPrompt({
kind: 'file',
relativePath: FILE,
head: { lines, truncated: true },
}, '?')
expect(prompt).toContain(`这是一个文件,相对路径:${FILE}`)
expect(prompt).toContain(`上面只贴了开头 ${PATH_ASK_HEAD_LINES} 行`)
expect(prompt).toContain(`80 | line ${PATH_ASK_HEAD_LINES - 1}`)
expect(prompt).not.toContain('line 80')
})
it('文件夹:写明是文件夹,带相对路径,只列这一层的名字', () => {
const prompt = pathAskPrompt({
kind: 'folder',
relativePath: FOLDER,
children: [
{ name: 'components', kind: 'folder' },
{ name: 'page.tsx', kind: 'file' },
],
}, '[[...section]] 是啥意思')
expect(prompt).toContain(`这是一个文件夹,相对路径:${FOLDER}`)
expect(prompt).toContain('- 文件夹 components\n- 文件 page.tsx')
expect(prompt).toContain('只列了这一层的名字')
expect(prompt).toContain('问题:[[...section]] 是啥意思')
expect(prompt).not.toContain('export default')
})
})
describe('文件开头截取', () => {
it('超过行数上限时只留前 80 行,并标明没贴完', () => {
const text = Array.from({ length: 100 }, (_, index) => `L${index}`).join('\n')
expect(headLines(text, false, PATH_ASK_HEAD_LINES)).toEqual({
lines: Array.from({ length: PATH_ASK_HEAD_LINES }, (_, index) => `L${index}`),
truncated: true,
})
})
it('字节没读完时丢掉被截断的最后一行', () => {
expect(headLines('alpha\nbeta-cut', true, PATH_ASK_HEAD_LINES)).toEqual({
lines: ['alpha'],
truncated: true,
})
})
it('空文件没有行', () => {
expect(headLines('', false, PATH_ASK_HEAD_LINES)).toEqual({ lines: [], truncated: false })
})
})
+65
View File
@@ -0,0 +1,65 @@
/**
* 侧栏问一个路径时发给模型的话:写明是文件还是文件夹、相对工作区的路径。
* 文件附上开头若干行;文件夹只列这一层的名字,不把里面的代码贴进去。
*/
/** 文件开头最多贴这么多行,剩下的让模型自己去读。 */
export const PATH_ASK_HEAD_LINES = 80
export interface PathChild {
name: string
kind: 'file' | 'folder'
}
/** 文件开头:贴得下的行,或者为什么没附上。 */
export type PathHead
= | { lines: string[], truncated: boolean }
| { unavailable: string }
export interface PathAskFile {
kind: 'file'
/** 相对所在工作区文件夹的路径 */
relativePath: string
head: PathHead
}
export interface PathAskFolder {
kind: 'folder'
/** 相对所在工作区文件夹的路径 */
relativePath: string
/** 这一层的子文件和子文件夹,调用方排好序;列不出来时是原因 */
children: PathChild[] | { unavailable: string }
}
export type PathAskSubject = PathAskFile | PathAskFolder
const LEAD = '我在看项目里的一个路径,想问它。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的文件,但只读,不要改任何东西。提到文件时写相对项目根目录的路径。'
/** 一次提问的全文。不写进某一块代码的问答记录。 */
export function pathAskPrompt(subject: PathAskSubject, question: string): string {
const where = subject.kind === 'file'
? `这是一个文件,相对路径:${subject.relativePath}`
: `这是一个文件夹,相对路径:${subject.relativePath}`
const detail = subject.kind === 'file' ? fileHead(subject.head) : folderList(subject.children)
return [LEAD, where, detail, `问题:${question}`].join('\n\n')
}
function fileHead(head: PathHead): string {
if ('unavailable' in head)
return `文件开头没附上:${head.unavailable}`
if (head.lines.length === 0)
return head.truncated ? '文件开头一行都没读全,需要时自己读这个文件。' : '文件是空的。'
const width = String(head.lines.length).length
const body = head.lines.map((line, index) => `${String(index + 1).padStart(width)} | ${line}`).join('\n')
const rest = head.truncated ? `\n(上面只贴了开头 ${head.lines.length} 行,剩下的需要时自己读这个文件)` : ''
return `文件开头:\n${body}${rest}`
}
function folderList(children: PathAskFolder['children']): string {
if (!Array.isArray(children))
return `这一层没列出来:${children.unavailable}`
if (children.length === 0)
return '里面这一层是空的。'
const lines = children.map(child => `- ${child.kind === 'folder' ? '文件夹' : '文件'} ${child.name}`)
return `里面这一层有:\n${lines.join('\n')}\n只列了这一层的名字,没有把里面的代码贴进来。侧栏里不显示的名字这里也没列。需要时自己去读这些文件。`
}