feat(conversation): replicate WorkBuddy rewind-to-message action (TODO 05)
This commit is contained in:
1 parent
074523a501
commit
c7ab047d52
11 files changed
+293
-60
No files matched your search
+3
-2
@@ -509,12 +509,13 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('请先停止当前运行')
|
||||
const history = [...await sessions.getMessages(sessionId)]
|
||||
const index = history.findIndex((item) => item.id === messageId && item.role === 'user')
|
||||
if (index < 0) throw new Error('未找到用户消息')
|
||||
const changes = getRevertChanges(history, messageId)
|
||||
if (!changes.length) throw new Error('此版本没有可恢复的文件修改')
|
||||
const workspace = await sessionRoot(sessionId)
|
||||
const kernel = await getOmpKernel(sessionId, workspace, await models.resolveModel((await sessions.getSessionContext(sessionId)).modelId))
|
||||
if (kernel && !history[index].nativeEntryId) throw new Error('缺少原生会话分支,无法同时恢复上下文')
|
||||
const undo = await restoreWorkspaceFiles(workspace, changes)
|
||||
// 原版 rewind 的 no-version 变体:没有产物版本时仅回退对话内容,不触碰文件。
|
||||
const undo = changes.length ? await restoreWorkspaceFiles(workspace, changes) : async () => undefined
|
||||
try {
|
||||
if (kernel) {
|
||||
const response = await kernel.branch(history[index].nativeEntryId!)
|
||||
|
||||
@@ -22,7 +22,8 @@ import { ComposerControls, type ComposerSendPayload } from '../workbuddy/compose
|
||||
import { QuestionFloating } from '../workbuddy/conversation'
|
||||
import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode } from '@shared/conversation'
|
||||
import type { ChatMessage } from '@shared/types'
|
||||
import { getRevertChanges } from '@shared/revert'
|
||||
import { getRevertChanges, type RevertChange } from '@shared/revert'
|
||||
import { rewindLabel } from '../workbuddy/conversation/rewind-labels'
|
||||
import { collectWorkbenchArtifacts } from '../workbuddy/workbench/workbench-state'
|
||||
|
||||
interface Props {
|
||||
@@ -112,12 +113,26 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default-sandbox')
|
||||
const [confirmFullAccess, setConfirmFullAccess] = useState(true)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [revertTarget, setRevertTarget] = useState<ChatMessage | null>(null)
|
||||
const [revertError, setRevertError] = useState<string | null>(null)
|
||||
const [reverting, setReverting] = useState(false)
|
||||
const canRevert = (message: ChatMessage): boolean => {
|
||||
if (message.role !== 'user' || running || sending) return false
|
||||
try { return getRevertChanges(messages, message.id).length > 0 } catch { return false }
|
||||
const [rewindTarget, setRewindTarget] = useState<ChatMessage | null>(null)
|
||||
const [rewindError, setRewindError] = useState<string | null>(null)
|
||||
const [rewinding, setRewinding] = useState(false)
|
||||
/**
|
||||
* 原版 resolveRevertEntryVisible(ConversationTimelineImpl.hasRevision):
|
||||
* 有 requestId 且不是“历史已全部加载时的首条用户消息”才显示回退入口;
|
||||
* 会话运行中 showDefaultRevert = !isSessionActive 为假,同样不显示。
|
||||
* 可见性与文件改动无关:没有产物版本时弹窗走 no-version 变体,仅回退对话。
|
||||
*/
|
||||
const firstUserRequestId = messages.find((message) => message.role === 'user')?.requestId
|
||||
const rewindEntryVisible = (message: ChatMessage): boolean => {
|
||||
if (message.role !== 'user' || running || sending || !message.requestId) return false
|
||||
return !firstUserRequestId || message.requestId !== firstUserRequestId
|
||||
}
|
||||
/** 原版 M.confirm('rollback') 的三种变体:artifacts / no-version / plain(回退预览不可用)。 */
|
||||
const rewindPreview = (message: ChatMessage): { variant: 'artifacts' | 'no-version' | 'plain'; changes: RevertChange[] } => {
|
||||
try {
|
||||
const changes = getRevertChanges(messages, message.id)
|
||||
return { variant: changes.length ? 'artifacts' : 'no-version', changes }
|
||||
} catch { return { variant: 'plain', changes: [] } }
|
||||
}
|
||||
const [activeSegment, setActiveSegment] = useState<SourceHomeMode>('work')
|
||||
const [homeAction, setHomeAction] = useState<string | null>(null)
|
||||
@@ -196,8 +211,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
}, [messages, running])
|
||||
|
||||
useEffect(() => {
|
||||
setRevertTarget(null)
|
||||
setRevertError(null)
|
||||
setRewindTarget(null)
|
||||
setRewindError(null)
|
||||
followBottom.current = true
|
||||
const el = scrollRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
@@ -378,11 +393,12 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
onShowArtifacts={() => window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))}
|
||||
buildingPlanId={running ? messages.find((item) => item.role === 'user' && item.requestId === running.runId)?.planId : undefined}
|
||||
onBuildPlan={async (plan) => { if (running || sending) throw new Error('请等待当前运行结束'); await send(`请执行以下计划:\n\n${planMarkdown(plan)}`, { permissionMode, planId: plan.id }) }}
|
||||
actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: canRevert(message),
|
||||
actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: rewindEntryVisible(message) && !rewinding,
|
||||
rollbackPending: rewinding,
|
||||
canRegenerate: !running && !sending, isSessionActive: true, isLastAssistant: message.id === lastAssistantId,
|
||||
requestId: message.requestId, requestMessages: message.requestId ? messages.filter((item) => item.requestId === message.requestId) : undefined,
|
||||
onRegenerate: regenerate,
|
||||
onRevert: canRevert(message) ? () => { setRevertError(null); setRevertTarget(message) } : undefined,
|
||||
onRevert: rewindEntryVisible(message) && !rewinding ? () => { setRewindError(null); setRewindTarget(message) } : undefined,
|
||||
onFeedback: (target, value) => feedback(target.id, value) }}
|
||||
editingMessageId={editingMessageId} onStartEdit={startEdit} onCancelEdit={cancelEdit} onSubmitEdit={submitEdit} />
|
||||
))}
|
||||
@@ -398,18 +414,31 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{revertTarget && <div className="overlay" onClick={() => !reverting && setRevertTarget(null)}>
|
||||
<section className="dialog" role="dialog" aria-modal="true" aria-label="恢复到此版本" onClick={(event) => event.stopPropagation()}>
|
||||
<h2 className="dialog__title">恢复到此版本?</h2>
|
||||
<p>将恢复此消息之后由工具修改的文件,并退回对应的对话上下文。</p>
|
||||
{revertError && <p role="alert">{revertError}</p>}
|
||||
<div className="dialog__actions"><button className="btn" disabled={reverting} onClick={() => setRevertTarget(null)}>取消</button>
|
||||
<button className="btn btn--primary" disabled={reverting} onClick={() => {
|
||||
setReverting(true)
|
||||
void revert(revertTarget.id).then(() => setRevertTarget(null)).catch((error) => setRevertError(String(error instanceof Error ? error.message : error))).finally(() => setReverting(false))
|
||||
}}>{reverting ? '恢复中…' : '恢复'}</button></div>
|
||||
</section>
|
||||
</div>}
|
||||
{rewindTarget && (() => {
|
||||
const preview = rewindPreview(rewindTarget)
|
||||
const title = preview.variant === 'artifacts' ? rewindLabel('rewind.dialog.artifacts.title')
|
||||
: preview.variant === 'no-version' ? rewindLabel('rewind.dialog.noVersion.title') : rewindLabel('rewind.dialog.plain.title')
|
||||
const description = preview.variant === 'artifacts' ? rewindLabel('rewind.dialog.artifacts.desc')
|
||||
: preview.variant === 'no-version' ? rewindLabel('rewind.dialog.noVersion.desc') : rewindLabel('rewind.dialog.plain.desc')
|
||||
return <div className="overlay" onClick={() => !rewinding && setRewindTarget(null)}>
|
||||
<section className="dialog" role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
||||
<h2 className="dialog__title">{title}</h2>
|
||||
<p>{description}</p>
|
||||
{preview.changes.length > 0 && <div className="rewind-artifacts">
|
||||
{preview.changes.map((change) => <div className="rewind-artifacts__row" key={change.path}>
|
||||
<span className="rewind-artifacts__name" title={change.path}>{change.path.split(/[\\/]/).pop()}</span>
|
||||
<span className="rewind-artifacts__status">{change.before === null ? rewindLabel('rewind.item.willDelete') : rewindLabel('rewind.item.restoreUnknown')}</span>
|
||||
</div>)}
|
||||
</div>}
|
||||
{rewindError && <p role="alert">{rewindError}</p>}
|
||||
<div className="dialog__actions"><button className="btn" disabled={rewinding} onClick={() => setRewindTarget(null)}>{rewindLabel('rewind.action.cancel')}</button>
|
||||
<button className="btn btn--primary" disabled={rewinding} aria-busy={rewinding} onClick={() => {
|
||||
setRewinding(true)
|
||||
void revert(rewindTarget.id).then(() => setRewindTarget(null)).catch((error) => setRewindError(String(error instanceof Error ? error.message : error))).finally(() => setRewinding(false))
|
||||
}}>{rewindLabel('rewind.action.rewind')}</button></div>
|
||||
</section>
|
||||
</div>
|
||||
})()}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1381,6 +1381,39 @@ html[data-theme='dark'] .btn-stop {
|
||||
margin-top: var(--yc-space-6);
|
||||
}
|
||||
|
||||
/* 回退弹窗的产物清单:原版 RewindArtifactList(maxHeight 200 / 13px 行)。 */
|
||||
.rewind-artifacts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-top: var(--yc-space-6);
|
||||
}
|
||||
|
||||
.rewind-artifacts__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--yc-space-3);
|
||||
padding: var(--yc-space-3) 0;
|
||||
font-size: var(--yc-text-caption-size, 13px);
|
||||
line-height: var(--yc-line-height-5, 20px);
|
||||
}
|
||||
|
||||
.rewind-artifacts__name {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rewind-artifacts__status {
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--yc-space-4);
|
||||
color: var(--yc-text-secondary);
|
||||
font-size: var(--yc-font-size-3, 12px);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 原版“回退”(rewind)文案。
|
||||
*
|
||||
* 来源:WorkBuddy 5.8.3 桌面端渲染脚本(REA Evidence
|
||||
* ev_e91787b1d4dea651582c6cf12827775b9068b0ee8b80c68ba08d4c1d87d94e90 /
|
||||
* ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb,
|
||||
* 脚本 SHA-256 5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b)
|
||||
* 中 zh-CN 文案表逐字回收;`rewind.entry.tooltip` 也经由桌面端
|
||||
* ConversationTimelineImpl 的 `revertLabel: t('rewind.entry.tooltip')` 接线确认。
|
||||
*/
|
||||
const zhCN = {
|
||||
'rewind.entry.tooltip': '将对话及产物回退到此消息前',
|
||||
'rewind.dialog.plain.title': '将对话回退到此消息前?',
|
||||
'rewind.dialog.plain.desc': '回退将把会话内容回退到此消息前,请谨慎操作',
|
||||
'rewind.dialog.artifacts.title': '将对话及以下产物版本回退到此消息前?',
|
||||
'rewind.dialog.artifacts.desc': '回退暂不支持部分类型文件',
|
||||
'rewind.dialog.noVersion.title': '当前对话无产物版本,回退将仅回退对话内容',
|
||||
'rewind.dialog.noVersion.desc': '当前对话无产物版本,回退会话后产物版本不变,仅回退消息内容',
|
||||
'rewind.edit.artifacts.title': '是否回退产物历史版本?',
|
||||
'rewind.edit.artifacts.desc': '选择回退产物,将基于本条消息前的版本进行修改;不选择回退,则从最新版开始编辑',
|
||||
'rewind.edit.noHistory.title': '当前对话无产物历史版本,是否继续编辑?',
|
||||
'rewind.edit.noHistory.desc': '当前对话无产物历史版本,编辑会话后产物将基于最新版本开始生成',
|
||||
'rewind.item.restoreAt': '回退至 {time} 版本',
|
||||
'rewind.item.restoreUnknown': '回退至历史版本',
|
||||
'rewind.item.willDelete': '将被删除',
|
||||
'rewind.item.versionDeleted': '历史版本已被删除,将保持当前版本',
|
||||
'rewind.item.fallbackTooltip': '此消息前的版本已被删除,仅可回退至后面生成的版本',
|
||||
'rewind.item.noEarlierVersion': '不回退版本',
|
||||
'rewind.item.noEarlierVersionTooltip': '该产物无历史版本,回退后文件仍会保留在目录中',
|
||||
'rewind.action.cancel': '取消',
|
||||
'rewind.action.rewind': '回退',
|
||||
'rewind.action.skipRewind': '不回退',
|
||||
'rewind.action.edit': '编辑',
|
||||
'rewind.action.resend': '重发',
|
||||
'rewind.error.rollbackFailed': '对话回退失败,请稍后重试。',
|
||||
'rewind.error.editFailed': '对话编辑失败,请稍后重试。',
|
||||
'rewind.error.resendFailed': '对话重发失败,请稍后重试。'
|
||||
} as const
|
||||
|
||||
export type RewindLabelKey = keyof typeof zhCN
|
||||
|
||||
export function rewindLabel(key: RewindLabelKey, params?: Readonly<Record<string, string | number>>): string {
|
||||
const template: string = zhCN[key]
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) => {
|
||||
const value = params[name]
|
||||
return value == null ? match : String(value)
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState, type ReactNode } from 'react'
|
||||
import type { ChatMessage } from '@shared/types'
|
||||
import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons'
|
||||
import { SourceThumbsUpIcon, SourceThumbsDownIcon, SourceThumbsUpSolidIcon, SourceThumbsDownSolidIcon, SourceResendIcon, SourceRevertIcon } from './SourceActionIcons'
|
||||
import { CopyMessageIcon } from './RecoveredActionIcons'
|
||||
import { SourceThumbsUpIcon, SourceThumbsDownIcon, SourceThumbsUpSolidIcon, SourceThumbsDownSolidIcon } from './SourceActionIcons'
|
||||
import { RuntimeRegenerateIcon } from './RuntimeIcons'
|
||||
import { copyUserMessage } from './message-copy'
|
||||
import {
|
||||
@@ -35,16 +35,10 @@ export function MessageActionToolbar({
|
||||
message,
|
||||
requestId = message.requestId,
|
||||
requestMessages,
|
||||
busyState = 'idle',
|
||||
canRevert = true,
|
||||
rollbackPending = false,
|
||||
canRegenerate = false,
|
||||
isSessionActive = false,
|
||||
isLastAssistant = false,
|
||||
onCopy,
|
||||
onEdit,
|
||||
onRevert,
|
||||
onResend,
|
||||
onRegenerate,
|
||||
onFeedback
|
||||
}: MessageActionToolbarProps): React.JSX.Element {
|
||||
@@ -112,17 +106,15 @@ export function MessageActionToolbar({
|
||||
</button>
|
||||
)
|
||||
|
||||
const isUser = message.role === 'user'
|
||||
const regenAllowed = !isUser && canRegenerate && isSessionActive && isLastAssistant && !!requestId
|
||||
// 用户消息已由 UserMessageToolbar(copy / rewind / edit)承载;此工具条只渲染助手消息操作,
|
||||
// 原版的“重新发送”入口不对应任何助手按钮,故不再渲染任何用户消息按钮。
|
||||
const regenAllowed = canRegenerate && isSessionActive && isLastAssistant && !!requestId
|
||||
|
||||
return (
|
||||
<div className="wb-message-actions" role="toolbar" aria-label="消息操作">
|
||||
{button('复制', 'copy', copy, false, <CopyMessageIcon />)}
|
||||
{isUser && busyState === 'idle' && onEdit && button('编辑', 'edit', () => onEdit(message), false, <EditMessageIcon />)}
|
||||
{isUser && onRevert && button('恢复到此版本', 'revert', () => onRevert(message), !canRevert || rollbackPending, <SourceRevertIcon />)}
|
||||
{isUser && onResend && button('重新发送', 'resend', () => onResend(message), busyState !== 'idle', <SourceResendIcon />)}
|
||||
{!isUser && onFeedback && button('赞同', 'like', () => onFeedback(message, 'like'), false, message.feedback === 'like' ? <SourceThumbsUpSolidIcon /> : <SourceThumbsUpIcon />)}
|
||||
{!isUser && onFeedback && button('不赞同', 'dislike', () => onFeedback(message, 'dislike'), false, message.feedback === 'dislike' ? <SourceThumbsDownSolidIcon /> : <SourceThumbsDownIcon />)}
|
||||
{onFeedback && button('赞同', 'like', () => onFeedback(message, 'like'), false, message.feedback === 'like' ? <SourceThumbsUpSolidIcon /> : <SourceThumbsUpIcon />)}
|
||||
{onFeedback && button('不赞同', 'dislike', () => onFeedback(message, 'dislike'), false, message.feedback === 'dislike' ? <SourceThumbsDownSolidIcon /> : <SourceThumbsDownIcon />)}
|
||||
{regenAllowed && onRegenerate && button('重新生成', 'regenerate', () => onRegenerate(requestId!, message), false, <RuntimeRegenerateIcon />)}
|
||||
{pendingAction && <span className="wb-message-actions__pending" role="status">处理中</span>}
|
||||
{error && <span className="wb-message-actions__error" role="alert">{error}</span>}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChatMessage } from '@shared/types'
|
||||
import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons'
|
||||
import { RevertToBeforeIcon } from '../conversation/MessageEditorIcons'
|
||||
import { sourceConversationLabel } from '../conversation/source-labels'
|
||||
import { rewindLabel } from '../conversation/rewind-labels'
|
||||
import { formatMessageTime } from '../../components/AssistantMessageDetails'
|
||||
import { RecoveredSelectTooltip } from '../../components/source-select/RecoveredSelectTooltip'
|
||||
import { copyUserMessage, type MessageCopyWriter } from './message-copy'
|
||||
@@ -80,7 +81,8 @@ export function UserMessageToolbar({ message, canEdit = false, onEdit, onCopy, o
|
||||
<div className="cr-toolbar" data-message-id={message.id} role="toolbar">
|
||||
{time ? <span className="cr-toolbar__time">{time}</span> : null}
|
||||
{iconButton('copy', sourceConversationLabel(copied ? 'markdown.copied' : 'markdown.copy'), <CopyMessageIcon className="cr-icon-button__icon" />, () => void copy(), { hideTooltipOnClick: false })}
|
||||
{onRevert ? iconButton('revert', sourceConversationLabel('userMessage.action.revert'), <RevertToBeforeIcon className="cr-icon-button__icon" />, () => void onRevert(message), { disabled: revertDisabled }) : null}
|
||||
{/* 原版桌面端用 revertLabel 覆盖库默认标签:rewind.entry.tooltip(ConversationTimelineImpl.userMessage.actions)。 */}
|
||||
{onRevert ? iconButton('revert', rewindLabel('rewind.entry.tooltip'), <RevertToBeforeIcon className="cr-icon-button__icon" />, () => void onRevert(message), { disabled: revertDisabled }) : null}
|
||||
{canEdit && onEdit ? iconButton('edit', sourceConversationLabel('userMessage.action.edit'), <EditMessageIcon className="cr-icon-button__icon" />, () => void onEdit(message)) : null}
|
||||
{error ? <span className="wb-message-actions__error" role="alert">{error}</span> : null}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user