✨ 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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user