✨ feat(vscode): 代码阅读加「问 AI」,块下面问 Claude Code,问答存进 .spx 挂在块下
This commit is contained in:
1 parent
badc722640
commit
fb53bb727c
34 files changed
+2726
-43
No files matched your search
@@ -4,12 +4,15 @@ 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'
|
||||
import { useReveal } from './useReveal'
|
||||
import { useScrollPosition } from './useScrollPosition'
|
||||
import { collapsedIds } from './viewState'
|
||||
@@ -25,6 +28,8 @@ function savedBlockModes(): BlockModes {
|
||||
}
|
||||
|
||||
export function App(): ReactElement | null {
|
||||
// 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答
|
||||
const qa = useQa()
|
||||
const [doc, setDoc] = useState<DocumentMessage | null>(null)
|
||||
const [view, setView] = useState<BlockModes>(savedBlockModes)
|
||||
const [focusId, setFocusId] = useState<string | null>(null)
|
||||
@@ -80,6 +85,9 @@ 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),
|
||||
@@ -87,7 +95,7 @@ export function App(): ReactElement | null {
|
||||
hidden: filter.hidden,
|
||||
temporary: filter.temporary,
|
||||
covered,
|
||||
kindName: kind => kindNames.get(kind) ?? kind,
|
||||
kindName,
|
||||
focusId,
|
||||
onFocused,
|
||||
onToggleEdit: (id) => {
|
||||
@@ -98,7 +106,8 @@ export function App(): ReactElement | null {
|
||||
}))
|
||||
},
|
||||
onToggleFold: id => setFolded([id], !collapsed.has(id)),
|
||||
}, [doc, view, collapsed, filter, covered, kindNames, focusId, onFocused, setFolded])
|
||||
...(qa.state.available && { qa }),
|
||||
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa])
|
||||
|
||||
if (!doc || !tree)
|
||||
return null
|
||||
@@ -128,6 +137,7 @@ 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))
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { AskThreadProps } from './AskThread'
|
||||
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'
|
||||
|
||||
const NOW = new Date(2026, 8, 26, 15, 0).getTime()
|
||||
|
||||
const HISTORY: QaEntryView[] = [
|
||||
{ id: 'e1', question: '为什么普通用户不能看别人的资料?', answer: '这是**权限控制**:不是管理员、又不是查自己时抛 `Forbidden`。', createdAt: new Date(2026, 8, 23, 10, 0).toISOString() },
|
||||
{ id: 'e2', question: 'UserRole 还有哪些角色?', answer: '只有 ADMIN 和 USER。', createdAt: new Date(2026, 8, 25, 10, 0).toISOString() },
|
||||
]
|
||||
|
||||
function render(props: Partial<AskThreadProps>): string {
|
||||
return renderToStaticMarkup(<AskThread history={[]} asks={[]} open={false} now={NOW} onAsk={() => {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />)
|
||||
}
|
||||
|
||||
const text = (html: string): string => html.replace(/<[^>]*>/g, '')
|
||||
|
||||
function ask(overrides: Partial<Ask>): Ask {
|
||||
return { askId: 'a1', anchor: 'x', question: 'Forbidden 抛出去以后前端收到什么?', text: '', status: 'running', ...overrides }
|
||||
}
|
||||
|
||||
describe('块下面的问答区', () => {
|
||||
it('没问过、没展开:什么都不画', () => {
|
||||
expect(render({})).toBe('')
|
||||
})
|
||||
|
||||
it('问过、没展开:只露一行「问过 N 次」,点开才看历史', () => {
|
||||
const html = render({ history: HISTORY })
|
||||
expect(html).toContain('data-collapsed')
|
||||
expect(text(html)).toBe('问过 2 次')
|
||||
expect(html).toContain('aria-expanded="false"')
|
||||
expect(html).not.toContain('<textarea')
|
||||
})
|
||||
|
||||
it('展开:灰字的历史(问题、时间、「已理解,删除」、回答前两行),当前这一轮照常颜色,底下输入框', () => {
|
||||
const html = render({ open: true, history: HISTORY, asks: [ask({ text: '`Forbidden` 定义在 **core**', activity: '读取 exceptions.py' })] })
|
||||
expect(html.match(/data-history=""/g)).toHaveLength(2)
|
||||
expect(text(html)).toContain('为什么普通用户不能看别人的资料?3 天前')
|
||||
expect(text(html)).toContain('UserRole 还有哪些角色?昨天')
|
||||
expect(html.match(/title="已理解,删除" aria-label="已理解,删除"/g)).toHaveLength(2)
|
||||
expect(text(html)).toContain('这是权限控制:不是管理员、又不是查自己时抛 Forbidden。')
|
||||
expect(html).toContain('<hr class="cr-qa-divider"/>')
|
||||
expect(html).toContain('data-current=""')
|
||||
expect(html).toContain('data-streaming=""><p><code>Forbidden</code> 定义在 <strong>core</strong></p>')
|
||||
expect(text(html)).toContain('读取 exceptions.py')
|
||||
expect(html).toContain('placeholder="继续问… Enter 发送,Esc 收起"')
|
||||
expect(html).toContain('title="停止" aria-label="停止"')
|
||||
expect(html).not.toContain('aria-label="发送"')
|
||||
})
|
||||
|
||||
it('还没问过:输入框提示问这块代码,发送按钮在没字时不能点;答完的回答按 Markdown 排、不再闪光标', () => {
|
||||
expect(render({ open: true })).toContain('placeholder="问这块代码… Enter 发送,Esc 收起"')
|
||||
expect(render({ open: true })).toMatch(/aria-label="发送" disabled=""/)
|
||||
const done = render({ open: true, asks: [ask({ status: 'done', text: '答完了' })] })
|
||||
expect(done).toContain('<div class="cr-markdown cr-qa-answer"><p>答完了</p>')
|
||||
expect(done).not.toContain('data-streaming')
|
||||
expect(done).not.toContain('role="status"')
|
||||
})
|
||||
|
||||
it('出错、停止写在这一问下面', () => {
|
||||
const failed = render({ open: true, asks: [ask({ status: 'failed', error: 'Claude Code 没登录或登录过期了' })] })
|
||||
expect(failed).toContain('role="alert"')
|
||||
expect(text(failed)).toContain('Claude Code 没登录或登录过期了')
|
||||
expect(text(render({ open: true, asks: [ask({ status: 'stopped', text: '一半' })] }))).toContain('已停止,这次没存。')
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { KeyboardEvent, ReactElement } from 'react'
|
||||
import type { Ask } from './qa'
|
||||
import type { QaEntryView, QaThreadView } 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'
|
||||
|
||||
/** 问过的一次,灰字:问题、时间、「已理解,删除」,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
|
||||
function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number, onDelete: () => void }): ReactElement {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const toggle = (): void => setExpanded(value => !value)
|
||||
return (
|
||||
<div className="cr-qa-entry" data-history="">
|
||||
<div className="cr-qa-head">
|
||||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||||
<button type="button" className="cr-qa-question" aria-expanded={expanded} title={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
|
||||
{entry.question}
|
||||
</button>
|
||||
<time className="cr-qa-when" dateTime={entry.createdAt} title={new Date(entry.createdAt).toLocaleString()}>
|
||||
{relativeTime(entry.createdAt, now)}
|
||||
</time>
|
||||
<button type="button" className="cr-tool cr-qa-delete" title="已理解,删除" aria-label="已理解,删除" onClick={onDelete}>
|
||||
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{expanded
|
||||
? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer) }} />
|
||||
: (
|
||||
<button type="button" className="cr-qa-preview" onClick={toggle}>
|
||||
{answerPreview(entry.answer)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */
|
||||
function CurrentItem({ ask }: { ask: Ask }): ReactElement {
|
||||
const running = ask.status === 'running'
|
||||
return (
|
||||
<div className="cr-qa-entry" data-current="">
|
||||
<div className="cr-qa-head">
|
||||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||||
<span className="cr-qa-question">{ask.question}</span>
|
||||
</div>
|
||||
{ask.text !== '' && (
|
||||
<div
|
||||
className="cr-markdown cr-qa-answer"
|
||||
data-streaming={running ? '' : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text) }}
|
||||
/>
|
||||
)}
|
||||
{running && (
|
||||
<div className="cr-qa-status" role="status">
|
||||
<LoaderCircle size={12} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
|
||||
{ask.activity ?? (ask.text === '' ? '正在想…' : '正在回答…')}
|
||||
</div>
|
||||
)}
|
||||
{ask.status === 'failed' && (
|
||||
<div className="cr-qa-error" role="alert">
|
||||
<TriangleAlert size={13} strokeWidth={1.8} className="mt-[3px] shrink-0" aria-hidden />
|
||||
<span>{ask.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{ask.status === 'stopped' && <div className="cr-qa-status">已停止,这次没存。</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止 */
|
||||
function AskInput({ running, first, onAsk, onStop, onClose }: {
|
||||
running: Ask | undefined
|
||||
/** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */
|
||||
first: boolean
|
||||
onAsk: (question: string) => void
|
||||
onStop: (askId: string) => void
|
||||
onClose: () => void
|
||||
}): ReactElement {
|
||||
const [value, setValue] = useState('')
|
||||
const send = (): void => {
|
||||
const question = value.trim()
|
||||
if (question === '' || running)
|
||||
return
|
||||
onAsk(question)
|
||||
setValue('')
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
// 输入法选字时的回车不算发送
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
send()
|
||||
}
|
||||
else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="cr-qa-input">
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder={`${first ? '问这块代码…' : '继续问…'} Enter 发送,Esc 收起`}
|
||||
aria-label="问 AI 的问题"
|
||||
onChange={event => setValue(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{running
|
||||
? (
|
||||
<button type="button" className="cr-tool" title="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
|
||||
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button type="button" className="cr-tool" title="发送(Enter)" aria-label="发送" disabled={value.trim() === ''} onClick={send}>
|
||||
<SendHorizontal size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AskThreadProps {
|
||||
history: QaEntryView[]
|
||||
asks: Ask[]
|
||||
/** 点了「问 AI」:展开全部历史、当前这一轮和输入框 */
|
||||
open: boolean
|
||||
now?: number
|
||||
onAsk: (question: string) => void
|
||||
onStop: (askId: string) => void
|
||||
onDelete: (entryId: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
|
||||
* 没展开但问过的块只露一行「问过 N 次」,点开看灰字历史,读代码时一眼知道这里问过什么。
|
||||
*/
|
||||
export function AskThread({ history, asks, open, now = Date.now(), onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
if (!open) {
|
||||
if (history.length === 0)
|
||||
return null
|
||||
const Chevron = showHistory ? ChevronDown : ChevronRight
|
||||
return (
|
||||
<div className="cr-qa" data-collapsed="">
|
||||
<button type="button" className="cr-qa-summary" aria-expanded={showHistory} onClick={() => setShowHistory(value => !value)}>
|
||||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||||
{`问过 ${history.length} 次`}
|
||||
<Chevron size={12} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
{showHistory && history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<section className="cr-qa" aria-label="问 AI">
|
||||
{history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} />)}
|
||||
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
|
||||
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} />)}
|
||||
<AskInput
|
||||
running={asks.find(ask => ask.status === 'running')}
|
||||
first={history.length === 0 && asks.length === 0}
|
||||
onAsk={onAsk}
|
||||
onStop={onStop}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockTools } from './BlockTools'
|
||||
|
||||
const render = (editing: boolean): string => renderToStaticMarkup(<BlockTools editing={editing} onToggleEdit={() => {}} />)
|
||||
function render(editing: boolean, ask?: { asking: boolean }): string {
|
||||
return renderToStaticMarkup(<BlockTools editing={editing} onToggleEdit={() => {}} {...(ask && { asking: ask.asking, onToggleAsk: () => {} })} />)
|
||||
}
|
||||
|
||||
/** 标签之间的字:图标(svg)没有字,按钮上有字就会出现在这里 */
|
||||
const visibleText = (html: string): string => html.replace(/<[^>]*>/g, '').trim()
|
||||
@@ -17,4 +19,15 @@ describe('块右上角的图标按钮', () => {
|
||||
expect(html).toMatch(/<button[^>]*><svg/)
|
||||
expect(html.includes('data-active')).toBe(editing)
|
||||
})
|
||||
|
||||
it.each([false, true])('「问 AI」排在编辑前面,只有图标;问答区展开着 = %s 时带按下标记', (asking) => {
|
||||
const html = render(false, { asking })
|
||||
expect(visibleText(html)).toBe('')
|
||||
const buttons = [...html.matchAll(/<button[^>]*>/g)].map(match => match[0])
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0]).toContain('title="问 AI" aria-label="问 AI"')
|
||||
expect(buttons[0]).toContain(`aria-expanded="${asking}"`)
|
||||
expect(buttons[0].includes('data-active')).toBe(asking)
|
||||
expect(buttons[1]).toContain('title="编辑"')
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,36 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { Eye, Pencil } from 'lucide-react'
|
||||
import { Eye, Pencil, Sparkles } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(title)和读屏用的 aria-label 里。
|
||||
* 眼下只有「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
|
||||
* 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。
|
||||
* 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
|
||||
*/
|
||||
export function BlockTools({ editing, onToggleEdit }: { editing: boolean, onToggleEdit: () => void }): ReactElement {
|
||||
export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk }: {
|
||||
editing: boolean
|
||||
onToggleEdit: () => void
|
||||
/** 块下面的问答区展开着 */
|
||||
asking?: boolean
|
||||
/** 没给就不显示「问 AI」(文件不在工作区文件夹里,问答没地方存) */
|
||||
onToggleAsk?: () => void
|
||||
}): ReactElement {
|
||||
const label = editing ? '预览' : '编辑'
|
||||
const Icon = editing ? Eye : Pencil
|
||||
return (
|
||||
<div className="flex h-5 shrink-0 items-center gap-0.5" role="group" aria-label="块操作">
|
||||
{onToggleAsk && (
|
||||
<button
|
||||
type="button"
|
||||
className="cr-tool flex h-5 w-6 items-center justify-center rounded"
|
||||
data-active={asking ? '' : undefined}
|
||||
title="问 AI"
|
||||
aria-label="问 AI"
|
||||
aria-expanded={asking === true}
|
||||
onClick={onToggleAsk}
|
||||
>
|
||||
<Sparkles size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="cr-tool flex h-5 w-6 items-center justify-center rounded"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { CSSProperties, ReactElement } from 'react'
|
||||
import type { QaState } from './qa'
|
||||
import type { BlockView } from './types'
|
||||
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { AskThread } from './AskThread'
|
||||
import { BlockTools } from './BlockTools'
|
||||
import { CodeEditor } from './CodeEditor'
|
||||
import { PropertyGrid } from './PropertyGrid'
|
||||
import { asksOf, historyOf } from './qa'
|
||||
import { TextPreview } from './TextPreview'
|
||||
|
||||
export interface BlockTreeState {
|
||||
@@ -25,6 +28,18 @@ export interface BlockTreeState {
|
||||
onFocused: () => void
|
||||
onToggleEdit: (id: string) => void
|
||||
onToggleFold: (id: string) => void
|
||||
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
|
||||
qa?: QaTree
|
||||
}
|
||||
|
||||
/** 问 AI 的状态和动作,块按自己的记号(anchor)取 */
|
||||
export interface QaTree {
|
||||
state: QaState
|
||||
onToggle: (anchor: string) => void
|
||||
onClose: (anchor: string) => void
|
||||
onAsk: (anchor: string, question: string) => void
|
||||
onStop: (askId: string) => void
|
||||
onDelete: (anchor: string, entryId: string) => void
|
||||
}
|
||||
|
||||
/** 块下面有没有画出来的子块:子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */
|
||||
@@ -64,6 +79,7 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
|
||||
{blocks.filter(block => shown(block, tree)).map(block => (
|
||||
<Fragment key={block.id}>
|
||||
<BlockCard block={block} tree={tree} />
|
||||
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />}
|
||||
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
|
||||
<BlockLevel blocks={block.children} owner={block} tree={tree} />
|
||||
)}
|
||||
@@ -74,6 +90,21 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
|
||||
)
|
||||
}
|
||||
|
||||
/** 块下面的问答区,紧跟在块后面、子块前面;输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */
|
||||
function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
|
||||
return (
|
||||
<AskThread
|
||||
history={historyOf(qa.state, anchor)}
|
||||
asks={asksOf(qa.state, anchor)}
|
||||
open={qa.state.open.includes(anchor)}
|
||||
onAsk={question => qa.onAsk(anchor, question)}
|
||||
onStop={qa.onStop}
|
||||
onDelete={entryId => qa.onDelete(anchor, entryId)}
|
||||
onClose={() => qa.onClose(anchor)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文,带说明的译文下面接着说明。 */
|
||||
function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
|
||||
if ('properties' in block)
|
||||
@@ -135,7 +166,12 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
已被筛选隐藏,临时显示
|
||||
</span>
|
||||
)}
|
||||
<BlockTools editing={editing} onToggleEdit={() => tree.onToggleEdit(block.id)} />
|
||||
<BlockTools
|
||||
editing={editing}
|
||||
onToggleEdit={() => tree.onToggleEdit(block.id)}
|
||||
asking={tree.qa?.state.open.includes(block.anchor)}
|
||||
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -106,12 +106,13 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* 层级竖线:悬停一个块时,它子层的竖线换成它的种类色,标出这个块管到哪里为止 */
|
||||
/* 层级竖线:悬停一个块时,它子层的竖线换成它的种类色,标出这个块管到哪里为止(块和子层中间隔着问答区也算) */
|
||||
.cr-guide {
|
||||
background: var(--vscode-editorIndentGuide-background1);
|
||||
transition: background-color 100ms ease-out;
|
||||
}
|
||||
.cr-block:hover + .cr-level > * > .cr-guide {
|
||||
.cr-block:hover + .cr-level > * > .cr-guide,
|
||||
.cr-block:hover + .cr-qa + .cr-level > * > .cr-guide {
|
||||
background: var(--kind);
|
||||
}
|
||||
|
||||
@@ -485,3 +486,219 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder) 15%, transparent);
|
||||
}
|
||||
|
||||
/* 问 AI:块下面的问答区,左边一条链接色竖线;问过的灰字,当前这一轮照常颜色 */
|
||||
.cr-qa {
|
||||
--qa-accent: var(--vscode-textLink-foreground);
|
||||
--qa-dim: color-mix(in srgb, var(--vscode-descriptionForeground) 75%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 6px 10px 6px 12px;
|
||||
border-left: 2px solid color-mix(in srgb, var(--qa-accent) 50%, transparent);
|
||||
font-family: var(--vscode-font-family, system-ui, sans-serif), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: 12.5px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.cr-qa[data-collapsed] {
|
||||
gap: 6px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.cr-qa-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.cr-qa-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cr-qa-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--qa-dim);
|
||||
}
|
||||
.cr-qa-entry[data-current] .cr-qa-icon {
|
||||
color: var(--qa-accent);
|
||||
}
|
||||
.cr-qa-question {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.cr-qa-entry[data-history] .cr-qa-question {
|
||||
overflow: hidden;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cr-qa-entry[data-current] .cr-qa-question {
|
||||
color: var(--vscode-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
.cr-qa-when {
|
||||
flex-shrink: 0;
|
||||
color: var(--qa-dim);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.cr-qa-delete {
|
||||
display: flex;
|
||||
width: 22px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: var(--qa-dim);
|
||||
}
|
||||
.cr-qa-delete:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
/* 回答的前两行,和回答正文一样往右缩到问题下面 */
|
||||
.cr-qa-preview {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
padding-left: 18px;
|
||||
color: var(--qa-dim);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.cr-qa-answer {
|
||||
padding-left: 18px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.cr-qa .cr-markdown.cr-qa-answer {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
.cr-qa .cr-qa-entry[data-history] .cr-markdown.cr-qa-answer {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
/* 回答还在长:最后一段末尾一个闪的光标 */
|
||||
.cr-qa-answer[data-streaming] > :last-child::after {
|
||||
content: "▍";
|
||||
margin-left: 1px;
|
||||
color: var(--qa-accent);
|
||||
animation: cr-qa-blink 1s steps(1) infinite;
|
||||
}
|
||||
@keyframes cr-qa-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.cr-qa-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 18px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.cr-qa-spin {
|
||||
animation: cr-qa-spin 900ms linear infinite;
|
||||
}
|
||||
@keyframes cr-qa-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.cr-qa-error {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: 18px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--vscode-inputValidation-errorBorder);
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-inputValidation-errorBackground);
|
||||
color: var(--vscode-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cr-qa-error > svg {
|
||||
color: var(--vscode-errorForeground);
|
||||
}
|
||||
.cr-qa-divider {
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
||||
}
|
||||
.cr-qa-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
padding: 4px 4px 4px 8px;
|
||||
border: 1px solid var(--vscode-input-border, color-mix(in srgb, var(--vscode-foreground) 20%, transparent));
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-input-background);
|
||||
}
|
||||
.cr-qa-input:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.cr-qa-input textarea {
|
||||
min-width: 0;
|
||||
max-height: 160px;
|
||||
flex: 1;
|
||||
padding: 1px 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-input-foreground);
|
||||
font: inherit;
|
||||
resize: none;
|
||||
field-sizing: content;
|
||||
}
|
||||
.cr-qa-input textarea::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
.cr-qa-input .cr-tool {
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cr-qa-input .cr-tool:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.cr-qa-summary {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cr-qa-summary:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
.cr-qa-summary:focus-visible,
|
||||
.cr-qa-question:focus-visible,
|
||||
.cr-qa-preview:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
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 {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
/**
|
||||
* 注释、函数和类说明的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
|
||||
* 注释、函数和类说明、问 AI 的回答的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
|
||||
* 源码里的 HTML 当文字显示;链接只认 http / https / mailto,别的写法照原文显示——
|
||||
* webview 会拦下 <a> 的点击交给 VS Code 在外部打开。图片不渲染:webview 的 CSP 不放行外部图片。
|
||||
*/
|
||||
@@ -12,6 +12,11 @@ md.validateLink = url => /^(?:https?|mailto):/i.test(url)
|
||||
const CACHE_SIZE = 500
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
/** 还在变的文字(逐字长出来的回答)→ HTML:每次都现渲染,不进缓存,免得半截的文字把缓存挤满。 */
|
||||
export function renderMarkdownOnce(text: string): string {
|
||||
return md.render(text)
|
||||
}
|
||||
|
||||
/** Markdown 原文 → HTML;同一段文字只渲染一次。 */
|
||||
export function renderMarkdown(text: string): string {
|
||||
const html = cache.get(text) ?? md.render(text)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { QaAction, QaState } from './qa'
|
||||
import type { BlockView, QaMessage, QaThreadView } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { answerPreview, asksOf, EMPTY_QA, historyOf, orphanThreads, qaReducer, relativeTime, resumeFor, whereLabel } 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' })) }
|
||||
}
|
||||
|
||||
function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage {
|
||||
return { type: 'qa', available: true, threads, running }
|
||||
}
|
||||
|
||||
function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaAction {
|
||||
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)
|
||||
}
|
||||
|
||||
describe('问 AI 的状态', () => {
|
||||
it('问一次:展开这块,当前这一轮多一条在答的;回答长了跟着变;答完记下存成的那条,历史里不再重复', () => {
|
||||
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?' })
|
||||
expect(state.open).toEqual([IF])
|
||||
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, qa([thread(IF, 'old', 'new')]))
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new', sessionId: 's1' }])
|
||||
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: '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])
|
||||
})
|
||||
|
||||
it('页面重新加载后:扩展端还在答的接上、展开它那块;已知道的不重复', () => {
|
||||
const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半' }]
|
||||
let state = run(qa([], running))
|
||||
expect(state.open).toEqual([IF])
|
||||
expect(asksOf(state, IF)).toEqual([{ ...running[0], status: 'running' }])
|
||||
state = qaReducer(state, qa([], running))
|
||||
expect(state.asks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('停止、失败照记;不认识的问题的消息不理', () => {
|
||||
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' }, { type: 'answer', askId: 'a1', text: '一半' })
|
||||
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'stopped' } })
|
||||
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'stopped', text: '一半' })
|
||||
expect(qaReducer(state, { type: 'answer', askId: 'a1', text: '晚到的' })).toEqual(state)
|
||||
expect(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'nope', status: 'done' } })).toEqual(state)
|
||||
})
|
||||
})
|
||||
|
||||
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 =>
|
||||
new Date(year, month, day, hour, minute, second).toISOString()
|
||||
|
||||
it('刚刚、几分钟前、几小时前(同一天)、昨天、几天前、一个月以前写日期', () => {
|
||||
expect(relativeTime(at(2026, 8, 26, 14, 59, 30), now)).toBe('刚刚')
|
||||
expect(relativeTime(at(2026, 8, 26, 14, 55), now)).toBe('5 分钟前')
|
||||
expect(relativeTime(at(2026, 8, 26, 9, 30), now)).toBe('5 小时前')
|
||||
expect(relativeTime(at(2026, 8, 25, 23, 0), now)).toBe('昨天')
|
||||
expect(relativeTime(at(2026, 8, 23, 10, 0), now)).toBe('3 天前')
|
||||
expect(relativeTime(at(2026, 7, 1, 10, 0), now)).toBe('2026-08-01')
|
||||
expect(relativeTime('坏的', now)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('回答的前两行预览', () => {
|
||||
it('去掉 Markdown 记号连成一段', () => {
|
||||
expect(answerPreview('## 结论\n\n`Forbidden` 定义在 **core/exceptions.py**,见 [文档](https://x.y)。\n\n- 第一条\n1. 第二条\n\n```py\nraise Forbidden()\n```'))
|
||||
.toBe('结论 Forbidden 定义在 core/exceptions.py,见 文档。 第一条 第二条 raise Forbidden()')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 「问 AI」在页面上的状态和规则(不碰 DOM):问答按块的记号(BlockView.anchor)挂到块下面;
|
||||
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答是「找不到原来位置的」。
|
||||
*/
|
||||
|
||||
import type { AnchorStep, AnswerEnd, BlockView, QaEntryView, QaMessage, QaThreadView } from './types'
|
||||
|
||||
/** 这次打开以来问的一个问题 */
|
||||
export interface Ask {
|
||||
askId: string
|
||||
anchor: string
|
||||
question: string
|
||||
/** 回答到现在为止的全文(Markdown) */
|
||||
text: string
|
||||
/** 正在做的事:「读取 routes.py」 */
|
||||
activity?: string
|
||||
status: 'running' | 'done' | 'failed' | 'stopped'
|
||||
/** failed 时给人看的原因 */
|
||||
error?: string
|
||||
/** done 时存下的那条:历史里不再重复显示它 */
|
||||
entryId?: string
|
||||
/** done 时的会话 id:接着追问用 */
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export interface QaState {
|
||||
/** 文件不在工作区文件夹里时问答没地方存,不给「问 AI」 */
|
||||
available: boolean
|
||||
threads: QaThreadView[]
|
||||
/** 当前这一轮问的,按先后 */
|
||||
asks: Ask[]
|
||||
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
|
||||
open: string[]
|
||||
}
|
||||
|
||||
export const EMPTY_QA: QaState = { available: false, threads: [], asks: [], open: [] }
|
||||
|
||||
export type QaAction
|
||||
= | { type: 'qa', message: QaMessage }
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| { type: 'answerEnd', end: AnswerEnd }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string }
|
||||
| { type: 'open', anchor: string }
|
||||
| { type: 'close', anchor: string }
|
||||
|
||||
function withOpen(open: string[], anchor: string): string[] {
|
||||
return open.includes(anchor) ? open : [...open, anchor]
|
||||
}
|
||||
|
||||
export function qaReducer(state: QaState, action: QaAction): QaState {
|
||||
switch (action.type) {
|
||||
case 'qa': {
|
||||
// 页面重新加载、或同一文件在别处开着的预览问的:扩展端还在答的接上,展开它那块
|
||||
const known = new Set(state.asks.map(ask => ask.askId))
|
||||
const adopted = action.message.running.filter(ask => !known.has(ask.askId))
|
||||
return {
|
||||
available: action.message.available,
|
||||
threads: action.message.threads,
|
||||
asks: [...state.asks, ...adopted.map((ask): Ask => ({ ...ask, status: 'running' }))],
|
||||
open: adopted.reduce((open, ask) => withOpen(open, ask.anchor), state.open),
|
||||
}
|
||||
}
|
||||
case 'answer':
|
||||
return {
|
||||
...state,
|
||||
asks: state.asks.map(ask => ask.askId === action.askId && ask.status === 'running'
|
||||
? { ...ask, text: action.text, activity: action.activity }
|
||||
: ask),
|
||||
}
|
||||
case 'answerEnd': {
|
||||
const { askId, status, text, error, entryId, sessionId } = 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 }
|
||||
: ask),
|
||||
}
|
||||
}
|
||||
case 'ask':
|
||||
return {
|
||||
...state,
|
||||
asks: [...state.asks, { askId: action.askId, anchor: action.anchor, question: action.question, text: '', status: 'running' }],
|
||||
open: withOpen(state.open, action.anchor),
|
||||
}
|
||||
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'),
|
||||
open: state.open.filter(anchor => anchor !== action.anchor),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前这一轮在 anchor 那块问的 */
|
||||
export function asksOf(state: QaState, anchor: string): Ask[] {
|
||||
return state.asks.filter(ask => ask.anchor === anchor)
|
||||
}
|
||||
|
||||
/** anchor 那块的灰字历史:存下的问答,当前这一轮问的那几条除外 */
|
||||
export function historyOf(state: QaState, anchor: string): QaEntryView[] {
|
||||
const current = new Set(asksOf(state, anchor).map(ask => ask.entryId))
|
||||
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
|
||||
|
||||
function startOfDay(time: number): number {
|
||||
const day = new Date(time)
|
||||
day.setHours(0, 0, 0, 0)
|
||||
return day.getTime()
|
||||
}
|
||||
|
||||
/** 问的时间写成「刚刚」「5 分钟前」「3 小时前」「昨天」「3 天前」,一个月以前的写日期 */
|
||||
export function relativeTime(iso: string, now: number): string {
|
||||
const time = Date.parse(iso)
|
||||
if (Number.isNaN(time))
|
||||
return ''
|
||||
const elapsed = now - time
|
||||
if (elapsed < MINUTE)
|
||||
return '刚刚'
|
||||
if (elapsed < 60 * MINUTE)
|
||||
return `${Math.floor(elapsed / MINUTE)} 分钟前`
|
||||
const days = Math.round((startOfDay(now) - startOfDay(time)) / DAY)
|
||||
if (days <= 0)
|
||||
return `${Math.floor(elapsed / (60 * MINUTE))} 小时前`
|
||||
if (days === 1)
|
||||
return '昨天'
|
||||
if (days < 30)
|
||||
return `${days} 天前`
|
||||
const date = new Date(time)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 回答的前两行预览:Markdown 去掉记号(代码块、反引号、粗体、标题、列表、链接)连成一段纯文字,截两行交给样式 */
|
||||
export function answerPreview(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/^```.*$/gm, '')
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/gm, '')
|
||||
.replace(/(\*\*|__|~~|`)/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
let askCounter = 0
|
||||
|
||||
/** 这一页里不重样的问题 id */
|
||||
export function newAskId(): string {
|
||||
askCounter += 1
|
||||
return `${Date.now().toString(36)}-${askCounter}`
|
||||
}
|
||||
@@ -78,6 +78,8 @@ export interface KindBadge { label: string, color: ThemeColor }
|
||||
interface BlockViewBase {
|
||||
/** 在树里的位置,例如 '6.1.0' */
|
||||
id: string
|
||||
/** 块的位置记号:问答挂在它上面(QaThreadView.anchor),文件别处改了照样对得上;webview 只拿它比相等,不拆开看 */
|
||||
anchor: string
|
||||
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */
|
||||
kind: string
|
||||
/** 编辑范围的起止行,0 起:一般是 header,库规则认领的块是原块整条语句(盖住它留下的子块,见 coveredByEditing) */
|
||||
@@ -154,11 +156,69 @@ export interface DocumentMessage {
|
||||
reveal?: Reveal
|
||||
}
|
||||
|
||||
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间) */
|
||||
export interface QaEntryView {
|
||||
id: string
|
||||
question: string
|
||||
answer: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 块的位置记号里的一层:种类(同 BlockViewBase.kind)和名字,没名字的块只有种类 */
|
||||
export interface AnchorStep {
|
||||
kind: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** 挂在一块上的问答;where 是它原来在哪(各层的种类和名字),块找不到了时显示 */
|
||||
export interface QaThreadView {
|
||||
anchor: string
|
||||
where: AnchorStep[]
|
||||
entries: QaEntryView[]
|
||||
}
|
||||
|
||||
/** 扩展端正在答的一个问题:text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」) */
|
||||
export interface RunningAsk {
|
||||
askId: string
|
||||
anchor: string
|
||||
question: string
|
||||
text: string
|
||||
activity?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」;
|
||||
* running 是扩展端正在答的问题(页面重新加载后靠它接上)
|
||||
*/
|
||||
export interface QaMessage {
|
||||
type: 'qa'
|
||||
available: boolean
|
||||
threads: QaThreadView[]
|
||||
running: RunningAsk[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)、存下的那条的 id 和会话 id(追问接着这个会话),
|
||||
* failed 带给人看的原因,stopped 是被叫停、没存
|
||||
*/
|
||||
export interface AnswerEnd {
|
||||
type: 'answerEnd'
|
||||
askId: string
|
||||
status: 'done' | 'failed' | 'stopped'
|
||||
text?: string
|
||||
error?: string
|
||||
entryId?: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
|
||||
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
|
||||
* filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
|
||||
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
|
||||
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
|
||||
* answerEnd:askId 那个问题答完了,见 AnswerEnd
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
@@ -166,6 +226,9 @@ export type ExtensionToWebview
|
||||
| { type: 'definitionProbed', id: number, found: boolean }
|
||||
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
|
||||
| { type: 'filter', excluded: Excluded }
|
||||
| QaMessage
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| AnswerEnd
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
|
||||
@@ -175,6 +238,9 @@ export type ExtensionToWebview
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
|
||||
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
|
||||
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;resume 是追问时上一次的会话 id
|
||||
* stopAsk:叫停 askId 那个问题,这次不存
|
||||
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
@@ -185,3 +251,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: 'stopAsk', askId: string }
|
||||
| { type: 'deleteQa', anchor: string, entryId: string }
|
||||
@@ -0,0 +1,61 @@
|
||||
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 { onMessage, postMessage } from './vscode'
|
||||
|
||||
/** 逐字长出来的回答最多隔多久画一次:一个字一画整棵块树跟着重画,大文件会卡 */
|
||||
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
|
||||
|
||||
useEffect(() => {
|
||||
/** 攒着没画的回答:askId → 最新的一份 */
|
||||
const pending = new Map<string, { text: string, activity?: string }>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const flush = (): void => {
|
||||
timer = undefined
|
||||
for (const [askId, answer] of pending)
|
||||
dispatch({ type: 'answer', askId, ...answer })
|
||||
pending.clear()
|
||||
}
|
||||
const off = onMessage((msg) => {
|
||||
switch (msg.type) {
|
||||
case 'qa':
|
||||
dispatch({ type: 'qa', message: msg })
|
||||
return
|
||||
case 'answer':
|
||||
pending.set(msg.askId, { text: msg.text, activity: msg.activity })
|
||||
timer ??= setTimeout(flush, ANSWER_FRAME_MS)
|
||||
return
|
||||
case 'answerEnd':
|
||||
clearTimeout(timer)
|
||||
flush()
|
||||
dispatch({ type: 'answerEnd', end: msg })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useMemo((): QaTree => ({
|
||||
state,
|
||||
onToggle: anchor => dispatch({ type: current.current.open.includes(anchor) ? 'close' : 'open', anchor }),
|
||||
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 }) })
|
||||
},
|
||||
onStop: askId => postMessage({ type: 'stopAsk', askId }),
|
||||
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
|
||||
}), [state])
|
||||
}
|
||||
Reference in new issue
Block a user