✨ feat(vscode): 问 AI 输入框能粘贴、拖进图片随问题发给 Claude Code,缩略图能点开看大图,图片存进 .spx/code-qa 跟着问答删
This commit is contained in:
1 parent
d9a5c3673e
commit
36d842e18e
28 files changed
+825
-121
No files matched your search
@@ -92,4 +92,20 @@ describe('块下面的问答区', () => {
|
||||
expect(text(html)).toContain('UserRole 还有哪些角色?昨天')
|
||||
expect(text(html)).not.toContain('昨天 ·')
|
||||
})
|
||||
|
||||
it('问题带的图片:历史和当前这一轮在问题下面一排缩略图,点开看大图,不能去掉;没带图片的不画这一排', () => {
|
||||
const stored = ['https://file+.vscode-resource.vscode-cdn.net/work/.spx/code-qa/e1-1.png', 'https://file+.vscode-resource.vscode-cdn.net/work/.spx/code-qa/e1-2.jpg']
|
||||
const html = render({ open: true, history: [{ ...HISTORY[0]!, images: stored }, HISTORY[1]!], asks: [ask({ images: ['data:image/png;base64,AAA'] })] })
|
||||
const [first, second] = html.split('data-history=""').slice(1)
|
||||
expect(first).toContain('<div class="cr-qa-thumbs">')
|
||||
expect(first.indexOf('cr-qa-thumbs')).toBeGreaterThan(first.indexOf('cr-qa-head'))
|
||||
expect(first.indexOf('cr-qa-thumbs')).toBeLessThan(first.indexOf('cr-qa-preview'))
|
||||
expect(first).toContain(`<img src="${stored[0]}" alt=""/>`)
|
||||
expect(first).toContain('aria-label="看第 2 张图片的大图"')
|
||||
expect(second.slice(0, second.indexOf('data-current'))).not.toContain('cr-qa-thumbs')
|
||||
const current = html.slice(html.indexOf('data-current'))
|
||||
expect(current).toContain('<img src="data:image/png;base64,AAA" alt=""/>')
|
||||
expect(html).not.toContain('cr-qa-thumb-remove')
|
||||
expect(html).not.toContain('data-pending')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties, ReactElement } from 'react'
|
||||
import type { QaState } from './qa'
|
||||
import type { PendingImage } from './qaImages'
|
||||
import type { BlockView } from './types'
|
||||
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
@@ -37,7 +38,8 @@ export interface QaTree {
|
||||
state: QaState
|
||||
onToggle: (anchor: string) => void
|
||||
onClose: (anchor: string) => void
|
||||
onAsk: (anchor: string, question: string) => void
|
||||
/** images 是贴进输入框的图片,随问题一起发 */
|
||||
onAsk: (anchor: string, question: string, images: PendingImage[]) => void
|
||||
onStop: (askId: string) => void
|
||||
onDelete: (anchor: string, entryId: string) => void
|
||||
/** 换「问 AI」用的 profile(所有块共用),id 同 ProfileOption.id */
|
||||
@@ -101,7 +103,7 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
|
||||
open={qa.state.open.includes(anchor)}
|
||||
profiles={qa.state.profiles}
|
||||
onSelectProfile={qa.onSelectProfile}
|
||||
onAsk={question => qa.onAsk(anchor, question)}
|
||||
onAsk={(question, images) => qa.onAsk(anchor, question, images)}
|
||||
onStop={qa.onStop}
|
||||
onDelete={entryId => qa.onDelete(anchor, entryId)}
|
||||
onClose={() => qa.onClose(anchor)}
|
||||
|
||||
@@ -621,6 +621,107 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
||||
}
|
||||
/* 问题带的图片:一排 40px 的缩略图,和回答一样往右缩到问题下面;贴了还没发的在输入框上面,右上角有「去掉」 */
|
||||
.cr-qa-thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 2px 0 2px 18px;
|
||||
}
|
||||
.cr-qa-thumbs[data-pending] {
|
||||
padding: 6px 0 0;
|
||||
}
|
||||
.cr-qa-thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
.cr-qa-thumb-open {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-foreground) 18%, transparent);
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-editor-background);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.cr-qa-thumb-open img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cr-qa-thumb-open:hover {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.cr-qa-entry[data-history] .cr-qa-thumb-open img {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.cr-qa-entry[data-history] .cr-qa-thumb-open:hover img {
|
||||
opacity: 1;
|
||||
}
|
||||
.cr-qa-thumb-remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--vscode-widget-border, color-mix(in srgb, var(--vscode-foreground) 25%, transparent));
|
||||
border-radius: 50%;
|
||||
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.cr-qa-thumb-remove:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
.cr-qa-thumb-open:focus-visible,
|
||||
.cr-qa-thumb-remove:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.cr-qa-notice {
|
||||
color: var(--vscode-editorWarning-foreground, var(--vscode-descriptionForeground));
|
||||
font-size: 11.5px;
|
||||
}
|
||||
/* 看大图:盖住整个预览,图片按比例放到最大,右上角「关闭」 */
|
||||
.cr-qa-lightbox {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: rgb(0 0 0 / 65%);
|
||||
cursor: zoom-out;
|
||||
inset: 0;
|
||||
}
|
||||
.cr-qa-lightbox img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 6px 28px rgb(0 0 0 / 45%);
|
||||
object-fit: contain;
|
||||
}
|
||||
.cr-qa-lightbox-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--vscode-widget-border, transparent);
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
.cr-qa-lightbox-close:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.cr-qa-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -23,14 +23,14 @@ function run(...actions: QaAction[]): QaState {
|
||||
|
||||
describe('问 AI 的状态', () => {
|
||||
it('问一次:展开这块,当前这一轮多一条在答的;回答长了跟着变;答完记下存成的那条,历史里不再重复', () => {
|
||||
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?' })
|
||||
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?', images: [] })
|
||||
expect(state.open).toEqual([IF])
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '', status: 'running' }])
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], 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' } })
|
||||
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new' }])
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '因为要鉴权。', status: 'done', entryId: 'new' }])
|
||||
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
|
||||
})
|
||||
|
||||
@@ -45,9 +45,9 @@ describe('问 AI 的状态', () => {
|
||||
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
|
||||
let state = run(
|
||||
qa([thread(IF, 'e1')]),
|
||||
{ type: 'ask', askId: 'a1', anchor: IF, question: '1' },
|
||||
{ type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] },
|
||||
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1' } },
|
||||
{ type: 'ask', askId: 'a2', anchor: IF, question: '2' },
|
||||
{ type: 'ask', askId: 'a2', anchor: IF, question: '2', images: [] },
|
||||
{ type: 'close', anchor: IF },
|
||||
)
|
||||
expect(state.open).toEqual([])
|
||||
@@ -67,12 +67,22 @@ describe('问 AI 的状态', () => {
|
||||
})
|
||||
|
||||
it('停止、失败照记;不认识的问题的消息不理', () => {
|
||||
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' }, { type: 'answer', askId: 'a1', text: '一半' })
|
||||
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] }, { 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)
|
||||
})
|
||||
|
||||
it('问题带的图片跟着这一问:答完、失败、停止都还在;页面重新加载后接上的没有', () => {
|
||||
const images = ['data:image/png;base64,AAA', 'data:image/jpeg;base64,BBB']
|
||||
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '看图', images })
|
||||
expect(asksOf(state, IF)[0].images).toEqual(images)
|
||||
for (const status of ['done', 'failed', 'stopped'] as const)
|
||||
expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status } }), IF)[0].images).toEqual(images)
|
||||
state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '' }]))
|
||||
expect(asksOf(state, IF)[0].images).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('问的时间', () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface Ask {
|
||||
askId: string
|
||||
anchor: string
|
||||
question: string
|
||||
/** 问题带的图片(这边贴的 data: 地址);页面重新加载后接上的、扩展端还在答的没有 */
|
||||
images?: string[]
|
||||
/** 回答到现在为止的全文(Markdown) */
|
||||
text: string
|
||||
/** 正在做的事:「读取 routes.py」 */
|
||||
@@ -40,7 +42,7 @@ export type QaAction
|
||||
| { type: 'profiles', profiles: ProfileChoice }
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| { type: 'answerEnd', end: AnswerEnd }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string, images: string[] }
|
||||
| { type: 'open', anchor: string }
|
||||
| { type: 'close', anchor: string }
|
||||
|
||||
@@ -76,14 +78,14 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
|
||||
return {
|
||||
...state,
|
||||
asks: state.asks.map(ask => ask.askId === askId
|
||||
? { askId, anchor: ask.anchor, question: ask.question, text: text ?? ask.text, status, error, entryId }
|
||||
? { askId, anchor: ask.anchor, question: ask.question, images: ask.images, text: text ?? ask.text, status, error, entryId }
|
||||
: ask),
|
||||
}
|
||||
}
|
||||
case 'ask':
|
||||
return {
|
||||
...state,
|
||||
asks: [...state.asks, { askId: action.askId, anchor: action.anchor, question: action.question, text: '', status: 'running' }],
|
||||
asks: [...state.asks, { askId: action.askId, anchor: action.anchor, question: action.question, images: action.images, text: '', status: 'running' }],
|
||||
open: withOpen(state.open, action.anchor),
|
||||
}
|
||||
case 'open':
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MAX_IMAGE_BYTES, MAX_IMAGES, pickImages, toAskImage, transferFiles } from './qaImages'
|
||||
|
||||
interface FakeFile { name: string, type: string, size: number }
|
||||
|
||||
const file = (name: string, type = 'image/png', size = 1024): FakeFile => ({ name, type, size })
|
||||
|
||||
/** 剪贴板 / 拖进来的一项:文件,或文字、网页片段这种字符串 */
|
||||
function item(value: FakeFile | string): { kind: string, getAsFile: () => FakeFile | null } {
|
||||
return typeof value === 'string'
|
||||
? { kind: 'string', getAsFile: () => null }
|
||||
: { kind: 'file', getAsFile: () => value }
|
||||
}
|
||||
|
||||
describe('剪贴板里的东西', () => {
|
||||
it('只取文件,文字、网页片段不算(照常粘成文字);取不出文件的跳过', () => {
|
||||
const shot = file('截图.png')
|
||||
const copied = file('image.jpg', 'image/jpeg')
|
||||
expect(transferFiles([item('<img src="x">'), item(shot), item('说明文字'), item(copied)])).toEqual([shot, copied])
|
||||
expect(transferFiles([{ kind: 'file', getAsFile: () => null }])).toEqual([])
|
||||
expect(transferFiles([item('只有文字')])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('能带上哪些图片', () => {
|
||||
it('认的四种格式(PNG、JPEG、GIF、WebP)都带上,按先后', () => {
|
||||
const files = [file('a.png'), file('b.jpg', 'image/jpeg'), file('c.gif', 'image/gif'), file('d.webp', 'image/webp')]
|
||||
expect(pickImages(files, 0)).toEqual({ accepted: files })
|
||||
})
|
||||
|
||||
it('不是这几种格式的不带(SVG、PDF 等),说一句原因', () => {
|
||||
const png = file('a.png')
|
||||
expect(pickImages([file('x.svg', 'image/svg+xml'), png, file('y.pdf', 'application/pdf')], 0))
|
||||
.toEqual({ accepted: [png], notice: '只能贴 PNG、JPEG、GIF、WebP 图片,有 2 个没加上。' })
|
||||
})
|
||||
|
||||
it('每张不超过 5 MB:正好 5 MB 的带上,大一点的不带', () => {
|
||||
const edge = file('edge.png', 'image/png', MAX_IMAGE_BYTES)
|
||||
expect(pickImages([edge, file('big.png', 'image/png', MAX_IMAGE_BYTES + 1)], 0))
|
||||
.toEqual({ accepted: [edge], notice: '每张图片不能超过 5 MB,有 1 个没加上。' })
|
||||
})
|
||||
|
||||
it('连同已经贴的最多 6 张:多出来的不带;已经满了一张都不带', () => {
|
||||
const files = [file('1.png'), file('2.png'), file('3.png')]
|
||||
expect(pickImages(files, MAX_IMAGES - 2)).toEqual({ accepted: files.slice(0, 2), notice: '一个问题最多带 6 张图片,有 1 个没加上。' })
|
||||
expect(pickImages(files, MAX_IMAGES)).toEqual({ accepted: [], notice: '一个问题最多带 6 张图片,有 3 个没加上。' })
|
||||
})
|
||||
|
||||
it('几种原因一起说', () => {
|
||||
const picked = pickImages([file('a.txt', 'text/plain'), file('big.png', 'image/png', MAX_IMAGE_BYTES * 2), ...Array.from({ length: 7 }, (_, i) => file(`${i}.png`))], 0)
|
||||
expect(picked.accepted).toHaveLength(MAX_IMAGES)
|
||||
expect(picked.notice).toBe('只能贴 PNG、JPEG、GIF、WebP 图片;每张图片不能超过 5 MB;一个问题最多带 6 张图片,有 3 个没加上。')
|
||||
})
|
||||
})
|
||||
|
||||
describe('发给扩展端的样子', () => {
|
||||
it('data: 地址去掉前缀剩下 base64,格式照原样', () => {
|
||||
expect(toAskImage({ id: 1, mediaType: 'image/png', url: 'data:image/png;base64,iVBORw0KGgo=' })).toEqual({ mediaType: 'image/png', base64: 'iVBORw0KGgo=' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 「问 AI」输入框里贴图、拖图的规则(不碰 React):认哪些格式、每张多大、最多几张,
|
||||
* 贴进来还没发的图片在页面上的样子,发给扩展端的样子。
|
||||
*/
|
||||
|
||||
import type { AskImage } from './types'
|
||||
|
||||
/** 认的格式:Claude 认的这几种 */
|
||||
export const IMAGE_TYPES: readonly string[] = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
|
||||
|
||||
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** 一个问题最多带几张 */
|
||||
export const MAX_IMAGES = 6
|
||||
|
||||
/** 贴进输入框、还没发的一张图片:url 是 data: 地址,缩略图、大图、发送都用它 */
|
||||
export interface PendingImage {
|
||||
id: number
|
||||
mediaType: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/** 剪贴板 / 拖进来的东西里的文件;文字、网页片段这些不算(照常粘成文字) */
|
||||
export function transferFiles<F>(items: ArrayLike<{ kind: string, getAsFile: () => F | null }>): F[] {
|
||||
return Array.from(items).flatMap(item => item.kind === 'file' ? item.getAsFile() ?? [] : [])
|
||||
}
|
||||
|
||||
/**
|
||||
* 挑出能带上的:只认 PNG、JPEG、GIF、WebP,每张不超过 MAX_IMAGE_BYTES,连同已经有的 have 张最多 MAX_IMAGES 张,按先后取;
|
||||
* 有没加上的给一句原因。
|
||||
*/
|
||||
export function pickImages<F extends { type: string, size: number }>(files: readonly F[], have: number): { accepted: F[], notice?: string } {
|
||||
const images = files.filter(file => IMAGE_TYPES.includes(file.type))
|
||||
const small = images.filter(file => file.size <= MAX_IMAGE_BYTES)
|
||||
const accepted = small.slice(0, Math.max(0, MAX_IMAGES - have))
|
||||
const reasons = [
|
||||
...(images.length < files.length ? ['只能贴 PNG、JPEG、GIF、WebP 图片'] : []),
|
||||
...(small.length < images.length ? [`每张图片不能超过 ${MAX_IMAGE_BYTES / 1024 / 1024} MB`] : []),
|
||||
...(accepted.length < small.length ? [`一个问题最多带 ${MAX_IMAGES} 张图片`] : []),
|
||||
]
|
||||
return reasons.length === 0
|
||||
? { accepted }
|
||||
: { accepted, notice: `${reasons.join(';')},有 ${files.length - accepted.length} 个没加上。` }
|
||||
}
|
||||
|
||||
let imageCounter = 0
|
||||
|
||||
/** 读成 data: 地址(缩略图直接用,页面的安全策略允许 data: 图片) */
|
||||
export function readImage(file: File): Promise<PendingImage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve({ id: ++imageCounter, mediaType: file.type, url: String(reader.result) })
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读不出图片'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/** 发给扩展端的样子:data: 地址去掉前缀剩下的 base64 */
|
||||
export function toAskImage(image: PendingImage): AskImage {
|
||||
return { mediaType: image.mediaType, base64: image.url.slice(image.url.indexOf(',') + 1) }
|
||||
}
|
||||
@@ -164,6 +164,14 @@ export interface QaEntryView {
|
||||
createdAt: string
|
||||
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
|
||||
profile?: string
|
||||
/** 问题带的图片,能直接放进 img 的地址;没带图片时没有 */
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
/** 问题带的一张图片,发给扩展端的样子:格式(`image/png` 等)和不带 `data:` 前缀的 base64 */
|
||||
export interface AskImage {
|
||||
mediaType: string
|
||||
base64: string
|
||||
}
|
||||
|
||||
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示 */
|
||||
@@ -246,7 +254,8 @@ export type ExtensionToWebview
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
|
||||
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
|
||||
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile
|
||||
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
|
||||
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
|
||||
* stopAsk:叫停 askId 那个问题,这次不存
|
||||
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
|
||||
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
|
||||
@@ -260,7 +269,7 @@ 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 }
|
||||
| { type: 'ask', askId: string, anchor: string, question: string, images: AskImage[] }
|
||||
| { type: 'stopAsk', askId: string }
|
||||
| { type: 'deleteQa', anchor: string, entryId: string }
|
||||
| { type: 'selectAskProfile', id: string }
|
||||
@@ -7,7 +7,7 @@ import { onMessage, postMessage } from './vscode'
|
||||
export const HighlightContext = createContext<Highlight | undefined>(undefined)
|
||||
|
||||
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动 */
|
||||
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], .cr-filter-bar, .cr-prop-label'
|
||||
const CONTROLS = 'a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label'
|
||||
|
||||
function clickOf(event: MouseEvent): Click {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { QaTree } from './BlockTree'
|
||||
import type { QaState } from './qa'
|
||||
import { useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
import { EMPTY_QA, newAskId, qaReducer } from './qa'
|
||||
import { toAskImage } from './qaImages'
|
||||
import { onMessage, postMessage } from './vscode'
|
||||
|
||||
/** 逐字长出来的回答最多隔多久画一次:一个字一画整棵块树跟着重画,大文件会卡 */
|
||||
@@ -52,10 +53,10 @@ export function useQa(): QaTree {
|
||||
state,
|
||||
onToggle: anchor => dispatch({ type: current.current.open.includes(anchor) ? 'close' : 'open', anchor }),
|
||||
onClose: anchor => dispatch({ type: 'close', anchor }),
|
||||
onAsk: (anchor, question) => {
|
||||
onAsk: (anchor, question, images) => {
|
||||
const askId = newAskId()
|
||||
dispatch({ type: 'ask', askId, anchor, question })
|
||||
postMessage({ type: 'ask', askId, anchor, question })
|
||||
dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) })
|
||||
postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) })
|
||||
},
|
||||
onStop: askId => postMessage({ type: 'stopAsk', askId }),
|
||||
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
|
||||
|
||||
Reference in new issue
Block a user