Files
superwork/vscode/webview-ui/src/codeReader/AskThread.tsx
T
cruldra c9f60f5b30 ✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图
每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。
重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。
进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
2026-10-12 13:45:37 +11:00

377 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { LucideIcon } from 'lucide-react'
import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'react'
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 { 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 { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages'
/** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */
export interface ThreadWording {
label: string
icon: LucideIcon
summary: (count: number) => string
deleteLabel: string
/** first:还没说过;hint:「问」后面的对象 */
placeholder: (first: boolean, hint: string) => string
inputLabel: string
answering: string
}
export const ASK_THREAD: ThreadWording = {
label: '问 AI',
icon: Sparkles,
summary: count => `问过 ${count} 次`,
deleteLabel: '已理解,删除',
placeholder: (first, hint) => first ? `问${hint}…` : '继续问…',
inputLabel: '问 AI 的问题',
answering: '正在回答…',
}
/** 重写注释:第一轮由块右上角的按钮发技能命令,输入框里说的是对这段注释的修改意见 */
export const REWRITE_THREAD: ThreadWording = {
label: '按注释规范重写',
icon: MessageSquareText,
summary: count => `重写过 ${count} 次`,
deleteLabel: '删除这条记录',
placeholder: () => '说说这段注释要怎么改…',
inputLabel: '对这段注释的修改意见',
answering: '正在写…',
}
/** 问题带的图片:一排小缩略图,点一张看大图;给了 onRemove 的(贴了还没发的)右上角有「去掉」。没有图片时什么都不画。 */
function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?: boolean, onOpen: (url: string) => void, onRemove?: (index: number) => void }): ReactElement | null {
if (urls.length === 0)
return null
return (
<div className="cr-qa-thumbs" data-pending={pending ? '' : undefined}>
{urls.map((url, index) => (
// 同一张图可能贴两次、地址一样;缩略图没有自己的状态,按位置认就行
<span key={index} className="cr-qa-thumb">
<button type="button" className="cr-qa-thumb-open" data-tooltip="看大图" aria-label={`看第 ${index + 1} 张图片的大图`} onClick={() => onOpen(url)}>
<img src={url} alt="" />
</button>
{onRemove && (
<button type="button" className="cr-qa-thumb-remove" data-tooltip="去掉这张图片" aria-label={`去掉第 ${index + 1} 张图片`} onClick={() => onRemove(index)}>
<X size={10} strokeWidth={2.4} aria-hidden />
</button>
)}
</span>
))}
</div>
)
}
/** 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。 */
function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): ReactElement {
const close = useRef<HTMLButtonElement>(null)
useEffect(() => {
const opener = document.activeElement
close.current?.focus()
return () => {
if (opener instanceof HTMLElement)
opener.focus()
}
}, [])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape') {
// 只关大图:不收起问答区,也不清名字高亮(那边认 defaultPrevented)
event.preventDefault()
event.stopPropagation()
onClose()
}
}
return createPortal(
<div className="cr-qa-lightbox" role="dialog" aria-modal="true" aria-label="图片大图" onClick={onClose} onKeyDown={onKeyDown}>
<img src={url} alt="" />
<button ref={close} type="button" className="cr-qa-lightbox-close" data-tooltip="关闭(Esc)" aria-label="关闭大图">
<X size={16} strokeWidth={1.8} aria-hidden />
</button>
</div>,
document.body,
)
}
/**
* 说过的一次,灰字:说的话、时间、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。
* 重写记录没写进文件时,最下面写着原因
*/
function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: QaEntryView, wording: ThreadWording, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement {
const [expanded, setExpanded] = useState(false)
const toggle = (): void => setExpanded(value => !value)
const Icon = wording.icon
return (
<div className="cr-qa-entry" data-history="">
<div className="cr-qa-head">
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<button type="button" className="cr-qa-question" aria-expanded={expanded} data-tooltip={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
{entry.question}
</button>
<span className="cr-qa-when">
<time dateTime={entry.createdAt} data-tooltip={new Date(entry.createdAt).toLocaleString()}>
{relativeTime(entry.createdAt, now)}
</time>
{entry.profile && <span data-tooltip="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
</span>
<button type="button" className="cr-tool cr-qa-delete" data-tooltip={wording.deleteLabel} aria-label={wording.deleteLabel} onClick={onDelete}>
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
</button>
</div>
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
{expanded
? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer, 'answer') }} />
: (
<button type="button" className="cr-qa-preview" onClick={toggle}>
{answerPreview(entry.answer)}
</button>
)}
{entry.error && (
<div className="cr-qa-status" data-warning="">
<TriangleAlert size={12} strokeWidth={1.8} className="shrink-0" aria-hidden />
{`没写进文件:${entry.error}`}
</div>
)}
</div>
)
}
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 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)
const Icon = wording.icon
return (
<div className="cr-qa-entry" data-current="">
<div className="cr-qa-head">
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<span className="cr-qa-question">{ask.question}</span>
</div>
<Thumbs urls={ask.images ?? []} onOpen={onOpenImage} />
{ask.text !== '' && (
<div
className="cr-markdown cr-qa-answer"
data-streaming={running ? '' : undefined}
dangerouslySetInnerHTML={{ __html: running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text, 'answer') }}
/>
)}
{running && (
<div className="cr-qa-status" role="status">
<LoaderCircle size={12} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
{label}
</div>
)}
{ask.error !== undefined && ask.status !== 'running' && (
<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, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
running: Ask | undefined
/** 输入框里的提示(后面接着怎么发送、收起) */
placeholder: string
/** 输入框读屏用的名字 */
label: string
profiles: ProfileChoice
onSelectProfile: (id: string) => void
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onClose: () => void
/** Esc 收起。顶部那条一直摊开,不收 */
closable: boolean
onOpenImage: (url: string) => void
/** 展开时把光标放进输入框。顶部那条一直摊开,不要抢阅读时光标 */
autoFocus: boolean
/** 大于 0 时再放一次光标;侧栏「问 AI」打开已经在的预览时用 */
focusToken: number
}): ReactElement {
const [value, setValue] = useState('')
const [images, setImages] = useState<PendingImage[]>([])
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
const [notice, setNotice] = useState<string>()
const textarea = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (autoFocus)
textarea.current?.focus()
}, [autoFocus])
useEffect(() => {
if (focusToken > 0)
textarea.current?.focus()
}, [focusToken])
const attach = (files: File[]): void => {
if (files.length === 0)
return
const picked = pickImages(files, images.length)
setNotice(picked.notice)
Promise.all(picked.accepted.map(readImage)).then(
read => setImages(current => [...current, ...read].slice(0, MAX_IMAGES)),
() => setNotice('有图片读不出来,没加上。'),
)
}
const send = (): void => {
const question = value.trim()
if (question === '' || running)
return
onAsk(question, images)
setValue('')
setImages([])
setNotice(undefined)
}
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
// 输入法选字时的回车不算发送
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
send()
}
else if (closable && event.key === 'Escape') {
event.preventDefault()
onClose()
}
}
// 剪贴板里的图片加到上面的缩略图里;文字照常粘进输入框,不拦
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>): void => attach(transferFiles(event.clipboardData.items))
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
}
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
const files = transferFiles(event.dataTransfer.items)
if (files.length > 0) {
event.preventDefault()
attach(files)
}
}
const remove = (index: number): void => {
setImages(current => current.filter((_, i) => i !== index))
textarea.current?.focus()
}
return (
<>
<Thumbs urls={images.map(image => image.url)} pending onOpen={onOpenImage} onRemove={remove} />
{notice && <div className="cr-qa-notice" role="status">{notice}</div>}
<div className="cr-qa-input" onDragOver={onDragOver} onDrop={onDrop}>
<textarea
ref={textarea}
rows={1}
value={value}
placeholder={`${placeholder} Enter 发送${closable ? ',Esc 收起' : ''}`}
aria-label={label}
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
/>
<SelectMenu
variant="ghost"
value={profiles.selected}
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
onChange={onSelectProfile}
placeholder="默认"
tooltip="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
className="cr-qa-profile"
/>
{running
? (
<button type="button" className="cr-tool" data-tooltip="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
</button>
)
: (
<button type="button" className="cr-tool" data-tooltip="发送(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
/** 输入框旁边的 profile 下拉 */
profiles: ProfileChoice
/** 「问」后面的对象,默认「这块代码」 */
hint?: string
/** 哪种对话,默认问 AI */
wording?: ThreadWording
/** 展开时聚焦输入框,默认聚焦。一直摊开的顶部聊天不聚焦 */
autoFocus?: boolean
/** 变大时把光标放进输入框 */
focusToken?: number
/** Esc 能不能收起,默认能。顶部聊天不收 */
closable?: boolean
now?: number
onSelectProfile: (id: string) => void
/** images 是贴进输入框的图片,随这句话一起发 */
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
onClose: () => void
}
/**
* 块下面的对话区(问 AI、重写注释,左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但说过的块是一行「问过 N 次」(重写注释是「重写过 N 次」)、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里说过什么。
*/
export function AskThread({ history, asks, open, profiles, hint = '这块代码', wording = ASK_THREAD, autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(true)
/** 正在看大图的那张 */
const [preview, setPreview] = useState<string>()
const lightbox = preview !== undefined && <ImagePreview url={preview} onClose={() => setPreview(undefined)} />
const Icon = wording.icon
const historyItems = history.map(entry => <HistoryItem key={entry.id} entry={entry} wording={wording} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)
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)}>
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
{wording.summary(history.length)}
<Chevron size={12} strokeWidth={1.8} aria-hidden />
</button>
{showHistory && historyItems}
{lightbox}
</div>
)
}
return (
<section className="cr-qa" aria-label={wording.label}>
{historyItems}
{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')}
placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)}
label={wording.inputLabel}
profiles={profiles}
onSelectProfile={onSelectProfile}
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
closable={closable}
onOpenImage={setPreview}
autoFocus={autoFocus}
focusToken={focusToken}
/>
{lightbox}
</section>
)
}