✨ feat(vscode): 重写注释排成一队,整个扩展一次只跑一段
OpenPencil 同一时间只能开一个画板,两段同时画会互相掐断。所有预览、所有文件的重写(第一轮和接着说)排成一队,前一段写成、出错、被叫停后按先后自动开始下一段;问 AI 照旧最多同时 3 个。 排着的那一轮记在扩展端(rewriteCurrent 的 queued),块下面写「排队中」、能取消排队,切标签页、页面重新加载后照样显示。
This commit is contained in:
1 parent
b7c29c60e1
commit
aecc4d1ab5
15 files changed
+329
-114
No files matched your search
@@ -644,10 +644,11 @@
|
||||
- 输入框里说的话:`--resume` 这块最近一次重写的会话(和问答的会话分开,各接各的),发「块现在在哪(`<路径>:<起>-<止>`,写回注释后行号会变,按记号从最新的块树里找)、空一行、用户的话」,带上贴的图。会话找不到了(过期、被清理)就开新会话,发技能命令后面空一行跟着这句话。
|
||||
- 能做的事(`claudeAsk.ts` 的 `rewriteArgs`):`--restricted` 不读用户、项目、本地的设置文件(那里放行的命令进不来,比如用户设置里的 `git -C`),文件工具只认工作目录;写注释的插件单独加载(`--plugin-dir`,目录按 `claude plugin list --json` 里启用着的 ecc 找),它的目录加进工作目录(`--add-dir`),技能读得到它的参考文档。原型照技能的约定存在仓库里:`docs/prototype/<功能>/openpencil/` 下的 `build.js`、`<功能>.op`,截图在它的 `exports/` 里,注释链到截图。工具只给读、搜、Bash、写文件(`--tools Read,Grep,Glob,Bash,Edit,Write`),不用再批的只有读搜、OpenPencil 的 `op`(`Bash(/Applications/OpenPencil.app/Contents/MacOS/op *)`)、建原型的截图目录(`Bash(mkdir -p docs/prototype/*/openpencil/exports)`)、改原型目录里的文件(`Edit(/docs/prototype/*/openpencil/**)`,开头的 `/` 是 claude 的工作目录,`*` 只配一层目录;Edit 规则也管 Write)。Bash 规则里的 `*` 能跨空格,建目录时后面多跟一个路径(`mkdir -p docs/prototype/x/openpencil/exports src/y`)也对得上,所以另外拒掉 `Bash(mkdir -p docs/prototype/* *)`(`--disallowedTools`)。别的一律拒绝、不等人批(`--permission-mode dontAsk`),不加载 MCP(`--strict-mcp-config`):改不了源码、跑不了别的命令,写回归扩展。本机 claude 2.1.296 用这组参数试过:写、改 `docs/prototype/x/openpencil/build.js`,建它的 `exports/`,`op --version` / `op status` 都行;写、改源码,写 `docs/prototype` 之外和 `docs/prototype/x/` 下 `openpencil/` 之外的文件,在别处建目录(`docs/other/exports`、`src/openpencil/exports`、仓库外的绝对路径、带 `..` 的路径、合法路径后面多跟一个),`rm`、`git -C`、`node -e`、重定向写文件都被拒。
|
||||
- 画图比读文件慢,超时 15 分钟(问 AI 仍是 5 分钟)。
|
||||
- 过程:说的话(第一轮是那句技能命令,开始后扩展端报上来)、正文逐字长出来(`--include-partial-messages` 时 `StructuredOutput` 的输入逐段来,按写到一半的 JSON 读出已经写出来的正文)、正在做什么(「读取 a.py」)。块下面同问答区的样子(`AskThread` 换成重写的称呼):灰字历史、当前这一轮、输入框(提示「说说这段注释要怎么改…」,旁边 profile 下拉,能贴图),正在写时发送换成停止。同一块同时只重写一轮,全插件同时最多 3 段。
|
||||
- 过程:说的话(第一轮是那句技能命令,排上队或开始后扩展端报上来)、正文逐字长出来(`--include-partial-messages` 时 `StructuredOutput` 的输入逐段来,按写到一半的 JSON 读出已经写出来的正文)、正在做什么(「读取 a.py」)。块下面同问答区的样子(`AskThread` 换成重写的称呼):灰字历史、当前这一轮、输入框(提示「说说这段注释要怎么改…」,旁边 profile 下拉,能贴图),正在写时发送换成停止。
|
||||
- 排队(`asks.ts` 的 `REWRITE_POLICY`):整个扩展(所有预览、所有文件,第一轮和接着说都算)同时只跑一段重写,因为 OpenPencil 同一时间只能开一个画板,两段同时画,一边 `op stop` 另一边就跟着断。后来的排在后面,块下面这一轮写「排队中,前面的写完就开始」(沙漏图标、不转圈),输入框旁边的按钮是「取消排队」,点了这一轮按被叫停结束(「已停止,这次没存。」)、不再开始;前一段结束(写成、出错、被叫停都算)后按先后自动开始下一段,开始了照常显示进展。同一块同时只有一轮(在写、排着都算),这时块右上角的按钮不能点,再从输入框说也回「这段注释已经在重写了」。问 AI 不排队,照旧最多同时 3 个。
|
||||
- 写回(扩展端,`WorkspaceEdit`,能撤销):换掉时套回原来那段的定界符和缩进(Python 三引号、`#` / `//` / `///` / `//!` 行注释、`/* */` 块注释,`refillComment`;几段隔着空行的照第一段的写法);新写时用这块说明的壳(docstring、`/** */`、`///`,或行注释 `#` / `//`)一行一行写,缩进同插入的那一行,空行写成只有记号的一行(docstring 的空行仍是空行)。写之前核对这块代码的那几行和开始时一样,变了就不写(「这块代码在重写期间变了」);没交回正文、定界符认不出、编辑器没接受也不写。
|
||||
- 记录:每个工作区文件夹一份 `.spx/comment-rewrites.json`,和 `code-qa.json` 同一套写法(同一份 `qaMap.ts`,改名、删文件跟着走),条目 `{ "id", "question", "body"?, "error"?, "createdAt", "sessionId", "profile"?, "images"? }`:说的话(第一轮是技能命令)、交回的正文、没写进文件的原因(写进去了没有)。模型答完就记,写回失败也记下原因,好接着让它改;Claude Code 自己报错、被叫停的不记,文件不动。带的图片和问答的放在同一个 `.spx/code-qa/` 里,删一条跟着删。没展开时块下面一行「重写过 N 次」带灰字历史(没写进文件的那条下面写着原因),能收起、能删(「删除这条记录」)。
|
||||
- 预览重新加载(标签页切走再切回来)后照样接上:进行中的、出错被叫停留着的那一轮记在扩展端(同问 AI,`QaAsks`),随 `qa` 消息的 `rewriteCurrent` 回来,展开它那块、进展接着长、停止能用;收起这块时留着的那一轮去掉。切走期间扩展端照样写完、写回、记下。
|
||||
- 预览重新加载(标签页切走再切回来)后照样接上:排着队的、进行中的、出错被叫停留着的那一轮记在扩展端(同问 AI,`QaAsks`),随 `qa` 消息的 `rewriteCurrent` 回来,展开它那块、排队中照样显示、进展接着长、停止和取消排队能用;收起这块时留着的那一轮去掉。切走期间扩展端照样写完、写回、记下。
|
||||
|
||||
## 通用契约
|
||||
|
||||
@@ -2222,7 +2223,7 @@ vscode/src/codeReader/
|
||||
qa/claudeAsk.ts 命令行参数(只读、选的 profile、要结构化结果时的 --json-schema)、问一次(问题连同图片从 stdin 发,追问找不到会话时重新问)、重写注释问一次(askCommentBody)、失败给人看的话
|
||||
qa/qaImages.ts 问题带的图片:认哪些格式、存到 .spx/code-qa/ 叫什么名字、写文件(不碰 VS Code)
|
||||
qa/askProfiles.ts AskProfiles:问 AI 用哪个 profile,下拉的选项、记在 globalState、通知所有预览(不碰 VS Code)
|
||||
qa/asks.ts QaAsks:正在答的问题,一块一个、最多 3 个、能叫停(不碰 VS Code)
|
||||
qa/asks.ts QaAsks:块下面正在进行的对话,一块一个、能叫停;问 AI 最多同时 3 个,重写注释排成一队、一次一段(RunPolicy,不碰 VS Code)
|
||||
qa/codeQa.ts CodeQa:问答的 FolderStore、开问(问过的块接着最近的会话,用选的 profile,带贴的图)、答完存(带 profile 名、图片)、删,发给预览的问答、图片目录
|
||||
(cc/spawnClaude.ts 的 runClaudeLines:跑 claude、给了 input 就写进 stdin、stdout 逐行交出、超时、叫停,问 AI 用它;buildStreamJsonLine 拼 stream-json 的一条用户消息)
|
||||
editor/previewEditor.ts CustomTextEditorProvider
|
||||
|
||||
@@ -88,7 +88,7 @@ export interface QaThreadView {
|
||||
|
||||
/**
|
||||
* 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」;
|
||||
* current、rewriteCurrent 是当前这一轮:正在答的问题、正在重写的注释,和出错、被叫停没记下留着的(页面重新加载后靠它们接上)。
|
||||
* current、rewriteCurrent 是当前这一轮:正在答的问题、排着队和正在重写的注释,和出错、被叫停没记下留着的(页面重新加载后靠它们接上)。
|
||||
*/
|
||||
export interface QaMessage {
|
||||
type: 'qa'
|
||||
@@ -128,7 +128,7 @@ export interface StickyScrollSettings {
|
||||
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
|
||||
* rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
|
||||
* rewriteProgress:askId 那一轮重写排上队了(queued)、开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
|
||||
* rewriteEnd:askId 那一轮重写结束了:done 是模型答完、这一轮记下了,带交回的正文(text)、存下的那条的 id,没写进文件时带原因(error);
|
||||
* failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改
|
||||
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
|
||||
@@ -158,7 +158,7 @@ export type ExtensionToWebview
|
||||
| ({ type: 'askProfiles' } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
|
||||
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string }
|
||||
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true }
|
||||
| { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
|
||||
| { type: 'diff', diff: DocumentDiff | null }
|
||||
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
|
||||
|
||||
@@ -303,8 +303,8 @@ class Preview {
|
||||
if (event.owner !== this.subject().uri.toString() || !this.ready)
|
||||
return
|
||||
if (event.kind === 'update') {
|
||||
const { askId, question, text, activity } = event.ask
|
||||
this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }) })
|
||||
const { askId, question, text, activity, queued } = event.ask
|
||||
this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }), ...(queued && { queued }) })
|
||||
return
|
||||
}
|
||||
const { end, entryId } = event
|
||||
@@ -1020,8 +1020,6 @@ class Preview {
|
||||
fail(refused)
|
||||
return
|
||||
}
|
||||
// 页面点按钮时还不知道那句技能命令,先报上去
|
||||
this.post({ type: 'rewriteProgress', askId, question, text: '' })
|
||||
this.postQa()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AskEnd, AskStart, RunningAsk } from './asks'
|
||||
import type { Answer, AskRun } from './claudeAsk'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude'
|
||||
import { ASK_WORDING, QaAsks, REWRITE_WORDING } from './asks'
|
||||
import { ASK_POLICY, QaAsks, REWRITE_POLICY } from './asks'
|
||||
|
||||
/** 假的回答:每次开问记下来,由测试决定什么时候答完 / 出错;叫停时像真的一样抛 ClaudeCancelledError */
|
||||
function fakeRunner(): { runner: (run: AskRun) => Promise<Answer>, runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] } {
|
||||
@@ -38,7 +38,7 @@ function listener(): { updates: RunningAsk[], ends: [RunningAsk, AskEnd][], ende
|
||||
}
|
||||
|
||||
describe('正在答的问题', () => {
|
||||
it('回答长出来、换了在做的事都报给界面;答完报 done,不再算在答', async () => {
|
||||
it('开始了、回答长出来、换了在做的事都报给界面;答完报 done,不再算在答', async () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const asks = new QaAsks(runner)
|
||||
const events = listener()
|
||||
@@ -48,6 +48,7 @@ describe('正在答的问题', () => {
|
||||
runs[0].onEvent({ kind: 'activity', label: '读取 m.py' })
|
||||
runs[0].onEvent({ kind: 'text', text: '答' })
|
||||
expect(events.updates).toEqual([
|
||||
{ askId: '1', anchor: '1', question: '问 1', text: '' },
|
||||
{ askId: '1', anchor: '1', question: '问 1', text: '', activity: '读取 m.py' },
|
||||
{ askId: '1', anchor: '1', question: '问 1', text: '答' },
|
||||
])
|
||||
@@ -89,31 +90,33 @@ describe('正在答的问题', () => {
|
||||
expect(asks.start(start('1', 'function g'), listener())).toBe('这个问题已经在问了。')
|
||||
})
|
||||
|
||||
it('不同块可以同时答,最多几个;答完一个又能问', async () => {
|
||||
it('不同块可以同时答,最多三个,再多不开始、说清楚;答完一个又能问', async () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const asks = new QaAsks(runner, ASK_WORDING, 2)
|
||||
const asks = new QaAsks(runner)
|
||||
const first = listener()
|
||||
asks.start(start('1'), first)
|
||||
asks.start(start('2'), listener())
|
||||
expect(asks.start(start('3'), listener())).toBe('同时最多问 2 个问题,等前面的答完再问。')
|
||||
asks.start(start('3'), listener())
|
||||
expect(asks.start(start('4'), listener())).toBe('同时最多问 3 个问题,等前面的答完再问。')
|
||||
expect(runs).toHaveLength(3)
|
||||
runs[0].answer({ text: '答', sessionId: 's' })
|
||||
await first.ended
|
||||
expect(asks.start(start('3'), listener())).toBeUndefined()
|
||||
expect(asks.start(start('4'), listener())).toBeUndefined()
|
||||
expect(runs).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('重写注释用自己的说法:这段已经在重写、同时重写的太多、等太久停掉', async () => {
|
||||
it('重写注释用自己的说法:这段已经在重写(排着队也算)、等太久停掉', async () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const rewrites = new QaAsks(runner, REWRITE_WORDING, 2)
|
||||
const rewrites = new QaAsks(runner, REWRITE_POLICY)
|
||||
const first = listener()
|
||||
rewrites.start(start('1', 'function f'), first)
|
||||
expect(rewrites.start(start('2', 'function f'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。')
|
||||
rewrites.start(start('3', 'function g'), listener())
|
||||
expect(rewrites.start(start('4', 'function h'), listener())).toBe('同时最多重写 2 段注释,等前面的写完再来。')
|
||||
expect(rewrites.start(start('4', 'function g'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。')
|
||||
runs[0].fail(new ClaudeTimeoutError('Claude 调用超时(900s)', ''))
|
||||
await first.ended
|
||||
expect(first.ends[0][1]).toEqual({ status: 'failed', error: '等了 15 分钟还没写完,已经停掉。' })
|
||||
})
|
||||
|
||||
it('出错、叫停的那一轮没记下,留在这里给重新加载的页面再画出来;这块再开一轮、收起这块时去掉,别的文件不算', async () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const asks = new QaAsks(runner)
|
||||
@@ -137,7 +140,7 @@ describe('正在答的问题', () => {
|
||||
|
||||
it('留着的那一轮不占名额,收起时还在答的不去掉', async () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const asks = new QaAsks(runner, ASK_WORDING, 1)
|
||||
const asks = new QaAsks(runner, { ...ASK_POLICY, limit: 1 })
|
||||
const failing = listener()
|
||||
asks.start(start('1', 'function f'), failing)
|
||||
runs[0].fail(new ClaudeError('网络断了', ''))
|
||||
@@ -158,3 +161,80 @@ describe('正在答的问题', () => {
|
||||
expect(runs.every(run => run.signal.aborted)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('重写注释排成一队', () => {
|
||||
const after = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
it('同时只跑一段,不管哪份文件;后面的排着、报排队中,前一段写成、出错、被叫停后按先后自动开始下一段', async () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const rewrites = new QaAsks(runner, REWRITE_POLICY)
|
||||
const [a, b, c, d] = [listener(), listener(), listener(), listener()]
|
||||
expect(rewrites.start(start('1', 'function f'), a)).toBeUndefined()
|
||||
expect(rewrites.start(start('2', 'function g', 'file:///b.py'), b)).toBeUndefined()
|
||||
expect(rewrites.start(start('3', 'function h'), c)).toBeUndefined()
|
||||
expect(rewrites.start(start('4', 'function k', 'file:///b.py'), d)).toBeUndefined()
|
||||
expect(runs.map(run => run.prompt)).toEqual(['完整的 1'])
|
||||
expect(b.updates).toEqual([{ askId: '2', anchor: 'function g', question: '问 2', text: '', queued: true }])
|
||||
expect(rewrites.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['1', 'running'], ['3', 'queued']])
|
||||
expect(rewrites.currentFor('file:///b.py')).toEqual([
|
||||
{ askId: '2', anchor: 'function g', question: '问 2', text: '', status: 'queued' },
|
||||
{ askId: '4', anchor: 'function k', question: '问 4', text: '', status: 'queued' },
|
||||
])
|
||||
|
||||
runs[0].answer({ text: '写好了', sessionId: 's1' })
|
||||
await a.ended
|
||||
await after()
|
||||
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2'])
|
||||
expect(b.updates.at(-1)).toEqual({ askId: '2', anchor: 'function g', question: '问 2', text: '' })
|
||||
expect(rewrites.currentFor('file:///b.py').map(ask => [ask.askId, ask.status])).toEqual([['2', 'running'], ['4', 'queued']])
|
||||
|
||||
runs[1].fail(new ClaudeError('网络断了', ''))
|
||||
await b.ended
|
||||
await after()
|
||||
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2', '完整的 3'])
|
||||
|
||||
rewrites.stop('3')
|
||||
await c.ended
|
||||
await after()
|
||||
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2', '完整的 3', '完整的 4'])
|
||||
})
|
||||
|
||||
it('排着队的能取消:报 stopped,不再开始;排在它后面的照样轮到', async () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const rewrites = new QaAsks(runner, REWRITE_POLICY)
|
||||
const [a, b, c] = [listener(), listener(), listener()]
|
||||
rewrites.start(start('1', 'function f'), a)
|
||||
rewrites.start(start('2', 'function g'), b)
|
||||
rewrites.start(start('3', 'function h'), c)
|
||||
rewrites.stop('2')
|
||||
await b.ended
|
||||
expect(b.ends).toEqual([[{ askId: '2', anchor: 'function g', question: '问 2', text: '' }, { status: 'stopped' }]])
|
||||
expect(rewrites.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['1', 'running'], ['2', 'stopped'], ['3', 'queued']])
|
||||
runs[0].answer({ text: '写好了', sessionId: 's1' })
|
||||
await a.ended
|
||||
await after()
|
||||
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 3'])
|
||||
})
|
||||
|
||||
it('全部叫停时排着队的也结束,不再开始', async () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const rewrites = new QaAsks(runner, REWRITE_POLICY)
|
||||
const [a, b] = [listener(), listener()]
|
||||
rewrites.start(start('1', 'function f'), a)
|
||||
rewrites.start(start('2', 'function g'), b)
|
||||
rewrites.stopAll()
|
||||
await Promise.all([a.ended, b.ended])
|
||||
await after()
|
||||
expect(b.ends[0][1]).toEqual({ status: 'stopped' })
|
||||
expect(runs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('问 AI 不排队:同时问几个照旧都开始', () => {
|
||||
const { runner, runs } = fakeRunner()
|
||||
const asks = new QaAsks(runner)
|
||||
asks.start(start('1'), listener())
|
||||
asks.start(start('2'), listener())
|
||||
expect(runs).toHaveLength(2)
|
||||
expect(asks.currentFor('file:///a.py').map(ask => ask.status)).toEqual(['running', 'running'])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 块下面正在进行的对话(问 AI、重写注释各一份):一块同时只进行一轮,不同块可以同时进行(最多 MAX_RUNNING 个),能叫停。
|
||||
* 块下面正在进行的对话(问 AI、重写注释各一份):一块同时只进行一轮,能叫停;不同块同时进行几轮、满了怎么办按 RunPolicy。
|
||||
* 不碰 VS Code:结束后怎么存、怎么告诉界面交给 AskListener。
|
||||
*/
|
||||
|
||||
@@ -7,9 +7,6 @@ import type { Answer, AskRun } from './claudeAsk'
|
||||
import { ClaudeCancelledError } from '../../cc/spawnClaude'
|
||||
import { askFailure, rewriteFailure } from './claudeAsk'
|
||||
|
||||
/** 同时进行的最多几个:每个都是一个 claude 进程 */
|
||||
export const MAX_RUNNING = 3
|
||||
|
||||
/** 正在进行的一轮,界面照它显示。 */
|
||||
export interface RunningAsk {
|
||||
askId: string
|
||||
@@ -19,6 +16,8 @@ export interface RunningAsk {
|
||||
text: string
|
||||
/** 正在做的事:「读取 routes.py」 */
|
||||
activity?: string
|
||||
/** 还在排队,前面的结束了才开始 */
|
||||
queued?: true
|
||||
}
|
||||
|
||||
/** 进行中报上来的:会话 id、回答长出来的字、正在做的事。 */
|
||||
@@ -52,28 +51,34 @@ export interface AskListener<Result = Answer> {
|
||||
end: (ask: RunningAsk, end: AskEnd<Result>) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** 给人看的话:这块已经在进行、同时进行的太多、没成的原因。 */
|
||||
export interface RunWording {
|
||||
/** 一种对话怎么跑:同时最多几轮(每轮一个 claude 进程),满了是不开始(refuse,回给人看的话)还是排在后面(queue),以及给人看的话。 */
|
||||
export interface RunPolicy {
|
||||
limit: number
|
||||
whenFull: { kind: 'refuse', message: (limit: number) => string } | { kind: 'queue' }
|
||||
/** 这块已经在进行 */
|
||||
busy: string
|
||||
full: (limit: number) => string
|
||||
/** 没成的原因 */
|
||||
failure: (err: unknown) => string
|
||||
}
|
||||
|
||||
export const ASK_WORDING: RunWording = {
|
||||
export const ASK_POLICY: RunPolicy = {
|
||||
limit: 3,
|
||||
whenFull: { kind: 'refuse', message: limit => `同时最多问 ${limit} 个问题,等前面的答完再问。` },
|
||||
busy: '这里已经在问了,等它答完或者先停下再问。',
|
||||
full: limit => `同时最多问 ${limit} 个问题,等前面的答完再问。`,
|
||||
failure: askFailure,
|
||||
}
|
||||
|
||||
export const REWRITE_WORDING: RunWording = {
|
||||
/** 重写注释一次只跑一段:OpenPencil 同一时间只能开一个画板,两段同时画,一边 `op stop` 另一边就跟着断。 */
|
||||
export const REWRITE_POLICY: RunPolicy = {
|
||||
limit: 1,
|
||||
whenFull: { kind: 'queue' },
|
||||
busy: '这段注释已经在重写了,等它写完或者先停下再说。',
|
||||
full: limit => `同时最多重写 ${limit} 段注释,等前面的写完再来。`,
|
||||
failure: rewriteFailure,
|
||||
}
|
||||
|
||||
/** 这一轮眼下怎样:还在进行,或出错、被叫停(没记下,留着给人看)。 */
|
||||
export interface AskRound extends RunningAsk {
|
||||
status: 'running' | 'failed' | 'stopped'
|
||||
/** 这一轮眼下怎样:排着队、还在进行,或出错、被叫停(没记下,留着给人看)。 */
|
||||
export interface AskRound extends Omit<RunningAsk, 'queued'> {
|
||||
status: 'queued' | 'running' | 'failed' | 'stopped'
|
||||
/** failed 时给人看的原因 */
|
||||
error?: string
|
||||
}
|
||||
@@ -82,12 +87,15 @@ interface Round {
|
||||
owner: string
|
||||
ask: RunningAsk
|
||||
controller: AbortController
|
||||
/** 出错、被叫停了;还在进行时没有 */
|
||||
/** 排着队时才有:轮到了怎么开始,取消了怎么结束 */
|
||||
waiting?: { launch: () => void, cancel: () => void }
|
||||
/** 出错、被叫停了;还没结束时没有 */
|
||||
end?: Exclude<AskEnd, { status: 'done' }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Run 是跑一轮要的全部,叫停的开关和进行中的消息由这里给。
|
||||
* 满了按 RunPolicy 不开始或排在后面;排着的按先后,前面结束一轮(答成、出错、被叫停都算)就开始下一轮。
|
||||
* 答成了的那一轮交给 listener 记下,这里就不管了;出错、被叫停的没记下,留在这里(页面重新加载后靠 currentFor 再画出来),
|
||||
* 这块再开一轮、或页面上收起这块时去掉(forget)。
|
||||
*/
|
||||
@@ -96,72 +104,91 @@ export class QaAsks<Run extends { signal: AbortSignal } = AskRun, Result = Answe
|
||||
|
||||
constructor(
|
||||
private readonly runner: (run: Run) => Promise<Result>,
|
||||
private readonly wording: RunWording = ASK_WORDING,
|
||||
private readonly limit = MAX_RUNNING,
|
||||
private readonly policy: RunPolicy = ASK_POLICY,
|
||||
) {}
|
||||
|
||||
/** 开始一轮;这块已经在进行、同时进行的太多时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */
|
||||
/** 开始一轮,满了按 RunPolicy 排在后面;这块已经在进行或排着、满了又不排队时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */
|
||||
start(start: AskStart<Omit<Run, keyof RunControl>>, listener: AskListener<Result>): string | undefined {
|
||||
const { owner, askId, anchor, question } = start
|
||||
if (this.rounds.has(askId))
|
||||
return '这个问题已经在问了。'
|
||||
const running = [...this.rounds.values()].filter(round => !round.end)
|
||||
if (running.some(round => round.owner === owner && round.ask.anchor === anchor))
|
||||
return this.wording.busy
|
||||
if (running.length >= this.limit)
|
||||
return this.wording.full(this.limit)
|
||||
if ([...this.rounds.values()].some(round => !round.end && round.owner === owner && round.ask.anchor === anchor))
|
||||
return this.policy.busy
|
||||
const full = this.running() >= this.policy.limit
|
||||
if (full && this.policy.whenFull.kind === 'refuse')
|
||||
return this.policy.whenFull.message(this.policy.limit)
|
||||
this.forget(owner, anchor)
|
||||
const controller = new AbortController()
|
||||
const ask: RunningAsk = { askId, anchor, question, text: '' }
|
||||
const round: Round = { owner, ask, controller }
|
||||
const round: Round = { owner, ask, controller: new AbortController() }
|
||||
this.rounds.set(askId, round)
|
||||
const control: RunControl = {
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => {
|
||||
if (event.kind === 'text') {
|
||||
ask.text = event.text
|
||||
delete ask.activity
|
||||
}
|
||||
else if (event.kind === 'activity') {
|
||||
ask.activity = event.label
|
||||
}
|
||||
else {
|
||||
return
|
||||
}
|
||||
listener.update({ ...ask })
|
||||
},
|
||||
}
|
||||
// Run 里除了这两项就是 start.run,拼回去还是 Run
|
||||
void this.runner({ ...start.run, ...control } as unknown as Run).then(
|
||||
(answer): AskEnd<Result> => ({ status: 'done', answer }),
|
||||
(err: unknown): AskEnd<Result> => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: this.wording.failure(err) },
|
||||
).then(async (end) => {
|
||||
const finish = async (end: AskEnd<Result>): Promise<void> => {
|
||||
if (end.status === 'done')
|
||||
this.rounds.delete(askId)
|
||||
else
|
||||
round.end = end
|
||||
await listener.end({ ...ask }, end)
|
||||
})
|
||||
try {
|
||||
await listener.end({ ...ask }, end)
|
||||
}
|
||||
finally {
|
||||
this.next()
|
||||
}
|
||||
}
|
||||
const launch = (): void => {
|
||||
listener.update({ ...ask })
|
||||
const control: RunControl = {
|
||||
signal: round.controller.signal,
|
||||
onEvent: (event) => {
|
||||
if (event.kind === 'text') {
|
||||
ask.text = event.text
|
||||
delete ask.activity
|
||||
}
|
||||
else if (event.kind === 'activity') {
|
||||
ask.activity = event.label
|
||||
}
|
||||
else {
|
||||
return
|
||||
}
|
||||
listener.update({ ...ask })
|
||||
},
|
||||
}
|
||||
// Run 里除了这两项就是 start.run,拼回去还是 Run
|
||||
void this.runner({ ...start.run, ...control } as unknown as Run).then(
|
||||
(answer): AskEnd<Result> => ({ status: 'done', answer }),
|
||||
(err: unknown): AskEnd<Result> => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: this.policy.failure(err) },
|
||||
).then(finish)
|
||||
}
|
||||
if (!full) {
|
||||
launch()
|
||||
return undefined
|
||||
}
|
||||
ask.queued = true
|
||||
round.waiting = { launch, cancel: () => void finish({ status: 'stopped' }) }
|
||||
listener.update({ ...ask })
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 叫停:结束 claude 进程,这次不存。 */
|
||||
/** 叫停:结束 claude 进程,这次不存;排着队的取消,不再开始。 */
|
||||
stop(askId: string): void {
|
||||
const round = this.rounds.get(askId)
|
||||
if (round && !round.end)
|
||||
if (!round || round.end)
|
||||
return
|
||||
const waiting = this.dequeue(round)
|
||||
if (waiting)
|
||||
waiting.cancel()
|
||||
else
|
||||
round.controller.abort()
|
||||
}
|
||||
|
||||
/** owner 这份文件当前的各轮:还在进行的,和出错、被叫停留着的,按开始的先后。 */
|
||||
/** owner 这份文件当前的各轮:排着队的、还在进行的,和出错、被叫停留着的,按开始的先后。 */
|
||||
currentFor(owner: string): AskRound[] {
|
||||
return [...this.rounds.values()].filter(round => round.owner === owner).map(({ ask, end }) => ({
|
||||
return [...this.rounds.values()].filter(round => round.owner === owner).map(({ ask: { queued, ...ask }, end }) => ({
|
||||
...ask,
|
||||
status: end?.status ?? 'running',
|
||||
status: end?.status ?? (queued ? 'queued' : 'running'),
|
||||
...(end?.status === 'failed' && { error: end.error }),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 去掉 owner 这份文件 anchor 那块留着的那一轮(页面上收起了这块);还在进行的不动。 */
|
||||
/** 去掉 owner 这份文件 anchor 那块留着的那一轮(页面上收起了这块);还在进行、排着的不动。 */
|
||||
forget(owner: string, anchor: string): void {
|
||||
for (const [askId, round] of this.rounds) {
|
||||
if (round.end && round.owner === owner && round.ask.anchor === anchor)
|
||||
@@ -169,11 +196,32 @@ export class QaAsks<Run extends { signal: AbortSignal } = AskRun, Result = Answe
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部叫停(插件停用时)。 */
|
||||
/** 全部叫停(插件停用时)。排着的同步取消,前面的停下后不会再开始它们。 */
|
||||
stopAll(): void {
|
||||
for (const askId of [...this.rounds.keys()])
|
||||
this.stop(askId)
|
||||
}
|
||||
|
||||
/** 正在跑的有几轮(排着的、结束了的不算) */
|
||||
private running(): number {
|
||||
return [...this.rounds.values()].filter(round => !round.end && !round.waiting).length
|
||||
}
|
||||
|
||||
/** 让这一轮不再排队,返回它排队时的办法;没在排队时没有。 */
|
||||
private dequeue(round: Round): Round['waiting'] {
|
||||
const { waiting } = round
|
||||
delete round.waiting
|
||||
delete round.ask.queued
|
||||
return waiting
|
||||
}
|
||||
|
||||
/** 有空位就按先后开始排着的。 */
|
||||
private next(): void {
|
||||
for (const round of this.rounds.values()) {
|
||||
if (this.running() >= this.policy.limit)
|
||||
return
|
||||
if (!round.end)
|
||||
round.controller.abort()
|
||||
this.dequeue(round)?.launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,10 @@ describe('重写注释的记录', () => {
|
||||
expect(rewriter.mock.calls[1][0].followUp).toEqual({ sessionId: 'r1', prompt: 'a.ts:1-5\n\n短一点' })
|
||||
expect((await second).end).toEqual({ status: 'done', body: '短的。', error: '编辑器没接受这次修改,没有写回。' })
|
||||
expect(writeBack).toHaveBeenCalledWith('短的。')
|
||||
expect(rewritesOf(qa)[0].entries.map(entry => [entry.question, entry.answer, entry.error])).toEqual([[SKILL, '按名字找。', undefined], ['短一点', '短的。', '编辑器没接受这次修改,没有写回。']])
|
||||
// 两条可能记在同一毫秒,不比先后
|
||||
const entries = rewritesOf(qa)[0].entries.map(entry => [entry.question, entry.answer, entry.error])
|
||||
expect(entries).toHaveLength(2)
|
||||
expect(entries).toEqual(expect.arrayContaining([[SKILL, '按名字找。', undefined], ['短一点', '短的。', '编辑器没接受这次修改,没有写回。']]))
|
||||
|
||||
await qa.ask(uri, { askId: '3', anchor: 'function f', question: '为什么', prompt: '完整的', followUp: '短的', images: [] })
|
||||
expect(runner.mock.calls[0][0]).not.toHaveProperty('followUp')
|
||||
@@ -199,8 +202,10 @@ describe('重写注释的记录', () => {
|
||||
const second = ended(qa, '2')
|
||||
await qa.rewrite(uri, request('2', { writeBack: async body => body === undefined ? '模型没有按要求交回注释正文,没有改文件。' : undefined }))
|
||||
expect(rewriter.mock.calls[1][0]).not.toHaveProperty('followUp')
|
||||
expect((await second).end).toEqual({ status: 'done', error: '模型没有按要求交回注释正文,没有改文件。' })
|
||||
expect(rewritesOf(qa)[0].entries.at(-1)).toMatchObject({ answer: '', error: '模型没有按要求交回注释正文,没有改文件。' })
|
||||
const { end, entryId } = await second
|
||||
expect(end).toEqual({ status: 'done', error: '模型没有按要求交回注释正文,没有改文件。' })
|
||||
// 两条可能记在同一毫秒,按 id 找这一条
|
||||
expect(rewritesOf(qa)[0].entries.find(entry => entry.id === entryId)).toMatchObject({ answer: '', error: '模型没有按要求交回注释正文,没有改文件。' })
|
||||
})
|
||||
|
||||
it('出错、被叫停的不记,也不写文件;进行中的、没记下留着的那一轮在 rewriteCurrent 里,页面重新加载能再画出来,收起这块时去掉', async () => {
|
||||
@@ -227,4 +232,33 @@ describe('重写注释的记录', () => {
|
||||
qa.forgetRewrite(uri, 'function f')
|
||||
expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([])
|
||||
})
|
||||
|
||||
it('整个扩展一次只重写一段:别的文件的排在后面、报排队中,记在 rewriteCurrent 里;前一段写完自动开始;问 AI 照旧同时问', async () => {
|
||||
const ctx = context()
|
||||
const { qa, runner, rewriter } = service(ctx)
|
||||
let finishFirst: (answer: CommentAnswer) => void = () => {}
|
||||
rewriter.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
finishFirst = resolve
|
||||
}))
|
||||
rewriter.mockResolvedValueOnce({ body: '第二段。', sessionId: 'r2' })
|
||||
const other = { fsPath: '/workspace/b.ts', toString: () => 'file:///workspace/b.ts' } as Uri
|
||||
const events: RewriteEvent[] = []
|
||||
qa.onDidRewrite(event => events.push(event))
|
||||
const writeBack = vi.fn(async () => undefined)
|
||||
expect(await qa.rewrite(uri, request('1'))).toBeUndefined()
|
||||
expect(await qa.rewrite(other, request('2', { anchor: 'function g', writeBack }))).toBeUndefined()
|
||||
expect(rewriter).toHaveBeenCalledTimes(1)
|
||||
expect(events).toContainEqual({ owner: 'file:///workspace/b.ts', kind: 'update', ask: { askId: '2', anchor: 'function g', question: SKILL, text: '', queued: true } })
|
||||
expect(qa.view(other, file => file.fsPath, 'file').rewriteCurrent).toEqual([{ askId: '2', anchor: 'function g', question: SKILL, text: '', status: 'queued' }])
|
||||
|
||||
await qa.ask(uri, { askId: '3', anchor: 'function f', question: '为什么', prompt: '完整的', followUp: '短的', images: [] })
|
||||
await qa.ask(other, { askId: '4', anchor: 'function g', question: '为什么', prompt: '完整的', followUp: '短的', images: [] })
|
||||
expect(runner).toHaveBeenCalledTimes(2)
|
||||
|
||||
const second = ended(qa, '2')
|
||||
finishFirst({ body: '第一段。', sessionId: 'r1' })
|
||||
expect((await second).end).toEqual({ status: 'done', body: '第二段。' })
|
||||
expect(rewriter).toHaveBeenCalledTimes(2)
|
||||
expect(writeBack).toHaveBeenCalledWith('第二段。')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import { getSettings, onDidChangeSettings } from '../../settings/store'
|
||||
import { pathQaLocation } from '../editor/pathPreview'
|
||||
import { FolderStore, locate } from '../folderStore'
|
||||
import { AskProfiles } from './askProfiles'
|
||||
import { QaAsks, REWRITE_WORDING } from './asks'
|
||||
import { QaAsks, REWRITE_POLICY } from './asks'
|
||||
import { askClaude, askCommentBody } from './claudeAsk'
|
||||
import { describePath } from './pathAskDescribe'
|
||||
import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt'
|
||||
@@ -135,7 +135,7 @@ export class CodeQa implements Disposable {
|
||||
})
|
||||
})
|
||||
this.asks = new QaAsks(runner)
|
||||
this.rewrites = new QaAsks(rewriter, REWRITE_WORDING)
|
||||
this.rewrites = new QaAsks(rewriter, REWRITE_POLICY)
|
||||
}
|
||||
|
||||
/** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */
|
||||
|
||||
@@ -149,4 +149,13 @@ describe('块下面的重写记录(样子和问答一样)', () => {
|
||||
expect(written).not.toContain('role="alert"')
|
||||
expect(text(render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'stopped' })] }))).toContain('已停止,这次没存。')
|
||||
})
|
||||
|
||||
it('排着队:这一轮写着「排队中」、不转圈;输入框旁边的按钮是「取消排队」,点了按叫停发出去', () => {
|
||||
const html = render({ wording: REWRITE_THREAD, open: true, asks: [ask({ question: '/ecc:writing-code-comments web/a.tsx:3-40', status: 'queued' })] })
|
||||
expect(text(html)).toContain('排队中,前面的写完就开始')
|
||||
expect(html).not.toContain('cr-qa-spin')
|
||||
expect(html).toContain('data-tooltip="取消排队" aria-label="取消排队"')
|
||||
expect(html).not.toContain('aria-label="停止"')
|
||||
expect(html).not.toContain('aria-label="发送"')
|
||||
})
|
||||
})
|
||||
@@ -3,12 +3,12 @@ import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'rea
|
||||
import type { Ask } from './qa'
|
||||
import type { PendingImage } from './qaImages'
|
||||
import type { ProfileChoice, QaEntryView } from './types'
|
||||
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageSquareText, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, CircleStop, Hourglass, LoaderCircle, MessageSquareText, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { SelectMenu } from '@/components/ui/select-menu'
|
||||
import { renderMarkdown, renderMarkdownOnce } from './markdown'
|
||||
import { answerPreview, relativeTime } from './qa'
|
||||
import { answerPreview, relativeTime, unfinished } from './qa'
|
||||
import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages'
|
||||
|
||||
/** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */
|
||||
@@ -140,7 +140,7 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa
|
||||
)
|
||||
}
|
||||
|
||||
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 Markdown 排),在做的事、出错(重写注释没写进文件也算)、停止写在下面 */
|
||||
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 Markdown 排),排着队、在做的事、出错(重写注释没写进文件也算)、停止写在下面 */
|
||||
function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadWording, onOpenImage: (url: string) => void }): ReactElement {
|
||||
const running = ask.status === 'running'
|
||||
const label = ask.activity ?? (ask.text === '' ? '正在想…' : wording.answering)
|
||||
@@ -165,7 +165,13 @@ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadW
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{ask.error !== undefined && ask.status !== 'running' && (
|
||||
{ask.status === 'queued' && (
|
||||
<div className="cr-qa-status" role="status">
|
||||
<Hourglass size={12} strokeWidth={1.8} aria-hidden />
|
||||
排队中,前面的写完就开始
|
||||
</div>
|
||||
)}
|
||||
{ask.error !== undefined && !unfinished(ask) && (
|
||||
<div className="cr-qa-error" role="alert">
|
||||
<TriangleAlert size={13} strokeWidth={1.8} className="mt-[3px] shrink-0" aria-hidden />
|
||||
<span>{ask.error}</span>
|
||||
@@ -176,9 +182,10 @@ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadW
|
||||
)
|
||||
}
|
||||
|
||||
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
|
||||
function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
|
||||
running: Ask | undefined
|
||||
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止,排着队时换成取消排队。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
|
||||
function AskInput({ active, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
|
||||
/** 正在答或排着队的那一轮 */
|
||||
active: Ask | undefined
|
||||
/** 输入框里的提示(后面接着怎么发送、收起) */
|
||||
placeholder: string
|
||||
/** 输入框读屏用的名字 */
|
||||
@@ -197,6 +204,7 @@ function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAs
|
||||
focusToken: number
|
||||
}): ReactElement {
|
||||
const [value, setValue] = useState('')
|
||||
const stopLabel = active?.status === 'queued' ? '取消排队' : '停止'
|
||||
const [images, setImages] = useState<PendingImage[]>([])
|
||||
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
|
||||
const [notice, setNotice] = useState<string>()
|
||||
@@ -221,7 +229,7 @@ function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAs
|
||||
}
|
||||
const send = (): void => {
|
||||
const question = value.trim()
|
||||
if (question === '' || running)
|
||||
if (question === '' || active)
|
||||
return
|
||||
onAsk(question, images)
|
||||
setValue('')
|
||||
@@ -282,9 +290,9 @@ function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAs
|
||||
tooltip="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
|
||||
className="cr-qa-profile"
|
||||
/>
|
||||
{running
|
||||
{active
|
||||
? (
|
||||
<button type="button" className="cr-tool" data-tooltip="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
|
||||
<button type="button" className="cr-tool" data-tooltip={stopLabel} aria-label={stopLabel} onClick={() => onStop(active.askId)}>
|
||||
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
)
|
||||
@@ -357,7 +365,7 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
|
||||
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
|
||||
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} wording={wording} onOpenImage={setPreview} />)}
|
||||
<AskInput
|
||||
running={asks.find(ask => ask.status === 'running')}
|
||||
active={asks.find(unfinished)}
|
||||
placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)}
|
||||
label={wording.inputLabel}
|
||||
profiles={profiles}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BlockTreeState, RewriteTree } from './BlockTree'
|
||||
import type { ChatState } from './qa'
|
||||
import type { Ask, ChatState } from './qa'
|
||||
import type { BlockView, KindBadge } from './types'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -215,6 +215,18 @@ describe('「注释」一行和「重写注释」按钮', () => {
|
||||
expect(opened).toContain('aria-label="对这段注释的修改意见"')
|
||||
})
|
||||
|
||||
it('展开着又在写或排着队时按钮不能点;出错、停下的不挡', () => {
|
||||
const block = view('6', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })
|
||||
const button = (status: Ask['status']): string => {
|
||||
const rewrite = { ...EMPTY_CHAT, available: true, open: [block.anchor], asks: [{ askId: 'r1', anchor: block.anchor, question: 'q', text: '', status }] }
|
||||
const markup = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set(), rewrite)} />)
|
||||
return /<button[^>]*aria-label="按注释规范重写"[^>]*>/.exec(markup)?.[0] ?? ''
|
||||
}
|
||||
expect(button('running')).toContain('disabled')
|
||||
expect(button('queued')).toContain('disabled')
|
||||
expect(button('stopped')).not.toContain('disabled')
|
||||
})
|
||||
|
||||
it('还没有注释的块:按钮同样在右上角那排,叫「按注释规范写注释」;不能写注释的块没有按钮', () => {
|
||||
inToolbar(html(view('3', 'statement', 'save()', { commentTarget: { kind: 'insert', line: 0 } })), '按注释规范写注释')
|
||||
expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范')
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DiffText } from './DiffText'
|
||||
import { removedTitle } from './diffView'
|
||||
import { KindIcon } from './kindIcons'
|
||||
import { PropertyGrid, PropertyTable } from './PropertyGrid'
|
||||
import { asksOf, historyOf } from './qa'
|
||||
import { asksOf, historyOf, unfinished } from './qa'
|
||||
import { TextPreview } from './TextPreview'
|
||||
import { tableId } from './viewState'
|
||||
|
||||
@@ -272,7 +272,7 @@ function callTreeTool(block: BlockView, tree: BlockTreeState) {
|
||||
|
||||
/**
|
||||
* 这块的「重写注释」按钮:能写注释的块才有。重写过、记录收着时点了先展开记录(好接着说);展开着、或还没重写过时点了按技能命令重写。
|
||||
* 展开着又正在写时不能再点。
|
||||
* 展开着又正在写、排着队时不能再点。
|
||||
*/
|
||||
function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | undefined {
|
||||
const { edit, rewrite } = tree
|
||||
@@ -284,7 +284,7 @@ function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | unde
|
||||
const reopen = !open && (asks.length > 0 || historyOf(rewrite.state, anchor).length > 0)
|
||||
return {
|
||||
fresh: block.commentTarget.kind === 'insert',
|
||||
rewriting: open && asks.some(ask => ask.status === 'running'),
|
||||
rewriting: open && asks.some(unfinished),
|
||||
reopen,
|
||||
onRewrite: () => reopen ? rewrite.onOpen(anchor) : rewrite.onRewrite(anchor),
|
||||
}
|
||||
|
||||
@@ -130,6 +130,25 @@ describe('重写注释的状态:和问 AI 同一套', () => {
|
||||
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' })
|
||||
expect(asksOf(state, IF)[0]).toMatchObject({ question: SKILL, status: 'running' })
|
||||
})
|
||||
|
||||
it('扩展端说排着队就显示排队中,轮到了变成在写;收起这块时排着的和在写的一样留着;取消了照停下记', () => {
|
||||
let state = chatReducer(EMPTY_CHAT, { type: 'ask', askId: 'r1', anchor: IF, question: '', images: [] })
|
||||
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '', queued: true })
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'r1', anchor: IF, question: SKILL, images: [], text: '', status: 'queued' }])
|
||||
state = chatReducer(state, { type: 'close', anchor: IF })
|
||||
expect(asksOf(state, IF).map(ask => ask.status)).toEqual(['queued'])
|
||||
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' })
|
||||
expect(asksOf(state, IF)[0].status).toBe('running')
|
||||
state = chatReducer(state, { type: 'answerEnd', end: { askId: 'r1', status: 'stopped' } })
|
||||
expect(asksOf(state, IF)[0].status).toBe('stopped')
|
||||
expect(chatReducer(state, { type: 'answer', askId: 'r1', text: '晚到的', queued: true })).toEqual(state)
|
||||
})
|
||||
|
||||
it('页面重新加载后:扩展端排着队的那一轮照样显示排队中、展开它那块', () => {
|
||||
const state = chatReducer(EMPTY_CHAT, { type: 'sync', available: true, threads: [], current: [{ askId: 'r2', anchor: IF, question: SKILL, text: '', status: 'queued' }] })
|
||||
expect(state.open).toEqual([IF])
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'r2', anchor: IF, question: SKILL, text: '', status: 'queued' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('问的时间', () => {
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface Ask {
|
||||
text: string
|
||||
/** 正在做的事:「读取 routes.py」 */
|
||||
activity?: string
|
||||
status: 'running' | 'done' | 'failed' | 'stopped'
|
||||
/** queued 是重写注释排着队,前面的写完才开始 */
|
||||
status: 'queued' | 'running' | 'done' | 'failed' | 'stopped'
|
||||
/** failed 时给人看的原因;重写注释 done 了但没写进文件时也有 */
|
||||
error?: string
|
||||
/** done 时存下的那条:历史里不再重复显示它 */
|
||||
@@ -62,11 +63,11 @@ export const EMPTY_QA: QaState = {
|
||||
}
|
||||
|
||||
/**
|
||||
* sync:扩展端发来的记录和它记着的当前一轮;answer:askId 那一轮的回答长了(question 是扩展端报上来的这一轮的话,点按钮那一轮开始时页面还不知道)
|
||||
* sync:扩展端发来的记录和它记着的当前一轮;answer:askId 那一轮的回答长了、排上队了(queued)或轮到了(question 是扩展端报上来的这一轮的话,点按钮那一轮开始时页面还不知道)
|
||||
*/
|
||||
export type ChatAction
|
||||
= | { type: 'sync', available: boolean, threads: QaThreadView[], current: AskRound[] }
|
||||
| { type: 'answer', askId: string, text: string, activity?: string, question?: string }
|
||||
| { type: 'answer', askId: string, text: string, activity?: string, question?: string, queued?: boolean }
|
||||
| { type: 'answerEnd', end: RoundEnd }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string, images: string[] }
|
||||
| { type: 'open', anchor: string }
|
||||
@@ -98,8 +99,8 @@ export function chatReducer<S extends ChatState>(state: S, action: ChatAction):
|
||||
case 'answer':
|
||||
return {
|
||||
...state,
|
||||
asks: state.asks.map(ask => ask.askId === action.askId && ask.status === 'running'
|
||||
? { ...ask, text: action.text, activity: action.activity, ...(action.question && { question: action.question }) }
|
||||
asks: state.asks.map(ask => ask.askId === action.askId && unfinished(ask)
|
||||
? { ...ask, text: action.text, activity: action.activity, ...(action.question && { question: action.question }), status: action.queued ? 'queued' : 'running' }
|
||||
: ask),
|
||||
}
|
||||
case 'answerEnd': {
|
||||
@@ -120,10 +121,10 @@ export function chatReducer<S extends ChatState>(state: S, action: ChatAction):
|
||||
case 'open':
|
||||
return { ...state, open: withOpen(state.open, action.anchor) }
|
||||
case 'close':
|
||||
// 收起就结束这一轮:答完的都回到灰字历史里;还在进行的留着,答完照样存
|
||||
// 收起就结束这一轮:答完的都回到灰字历史里;还在进行、排着队的留着,答完照样存
|
||||
return {
|
||||
...state,
|
||||
asks: state.asks.filter(ask => ask.anchor !== action.anchor || ask.status === 'running'),
|
||||
asks: state.asks.filter(ask => ask.anchor !== action.anchor || unfinished(ask)),
|
||||
open: state.open.filter(anchor => anchor !== action.anchor),
|
||||
}
|
||||
}
|
||||
@@ -142,6 +143,11 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
|
||||
}
|
||||
}
|
||||
|
||||
/** 这一轮还没结束:在进行,或排着队 */
|
||||
export function unfinished(ask: Pick<Ask, 'status'>): boolean {
|
||||
return ask.status === 'running' || ask.status === 'queued'
|
||||
}
|
||||
|
||||
/** 当前这一轮在 anchor 那块说的 */
|
||||
export function asksOf(state: ChatState, anchor: string): Ask[] {
|
||||
return state.asks.filter(ask => ask.anchor === anchor)
|
||||
|
||||
@@ -285,7 +285,7 @@ export interface QaThreadView {
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展端记着的当前一轮:还在进行的,或出错、被叫停没记下留着的。text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」),
|
||||
* 扩展端记着的当前一轮:排着队的(重写注释一次只跑一段)、还在进行的,或出错、被叫停没记下留着的。text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」),
|
||||
* error 是 failed 时给人看的原因
|
||||
*/
|
||||
export interface AskRound {
|
||||
@@ -294,7 +294,7 @@ export interface AskRound {
|
||||
question: string
|
||||
text: string
|
||||
activity?: string
|
||||
status: 'running' | 'failed' | 'stopped'
|
||||
status: 'queued' | 'running' | 'failed' | 'stopped'
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ export interface StickyScrollSettings {
|
||||
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
* answerEnd:askId 那个问题答完了,见 AnswerEnd
|
||||
* rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
|
||||
* rewriteProgress:askId 那一轮重写排上队了(queued)、开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
|
||||
* rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改
|
||||
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
|
||||
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
|
||||
@@ -527,7 +527,7 @@ export type ExtensionToWebview
|
||||
| ({ type: 'askProfiles' } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| AnswerEnd
|
||||
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string }
|
||||
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true }
|
||||
| RewriteEnd
|
||||
| { type: 'diff', diff: DocumentDiff | null }
|
||||
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
|
||||
|
||||
@@ -2,21 +2,21 @@ import type { RewriteTree } from './BlockTree'
|
||||
import type { ChatState } from './qa'
|
||||
import type { ProfileChoice } from './types'
|
||||
import { useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
import { chatReducer, EMPTY_CHAT, newAskId } from './qa'
|
||||
import { chatReducer, EMPTY_CHAT, newAskId, unfinished } from './qa'
|
||||
import { toAskImage } from './qaImages'
|
||||
import { ANSWER_FRAME_MS } from './useQa'
|
||||
import { onMessage, postMessage } from './vscode'
|
||||
|
||||
/**
|
||||
* 按注释规范重写:收扩展端的重写记录、当前一轮的进展,给块树用的状态和动作。和问 AI 同一套:
|
||||
* 进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉和问 AI 共用。
|
||||
* 排着队的、进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉和问 AI 共用。
|
||||
*/
|
||||
export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string) => void): RewriteTree {
|
||||
const [state, dispatch] = useReducer(chatReducer<ChatState>, EMPTY_CHAT)
|
||||
|
||||
useEffect(() => {
|
||||
/** 攒着没画的进展:askId → 最新的一份 */
|
||||
const pending = new Map<string, { question: string, text: string, activity?: string }>()
|
||||
const pending = new Map<string, { question: string, text: string, activity?: string, queued?: boolean }>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const flush = (): void => {
|
||||
timer = undefined
|
||||
@@ -30,7 +30,7 @@ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string
|
||||
dispatch({ type: 'sync', available: msg.available, threads: msg.rewrites, current: msg.rewriteCurrent })
|
||||
return
|
||||
case 'rewriteProgress':
|
||||
pending.set(msg.askId, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }) })
|
||||
pending.set(msg.askId, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }), ...(msg.queued && { queued: true }) })
|
||||
timer ??= setTimeout(flush, ANSWER_FRAME_MS)
|
||||
return
|
||||
case 'rewriteEnd':
|
||||
@@ -45,7 +45,7 @@ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 动作里读眼下的状态(这块是不是已经在写) */
|
||||
/** 动作里读眼下的状态(这块是不是已经在写或排着队) */
|
||||
const current = useRef(state)
|
||||
current.current = state
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string
|
||||
profiles,
|
||||
onSelectProfile,
|
||||
onRewrite: (anchor) => {
|
||||
if (current.current.asks.some(ask => ask.anchor === anchor && ask.status === 'running'))
|
||||
if (current.current.asks.some(ask => ask.anchor === anchor && unfinished(ask)))
|
||||
return
|
||||
const askId = newAskId()
|
||||
// 那句技能命令由扩展端拼好,开始后随 rewriteProgress 报上来
|
||||
|
||||
Reference in new issue
Block a user