✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图
每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。 重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。 进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
This commit is contained in:
1 parent
ae4e2160ef
commit
c9f60f5b30
32 files changed
+1654
-860
No files matched your search
@@ -1,9 +1,9 @@
|
||||
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 { RewriteRun } from './rewriteRun'
|
||||
import type { ProfileChoice, QaEntryView } from './types'
|
||||
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
|
||||
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'
|
||||
@@ -11,6 +11,39 @@ 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)
|
||||
@@ -64,14 +97,18 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R
|
||||
)
|
||||
}
|
||||
|
||||
/** 问过的一次,灰字:问题、时间、「已理解,删除」,问题带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
|
||||
function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement {
|
||||
/**
|
||||
* 说过的一次,灰字:说的话、时间、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 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">
|
||||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||||
<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>
|
||||
@@ -81,7 +118,7 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
|
||||
</time>
|
||||
{entry.profile && <span data-tooltip="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
|
||||
</span>
|
||||
<button type="button" className="cr-tool cr-qa-delete" data-tooltip="已理解,删除" aria-label="已理解,删除" onClick={onDelete}>
|
||||
<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>
|
||||
@@ -93,18 +130,25 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
|
||||
{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, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url: string) => void, onStop?: () => void }): ReactElement {
|
||||
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 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 === '' ? '正在想…' : '正在回答…')
|
||||
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">
|
||||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||||
<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} />
|
||||
@@ -118,15 +162,10 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
|
||||
{running && (
|
||||
<div className="cr-qa-status" role="status">
|
||||
<LoaderCircle size={12} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
|
||||
{onStop ? <span className="min-w-0 flex-1">{label}</span> : label}
|
||||
{onStop && (
|
||||
<button type="button" className="cr-tool" data-tooltip="停止" aria-label="停止" onClick={onStop}>
|
||||
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{ask.status === 'failed' && (
|
||||
{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>
|
||||
@@ -138,12 +177,12 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
|
||||
}
|
||||
|
||||
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
|
||||
function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
|
||||
function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
|
||||
running: Ask | undefined
|
||||
/** 还没问过:提示「问…」,问过了是「继续问…」 */
|
||||
first: boolean
|
||||
/** 「问」后面的对象:块下面是「这块代码」,顶部是「这个文件」或「这个文件夹」 */
|
||||
hint: string
|
||||
/** 输入框里的提示(后面接着怎么发送、收起) */
|
||||
placeholder: string
|
||||
/** 输入框读屏用的名字 */
|
||||
label: string
|
||||
profiles: ProfileChoice
|
||||
onSelectProfile: (id: string) => void
|
||||
onAsk: (question: string, images: PendingImage[]) => void
|
||||
@@ -228,8 +267,8 @@ function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onSt
|
||||
ref={textarea}
|
||||
rows={1}
|
||||
value={value}
|
||||
placeholder={`${first ? `问${hint}…` : '继续问…'} Enter 发送${closable ? ',Esc 收起' : ''}`}
|
||||
aria-label="问 AI 的问题"
|
||||
placeholder={`${placeholder} Enter 发送${closable ? ',Esc 收起' : ''}`}
|
||||
aria-label={label}
|
||||
onChange={event => setValue(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
@@ -268,6 +307,8 @@ export interface AskThreadProps {
|
||||
profiles: ProfileChoice
|
||||
/** 「问」后面的对象,默认「这块代码」 */
|
||||
hint?: string
|
||||
/** 哪种对话,默认问 AI */
|
||||
wording?: ThreadWording
|
||||
/** 展开时聚焦输入框,默认聚焦。一直摊开的顶部聊天不聚焦 */
|
||||
autoFocus?: boolean
|
||||
/** 变大时把光标放进输入框 */
|
||||
@@ -276,7 +317,7 @@ export interface AskThreadProps {
|
||||
closable?: boolean
|
||||
now?: number
|
||||
onSelectProfile: (id: string) => void
|
||||
/** images 是贴进输入框的图片,随问题一起发 */
|
||||
/** images 是贴进输入框的图片,随这句话一起发 */
|
||||
onAsk: (question: string, images: PendingImage[]) => void
|
||||
onStop: (askId: string) => void
|
||||
onDelete: (entryId: string) => void
|
||||
@@ -284,14 +325,16 @@ export interface AskThreadProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
|
||||
* 没展开但问过的块是一行「问过 N 次」、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里问过什么。
|
||||
* 块下面的对话区(问 AI、重写注释,左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
|
||||
* 没展开但说过的块是一行「问过 N 次」(重写注释是「重写过 N 次」)、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里说过什么。
|
||||
*/
|
||||
export function AskThread({ history, asks, open, profiles, hint = '这块代码', autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
|
||||
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
|
||||
@@ -299,26 +342,26 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
|
||||
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} 次`}
|
||||
<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 && history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
|
||||
{showHistory && historyItems}
|
||||
{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)} onOpenImage={setPreview} />)}
|
||||
<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} onOpenImage={setPreview} />)}
|
||||
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} wording={wording} onOpenImage={setPreview} />)}
|
||||
<AskInput
|
||||
running={asks.find(ask => ask.status === 'running')}
|
||||
first={history.length === 0 && asks.length === 0}
|
||||
placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)}
|
||||
label={wording.inputLabel}
|
||||
profiles={profiles}
|
||||
onSelectProfile={onSelectProfile}
|
||||
hint={hint}
|
||||
onAsk={onAsk}
|
||||
onStop={onStop}
|
||||
onClose={onClose}
|
||||
@@ -331,25 +374,3 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 注释块下面的重写过程,样子和展开的「问 AI」一样:左边竖线、问题、流出来的回答、正在读哪个文件。
|
||||
*
|
||||
* 问题是发给模型的那句固定要求。没有输入框,不记历史。写进文件后由调用方拿掉;失败或停下就留在这里。
|
||||
*/
|
||||
export function RewriteThread({ run, onStop }: { run: RewriteRun, onStop: () => void }): ReactElement {
|
||||
const ask: Ask = {
|
||||
askId: 'rewrite',
|
||||
anchor: '',
|
||||
question: run.question,
|
||||
text: run.text,
|
||||
...(run.activity !== undefined && { activity: run.activity }),
|
||||
status: run.status,
|
||||
...(run.error !== undefined && { error: run.error }),
|
||||
}
|
||||
return (
|
||||
<section className="cr-qa" aria-label="按注释规范重写">
|
||||
<CurrentItem ask={ask} onOpenImage={() => {}} onStop={run.status === 'running' ? onStop : undefined} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user