✨ feat(vscode): 问 AI 输入框能粘贴、拖进图片随问题发给 Claude Code,缩略图能点开看大图,图片存进 .spx/code-qa 跟着问答删

This commit is contained in:
cruldra committed 2026-09-26 23:15:02 +08:00
1 parent d9a5c3673e
commit 36d842e18e
28 files changed
+825 -121

No files matched your search

+152 -45
View File
@@ -1,14 +1,70 @@
import type { KeyboardEvent, ReactElement } from '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, SendHorizontal, Sparkles, Trash2, TriangleAlert } from 'lucide-react'
import { useState } from 'react'
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, 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'
/** 问过的一次,灰字:问题、时间、「已理解,删除」,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number, onDelete: () => void }): ReactElement {
/** 问题带的图片:一排小缩略图,点一张看大图;给了 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" title="看大图" aria-label={`看第 ${index + 1} 张图片的大图`} onClick={() => onOpen(url)}>
<img src={url} alt="" />
</button>
{onRemove && (
<button type="button" className="cr-qa-thumb-remove" title="去掉这张图片" 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" title="关闭(Esc)" aria-label="关闭大图">
<X size={16} strokeWidth={1.8} aria-hidden />
</button>
</div>,
document.body,
)
}
/** 问过的一次,灰字:问题、时间、「已理解,删除」,问题带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement {
const [expanded, setExpanded] = useState(false)
const toggle = (): void => setExpanded(value => !value)
return (
@@ -28,6 +84,7 @@ function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number
<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) }} />
: (
@@ -40,7 +97,7 @@ function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number
}
/** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */
function CurrentItem({ ask }: { ask: Ask }): ReactElement {
function CurrentItem({ ask, onOpenImage }: { ask: Ask, onOpenImage: (url: string) => void }): ReactElement {
const running = ask.status === 'running'
return (
<div className="cr-qa-entry" data-current="">
@@ -48,6 +105,7 @@ function CurrentItem({ ask }: { ask: Ask }): ReactElement {
<Sparkles 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"
@@ -72,24 +130,41 @@ function CurrentItem({ ask }: { ask: Ask }): ReactElement {
)
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止 */
function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose }: {
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose, onOpenImage }: {
running: Ask | undefined
/** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */
first: boolean
profiles: ProfileChoice
onSelectProfile: (id: string) => void
onAsk: (question: string) => void
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onClose: () => void
onOpenImage: (url: string) => void
}): ReactElement {
const [value, setValue] = useState('')
const [images, setImages] = useState<PendingImage[]>([])
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
const [notice, setNotice] = useState<string>()
const textarea = useRef<HTMLTextAreaElement>(null)
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)
onAsk(question, images)
setValue('')
setImages([])
setNotice(undefined)
}
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
// 输入法选字时的回车不算发送
@@ -102,38 +177,63 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on
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 (
<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}
/>
<SelectMenu
variant="ghost"
value={profiles.selected}
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
onChange={onSelectProfile}
placeholder="默认"
title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
className="cr-qa-profile"
/>
{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>
<>
<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}
autoFocus
value={value}
placeholder={`${first ? '问这块代码…' : '继续问…'} Enter 发送,Esc 收起`}
aria-label="问 AI 的问题"
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="默认"
title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
className="cr-qa-profile"
/>
{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>
</>
)
}
@@ -146,7 +246,8 @@ export interface AskThreadProps {
profiles: ProfileChoice
now?: number
onSelectProfile: (id: string) => void
onAsk: (question: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
onClose: () => void
@@ -158,6 +259,9 @@ export interface AskThreadProps {
*/
export function AskThread({ history, asks, open, profiles, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(false)
/** 正在看大图的那张 */
const [preview, setPreview] = useState<string>()
const lightbox = preview !== undefined && <ImagePreview url={preview} onClose={() => setPreview(undefined)} />
if (!open) {
if (history.length === 0)
return null
@@ -169,15 +273,16 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS
{`问过 ${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)} />)}
{showHistory && history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
{lightbox}
</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.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} />)}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} onOpenImage={setPreview} />)}
<AskInput
running={asks.find(ask => ask.status === 'running')}
first={history.length === 0 && asks.length === 0}
@@ -186,7 +291,9 @@ export function AskThread({ history, asks, open, profiles, now = Date.now(), onS
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
onOpenImage={setPreview}
/>
{lightbox}
</section>
)
}