✨ feat(vscode): 问 AI 收起再展开、重启后接着这块最近的会话问;代码改了对不上的问答不再显示
This commit is contained in:
1 parent
cbe447ade8
commit
f0a3ad80e1
16 files changed
+82
-211
No files matched your search
@@ -4,7 +4,6 @@
|
||||
|
||||
import type { FileAttribute } from '../attributes'
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { AnchorStep } from '../qa/anchor'
|
||||
import type { RunningAsk } from '../qa/asks'
|
||||
import type { Excluded } from './blockFilter'
|
||||
|
||||
@@ -54,10 +53,9 @@ export interface QaEntryView {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 挂在一块上的问答。where 是记号拆回的各层(种类和名字),块找不到了时写它原来在哪。 */
|
||||
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示。 */
|
||||
export interface QaThreadView {
|
||||
anchor: string
|
||||
where: AnchorStep[]
|
||||
entries: QaEntryView[]
|
||||
}
|
||||
|
||||
@@ -79,7 +77,7 @@ export interface QaMessage {
|
||||
* filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览
|
||||
* qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
* answerEnd:askId 那个问题答完了:done 带最终的回答、存下的那条的 id 和会话 id(追问接着这个会话),failed 带给人看的原因,stopped 是被叫停
|
||||
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
@@ -89,7 +87,7 @@ export type ExtensionToWebview
|
||||
| { type: 'filter', excluded: Excluded }
|
||||
| QaMessage
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string, sessionId?: string }
|
||||
| { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
|
||||
@@ -99,7 +97,7 @@ export type ExtensionToWebview
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
|
||||
* setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览
|
||||
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;resume 是追问时上一次的会话 id
|
||||
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问
|
||||
* stopAsk:叫停 askId 那个问题,这次不存
|
||||
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
|
||||
*/
|
||||
@@ -112,6 +110,6 @@ export type WebviewToExtension
|
||||
| { type: 'probeDefinition', id: number, version: number, offset: number }
|
||||
| { type: 'goToDefinition', version: number, offset: number }
|
||||
| { type: 'findOccurrences', id: number, version: number, offset: number }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string, resume?: string }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string }
|
||||
| { type: 'stopAsk', askId: string }
|
||||
| { type: 'deleteQa', anchor: string, entryId: string }
|
||||
@@ -157,7 +157,7 @@ class Preview {
|
||||
askId: event.ask.askId,
|
||||
status: end.status,
|
||||
...(end.status === 'failed' && { error: end.error }),
|
||||
...(end.status === 'done' && { text: end.answer.text, entryId, sessionId: end.answer.sessionId }),
|
||||
...(end.status === 'done' && { text: end.answer.text, entryId }),
|
||||
})
|
||||
this.postQa()
|
||||
}),
|
||||
@@ -219,17 +219,17 @@ class Preview {
|
||||
}
|
||||
|
||||
/**
|
||||
* 问 anchor 那块代码:按最近一次发出去的块树找到它、带上代码上下文交给 CodeQa;resume 是追问时上一次的会话 id。
|
||||
* 问 anchor 那块代码:按最近一次发出去的块树找到它,把完整问题和追问的话都交给 CodeQa,接不接着上次的会话由它定。
|
||||
* 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。
|
||||
*/
|
||||
private ask(askId: string, anchor: string, question: string, resume: string | undefined): void {
|
||||
private ask(askId: string, anchor: string, question: string): void {
|
||||
const at = locate(this.document.uri)
|
||||
const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, ...this.sent, anchor }
|
||||
const prompt = subject && askPrompt(subject, question)
|
||||
const followUp = subject && resume ? followUpPrompt(subject, question) : undefined
|
||||
const refused = prompt === undefined
|
||||
const followUp = subject && followUpPrompt(subject, question)
|
||||
const refused = prompt === undefined || followUp === undefined
|
||||
? '这块代码刚改过,找不到了。等预览刷新后重新点「问 AI」再问。'
|
||||
: this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, ...(resume && followUp && { followUp: { sessionId: resume, prompt: followUp } }) })
|
||||
: this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, followUp })
|
||||
if (refused !== undefined) {
|
||||
this.post({ type: 'answerEnd', askId, status: 'failed', error: refused })
|
||||
return
|
||||
@@ -252,7 +252,7 @@ class Preview {
|
||||
await this.push()
|
||||
return
|
||||
case 'ask':
|
||||
this.ask(msg.askId, msg.anchor, msg.question, msg.resume)
|
||||
this.ask(msg.askId, msg.anchor, msg.question)
|
||||
return
|
||||
case 'stopAsk':
|
||||
this.owner.qa.stop(msg.askId)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { toBlockViews } from '../blockView'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { anchorSteps, BlockAnchors } from './anchor'
|
||||
import { BlockAnchors } from './anchor'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
|
||||
@@ -83,15 +83,6 @@ describe('块的位置记号', () => {
|
||||
expect(anchorOf(after, 'def helper(x, y=1):')).toBe(anchorOf(before, 'def helper(x):'))
|
||||
})
|
||||
|
||||
it('拆回各层:没名字的一层只有种类,编号去掉', () => {
|
||||
expect(anchorSteps('class ReferralRelation > function display[1] > if @3fa1c2d0[2]')).toEqual([
|
||||
{ kind: 'class', name: 'ReferralRelation' },
|
||||
{ kind: 'function', name: 'display' },
|
||||
{ kind: 'if' },
|
||||
])
|
||||
expect(anchorSteps('variable { a, b }')).toEqual([{ kind: 'variable', name: '{ a, b }' }])
|
||||
})
|
||||
|
||||
it('名字里的空白压成一个空格,空名字按没名字算', () => {
|
||||
const anchors = new BlockAnchors()
|
||||
expect(anchors.place('', 'variable', '{\n a,\n b\n}', '').anchor).toBe('variable { a, b }')
|
||||
|
||||
@@ -40,20 +40,3 @@ export class BlockAnchors {
|
||||
return { anchor, inner: label === '' ? scope : anchor }
|
||||
}
|
||||
}
|
||||
|
||||
/** 记号里的一层:种类和名字(没名字的块只有种类),给「找不到原来位置的问答」写它原来在哪。 */
|
||||
export interface AnchorStep {
|
||||
kind: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** 把记号拆回各层;拆不出种类的一层丢掉。 */
|
||||
export function anchorSteps(anchor: string): AnchorStep[] {
|
||||
return anchor.split(SEPARATOR).flatMap((segment) => {
|
||||
const match = /^(\S+) (.*?)(?:\[\d+\])?$/.exec(segment)
|
||||
if (!match)
|
||||
return []
|
||||
const [, kind, rest] = match
|
||||
return rest.startsWith(HASH_MARK) || rest === '' ? [{ kind }] : [{ kind, name: rest }]
|
||||
})
|
||||
}
|
||||
@@ -6,22 +6,22 @@ import type { QaEntry } from './qaMap'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { EventEmitter } from 'vscode'
|
||||
import { FolderStore, locate } from '../folderStore'
|
||||
import { anchorSteps } from './anchor'
|
||||
import { QaAsks } from './asks'
|
||||
import { askClaude } from './claudeAsk'
|
||||
import { QA_FILE, withEntry, withoutEntry } from './qaMap'
|
||||
import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap'
|
||||
|
||||
/** 正在答的问题有了进展(update),或答完了(end;答成了带存下的那条的 id)。owner 是问的那份文件的 uri。 */
|
||||
export type AskEvent
|
||||
= | { owner: string, kind: 'update', ask: RunningAsk }
|
||||
| { owner: string, kind: 'end', ask: RunningAsk, end: AskEnd, entryId?: string }
|
||||
|
||||
/** prompt 是带代码上下文的完整问题;followUp 是接着这块上一次的会话问时发的话(只有问题和是哪块) */
|
||||
export interface AskRequest {
|
||||
askId: string
|
||||
anchor: string
|
||||
question: string
|
||||
prompt: string
|
||||
followUp?: { sessionId: string, prompt: string }
|
||||
followUp: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,21 +49,25 @@ export class CodeQa implements Disposable {
|
||||
available: at !== undefined,
|
||||
threads: threads.map(({ anchor, entries }) => ({
|
||||
anchor,
|
||||
where: anchorSteps(anchor),
|
||||
entries: entries.map(({ id, question, answer, createdAt }) => ({ id, question, answer, createdAt })),
|
||||
})),
|
||||
running: this.asks.runningFor(uri.toString()),
|
||||
}
|
||||
}
|
||||
|
||||
/** 开始问;问不了时返回原因(给人看的话)。在工作区文件夹里跑 claude,它读得到整个项目。 */
|
||||
/**
|
||||
* 开始问;问不了时返回原因(给人看的话)。在工作区文件夹里跑 claude,它读得到整个项目。
|
||||
* 这块问过的就接着最近一次的会话问(收起再展开、重启 VS Code 都一样),没问过就带上代码上下文开新会话。
|
||||
*/
|
||||
ask(uri: Uri, request: AskRequest): string | undefined {
|
||||
const at = locate(uri)
|
||||
if (!at)
|
||||
return '这个文件不在工作区文件夹里,问答没地方存。'
|
||||
const owner = uri.toString()
|
||||
const { askId, anchor, question, prompt, followUp } = request
|
||||
return this.asks.start({ owner, askId, anchor, question, run: { prompt, followUp, cwd: at.folder.uri.fsPath } }, {
|
||||
const sessionId = latestSession(this.store.get(at.folder, at.key), anchor)
|
||||
const run = { prompt, ...(sessionId && { followUp: { sessionId, prompt: followUp } }), cwd: at.folder.uri.fsPath }
|
||||
return this.asks.start({ owner, askId, anchor, question, run }, {
|
||||
update: ask => this.answered.fire({ owner, kind: 'update', ask }),
|
||||
end: async (ask, end) => {
|
||||
if (end.status !== 'done') {
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { dropEntries, moveEntries, readPathMap, writePathMap } from '../pathMap'
|
||||
import { QA_FILE, withEntry, withoutEntry } from './qaMap'
|
||||
import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap'
|
||||
|
||||
function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry {
|
||||
return { id, question, answer: `答 ${id}`, createdAt, sessionId: `s-${id}` }
|
||||
@@ -67,6 +67,24 @@ describe('记一次、删一次', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('再问时接着哪个会话', () => {
|
||||
it('接着这块最近一次问答的会话;别的块、没问过的块、没记会话 id 的都重新开', () => {
|
||||
let map: QaMap = {}
|
||||
map = withEntry(map, 'a.py', IF, entry('new', '2026-09-26T10:00:00.000Z'))
|
||||
map = withEntry(map, 'a.py', IF, entry('old', '2026-09-25T10:00:00.000Z'))
|
||||
map = withEntry(map, 'a.py', DISPLAY, { ...entry('bare', '2026-09-26T11:00:00.000Z'), sessionId: '' })
|
||||
expect(latestSession(map['a.py'], IF)).toBe('s-new')
|
||||
expect(latestSession(map['a.py'], DISPLAY)).toBeUndefined()
|
||||
expect(latestSession(map['a.py'], 'function gone')).toBeUndefined()
|
||||
expect(latestSession(undefined, IF)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('删掉最近那次,就接着剩下里最近的', () => {
|
||||
const map = withEntry(withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z'))
|
||||
expect(latestSession(withoutEntry(map, 'a.py', IF, 'b')['a.py'], IF)).toBe('s-a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('读写 .spx/code-qa.json', () => {
|
||||
it('文件不存在、解析失败、不是对象都当没有', async () => {
|
||||
expect(await readPathMap(root, QA_FILE)).toEqual({})
|
||||
|
||||
@@ -88,3 +88,8 @@ export function withoutEntry(map: QaMap, key: string, anchor: string, entryId: s
|
||||
delete next[key]
|
||||
return threads.length > 0 ? { ...next, [key]: threads } : next
|
||||
}
|
||||
|
||||
/** 再问 anchor 那块时接着的会话:这块最近一次问答的会话 id;没问过、或那次没记会话 id 时没有,重新开会话。 */
|
||||
export function latestSession(threads: readonly QaThread[] | undefined, anchor: string): string | undefined {
|
||||
return threads?.find(thread => thread.anchor === anchor)?.entries.at(-1)?.sessionId || undefined
|
||||
}
|
||||
@@ -4,12 +4,10 @@ import type { DocumentMessage, Excluded, Reveal } from './types'
|
||||
import type { ViewState } from './viewState'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { OrphanThreads } from './AskThread'
|
||||
import { BlockLevel } from './BlockTree'
|
||||
import { coveredByEditing, startEditing } from './editing'
|
||||
import { applyFilter, keptWhileEditing } from './filter'
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { orphanThreads } from './qa'
|
||||
import { useDefinitionLinks } from './useDefinitionLinks'
|
||||
import { HighlightContext, useOccurrences } from './useOccurrences'
|
||||
import { useQa } from './useQa'
|
||||
@@ -86,8 +84,6 @@ export function App(): ReactElement | null {
|
||||
const onFocused = useCallback(() => setFocusId(null), [])
|
||||
const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc])
|
||||
const kindName = useCallback((kind: string) => kindNames.get(kind) ?? kind, [kindNames])
|
||||
/** 找不到原来位置的问答;解析失败时一块都没有,不算 */
|
||||
const orphans = useMemo(() => doc && doc.error === undefined && qa.state.available ? orphanThreads(qa.state.threads, doc.blocks) : [], [doc, qa.state])
|
||||
const tree = useMemo((): BlockTreeState | null => doc && {
|
||||
version: doc.version,
|
||||
editing: new Set(view.editing),
|
||||
@@ -137,7 +133,6 @@ export function App(): ReactElement | null {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{orphans.length > 0 && <OrphanThreads threads={orphans} kindName={kindName} onDelete={qa.onDelete} />}
|
||||
{doc.blocks.length === 0
|
||||
? doc.error === undefined && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
|
||||
: doc.blocks.some(block => !filter.hidden.has(block.id))
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Ask } from './qa'
|
||||
import type { QaEntryView } from './types'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AskThread, OrphanThreads } from './AskThread'
|
||||
import { AskThread } from './AskThread'
|
||||
|
||||
const NOW = new Date(2026, 8, 26, 15, 0).getTime()
|
||||
|
||||
@@ -68,19 +68,3 @@ describe('块下面的问答区', () => {
|
||||
expect(text(render({ open: true, asks: [ask({})] }))).toContain('正在想…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('找不到原来位置的问答', () => {
|
||||
it('灰字一栏:写着原来在哪,问答照样能删', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OrphanThreads
|
||||
threads={[{ anchor: 'function display > if @1', where: [{ kind: 'function', name: 'display' }, { kind: 'if' }], entries: HISTORY }]}
|
||||
kindName={kind => ({ function: '函数', if: '条件' })[kind] ?? kind}
|
||||
now={NOW}
|
||||
onDelete={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(text(html)).toContain('代码改动后找不到原来位置的问答 · 2 条')
|
||||
expect(text(html)).toContain('原来在:函数 display › 条件')
|
||||
expect(html.match(/aria-label="已理解,删除"/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { KeyboardEvent, ReactElement } from 'react'
|
||||
import type { Ask } from './qa'
|
||||
import type { QaEntryView, QaThreadView } from './types'
|
||||
import type { QaEntryView } from './types'
|
||||
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { renderMarkdown, renderMarkdownOnce } from './markdown'
|
||||
import { answerPreview, relativeTime, whereLabel } from './qa'
|
||||
import { answerPreview, relativeTime } from './qa'
|
||||
|
||||
/** 问过的一次,灰字:问题、时间、「已理解,删除」,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
|
||||
function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number, onDelete: () => void }): ReactElement {
|
||||
@@ -170,29 +170,3 @@ export function AskThread({ history, asks, open, now = Date.now(), onAsk, onStop
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预览顶上的灰字一栏:代码改动后找不到原来位置的问答,写着原来在哪,照样能看、能删 */
|
||||
export function OrphanThreads({ threads, kindName, now = Date.now(), onDelete }: {
|
||||
threads: QaThreadView[]
|
||||
kindName: (kind: string) => string
|
||||
now?: number
|
||||
onDelete: (anchor: string, entryId: string) => void
|
||||
}): ReactElement {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const Chevron = expanded ? ChevronDown : ChevronRight
|
||||
const count = threads.reduce((sum, thread) => sum + thread.entries.length, 0)
|
||||
return (
|
||||
<section className="cr-qa-orphans" aria-label="代码改动后找不到原来位置的问答">
|
||||
<button type="button" className="cr-qa-summary" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>
|
||||
<Chevron size={12} strokeWidth={1.8} aria-hidden />
|
||||
{`代码改动后找不到原来位置的问答 · ${count} 条`}
|
||||
</button>
|
||||
{expanded && threads.map(thread => (
|
||||
<div key={thread.anchor} className="cr-qa">
|
||||
<div className="cr-qa-where">{`原来在:${whereLabel(thread.where, kindName)}`}</div>
|
||||
{thread.entries.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(thread.anchor, entry.id)} />)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -679,23 +679,6 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* 预览顶上:找不到原来位置的问答 */
|
||||
.cr-qa-orphans {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed color-mix(in srgb, var(--vscode-descriptionForeground) 40%, transparent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.cr-qa-orphans .cr-qa {
|
||||
--qa-accent: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.cr-qa-where {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cr-qa-spin,
|
||||
.cr-qa-answer[data-streaming] > :last-child::after {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { QaAction, QaState } from './qa'
|
||||
import type { BlockView, QaMessage, QaThreadView } from './types'
|
||||
import type { QaMessage, QaThreadView } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { answerPreview, asksOf, EMPTY_QA, historyOf, orphanThreads, qaReducer, relativeTime, resumeFor, whereLabel } from './qa'
|
||||
import { answerPreview, asksOf, EMPTY_QA, historyOf, qaReducer, relativeTime } from './qa'
|
||||
|
||||
const IF = 'function get_user > if @3fa1c2d0'
|
||||
const RETURN = 'function get_user > return @9b1e2c3d'
|
||||
|
||||
function thread(anchor: string, ...ids: string[]): QaThreadView {
|
||||
return { anchor, where: [], entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) }
|
||||
return { anchor, entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) }
|
||||
}
|
||||
|
||||
function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage {
|
||||
@@ -18,10 +17,6 @@ function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaActi
|
||||
return { type: 'qa', message: message(threads, running) }
|
||||
}
|
||||
|
||||
function block(anchor: string, children: BlockView[] = []): BlockView {
|
||||
return { id: anchor, anchor, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: {}, children, translation: [] }
|
||||
}
|
||||
|
||||
function run(...actions: QaAction[]): QaState {
|
||||
return actions.reduce(qaReducer, EMPTY_QA)
|
||||
}
|
||||
@@ -33,34 +28,23 @@ describe('问 AI 的状态', () => {
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '', status: 'running' }])
|
||||
state = qaReducer(state, { type: 'answer', askId: 'a1', text: '因为', activity: '读取 a.py' })
|
||||
expect(asksOf(state, IF)[0]).toMatchObject({ text: '因为', activity: '读取 a.py' })
|
||||
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new', sessionId: 's1' } })
|
||||
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } })
|
||||
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new', sessionId: 's1' }])
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new' }])
|
||||
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
|
||||
})
|
||||
|
||||
it('追问接着这一轮最近答成的会话;这一轮还没答成过就重新开', () => {
|
||||
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' })
|
||||
expect(resumeFor(state, IF)).toBeUndefined()
|
||||
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } })
|
||||
state = qaReducer(state, { type: 'ask', askId: 'a2', anchor: IF, question: '2' })
|
||||
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a2', status: 'failed', error: '网络断了' } })
|
||||
expect(resumeFor(state, IF)).toBe('s1')
|
||||
expect(resumeFor(state, RETURN)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
|
||||
let state = run(
|
||||
qa([thread(IF, 'e1')]),
|
||||
{ type: 'ask', askId: 'a1', anchor: IF, question: '1' },
|
||||
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } },
|
||||
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1' } },
|
||||
{ type: 'ask', askId: 'a2', anchor: IF, question: '2' },
|
||||
{ type: 'close', anchor: IF },
|
||||
)
|
||||
expect(state.open).toEqual([])
|
||||
expect(asksOf(state, IF).map(ask => ask.askId)).toEqual(['a2'])
|
||||
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['e1'])
|
||||
expect(resumeFor(state, IF)).toBeUndefined()
|
||||
state = qaReducer(state, { type: 'open', anchor: IF })
|
||||
expect(state.open).toEqual([IF])
|
||||
})
|
||||
@@ -83,19 +67,6 @@ describe('问 AI 的状态', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('找不到原来位置的问答', () => {
|
||||
it('记号对不上任何一层的块才算', () => {
|
||||
const threads = [thread(IF, 'a'), thread(RETURN, 'b'), thread('function gone', 'c')]
|
||||
expect(orphanThreads(threads, [block('function get_user', [block(IF)])]).map(item => item.anchor)).toEqual([RETURN, 'function gone'])
|
||||
})
|
||||
|
||||
it('原来在哪:种类取翻译插件起的名字,没名字的块只写种类', () => {
|
||||
const names: Record<string, string> = { class: '类', function: '函数', if: '条件' }
|
||||
expect(whereLabel([{ kind: 'class', name: 'ReferralRelation' }, { kind: 'function', name: 'display' }, { kind: 'if' }], kind => names[kind] ?? kind))
|
||||
.toBe('类 ReferralRelation › 函数 display › 条件')
|
||||
})
|
||||
})
|
||||
|
||||
describe('问的时间', () => {
|
||||
const now = new Date(2026, 8, 26, 15, 0).getTime()
|
||||
const at = (year: number, month: number, day: number, hour: number, minute: number, second = 0): string =>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 「问 AI」在页面上的状态和规则(不碰 DOM):问答按块的记号(BlockView.anchor)挂到块下面;
|
||||
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答是「找不到原来位置的」。
|
||||
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答不显示。
|
||||
*/
|
||||
|
||||
import type { AnchorStep, AnswerEnd, BlockView, QaEntryView, QaMessage, QaThreadView } from './types'
|
||||
import type { AnswerEnd, QaEntryView, QaMessage, QaThreadView } from './types'
|
||||
|
||||
/** 这次打开以来问的一个问题 */
|
||||
export interface Ask {
|
||||
@@ -19,8 +19,6 @@ export interface Ask {
|
||||
error?: string
|
||||
/** done 时存下的那条:历史里不再重复显示它 */
|
||||
entryId?: string
|
||||
/** done 时的会话 id:接着追问用 */
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export interface QaState {
|
||||
@@ -68,11 +66,11 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
|
||||
: ask),
|
||||
}
|
||||
case 'answerEnd': {
|
||||
const { askId, status, text, error, entryId, sessionId } = action.end
|
||||
const { askId, status, text, error, entryId } = action.end
|
||||
return {
|
||||
...state,
|
||||
asks: state.asks.map(ask => ask.askId === askId
|
||||
? { askId, anchor: ask.anchor, question: ask.question, text: text ?? ask.text, status, error, entryId, sessionId }
|
||||
? { askId, anchor: ask.anchor, question: ask.question, text: text ?? ask.text, status, error, entryId }
|
||||
: ask),
|
||||
}
|
||||
}
|
||||
@@ -105,30 +103,6 @@ export function historyOf(state: QaState, anchor: string): QaEntryView[] {
|
||||
return state.threads.find(thread => thread.anchor === anchor)?.entries.filter(entry => !current.has(entry.id)) ?? []
|
||||
}
|
||||
|
||||
/** 追问接着哪个会话:当前这一轮最近答成的那次;这一轮还没答成过就重新开 */
|
||||
export function resumeFor(state: QaState, anchor: string): string | undefined {
|
||||
return asksOf(state, anchor).filter(ask => ask.status === 'done' && ask.sessionId).at(-1)?.sessionId
|
||||
}
|
||||
|
||||
function anchorsIn(blocks: readonly BlockView[], into: Set<string>): Set<string> {
|
||||
for (const block of blocks) {
|
||||
into.add(block.anchor)
|
||||
anchorsIn(block.children, into)
|
||||
}
|
||||
return into
|
||||
}
|
||||
|
||||
/** 记号对不上任何块的问答(代码改动后找不到原来的位置了),按文件里的先后 */
|
||||
export function orphanThreads(threads: readonly QaThreadView[], blocks: readonly BlockView[]): QaThreadView[] {
|
||||
const anchors = anchorsIn(blocks, new Set())
|
||||
return threads.filter(thread => !anchors.has(thread.anchor))
|
||||
}
|
||||
|
||||
/** 问答原来挂在哪:各层写成「种类 名字」,用 › 连起来,例如「类 ReferralRelation › 函数 display › 条件」 */
|
||||
export function whereLabel(where: readonly AnchorStep[], kindName: (kind: string) => string): string {
|
||||
return where.map(step => step.name === undefined ? kindName(step.kind) : `${kindName(step.kind)} ${step.name}`).join(' › ')
|
||||
}
|
||||
|
||||
const MINUTE = 60_000
|
||||
const DAY = 24 * 60 * MINUTE
|
||||
|
||||
|
||||
@@ -164,16 +164,9 @@ export interface QaEntryView {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 块的位置记号里的一层:种类(同 BlockViewBase.kind)和名字,没名字的块只有种类 */
|
||||
export interface AnchorStep {
|
||||
kind: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** 挂在一块上的问答;where 是它原来在哪(各层的种类和名字),块找不到了时显示 */
|
||||
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示 */
|
||||
export interface QaThreadView {
|
||||
anchor: string
|
||||
where: AnchorStep[]
|
||||
entries: QaEntryView[]
|
||||
}
|
||||
|
||||
@@ -198,7 +191,7 @@ export interface QaMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)、存下的那条的 id 和会话 id(追问接着这个会话),
|
||||
* 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id,
|
||||
* failed 带给人看的原因,stopped 是被叫停、没存
|
||||
*/
|
||||
export interface AnswerEnd {
|
||||
@@ -208,7 +201,6 @@ export interface AnswerEnd {
|
||||
text?: string
|
||||
error?: string
|
||||
entryId?: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,7 +230,7 @@ export type ExtensionToWebview
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
|
||||
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
|
||||
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;resume 是追问时上一次的会话 id
|
||||
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问
|
||||
* stopAsk:叫停 askId 那个问题,这次不存
|
||||
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
|
||||
*/
|
||||
@@ -251,6 +243,6 @@ export type WebviewToExtension
|
||||
| { type: 'probeDefinition', id: number, version: number, offset: number }
|
||||
| { type: 'goToDefinition', version: number, offset: number }
|
||||
| { type: 'findOccurrences', id: number, version: number, offset: number }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string, resume?: string }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string }
|
||||
| { type: 'stopAsk', askId: string }
|
||||
| { type: 'deleteQa', anchor: string, entryId: string }
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { QaTree } from './BlockTree'
|
||||
import type { QaState } from './qa'
|
||||
import { useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
import { EMPTY_QA, newAskId, qaReducer, resumeFor } from './qa'
|
||||
import { EMPTY_QA, newAskId, qaReducer } from './qa'
|
||||
import { onMessage, postMessage } from './vscode'
|
||||
|
||||
/** 逐字长出来的回答最多隔多久画一次:一个字一画整棵块树跟着重画,大文件会卡 */
|
||||
@@ -10,7 +10,7 @@ const ANSWER_FRAME_MS = 80
|
||||
/** 问 AI:收扩展端的问答、正在答的进展,给块树用的状态和动作(问、停、删、展开 / 收起)。 */
|
||||
export function useQa(): QaTree {
|
||||
const [state, dispatch] = useReducer(qaReducer, EMPTY_QA)
|
||||
/** 动作里读眼下的状态(追问接着哪个会话) */
|
||||
/** 动作里读眼下的状态(这块展开着没有) */
|
||||
const current = useRef<QaState>(state)
|
||||
current.current = state
|
||||
|
||||
@@ -51,9 +51,8 @@ export function useQa(): QaTree {
|
||||
onClose: anchor => dispatch({ type: 'close', anchor }),
|
||||
onAsk: (anchor, question) => {
|
||||
const askId = newAskId()
|
||||
const resume = resumeFor(current.current, anchor)
|
||||
dispatch({ type: 'ask', askId, anchor, question })
|
||||
postMessage({ type: 'ask', askId, anchor, question, ...(resume && { resume }) })
|
||||
postMessage({ type: 'ask', askId, anchor, question })
|
||||
},
|
||||
onStop: askId => postMessage({ type: 'stopAsk', askId }),
|
||||
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
|
||||
|
||||
Reference in new issue
Block a user