feat(conversation): replicate in-place user message editing (TODO 03)
This commit is contained in:
1 parent
ed31a7c1b4
commit
ee51208931
18 files changed
+992
-67
No files matched your search
+9
-2
@@ -29,7 +29,7 @@ import { createInteractionBroker, respondToInteraction } from './interaction-bro
|
||||
import { attachmentPrompt, commitAttachments, getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments'
|
||||
import { transcribeAudio } from './speech'
|
||||
import { getRevertChanges } from '@shared/revert'
|
||||
import { restoreWorkspaceFiles } from './workspace-revert'
|
||||
import { restoreWorkspaceFiles, rollbackEditedArtifacts } from './workspace-revert'
|
||||
import { assistantWireHistory } from './agent/history'
|
||||
import * as models from './models'
|
||||
import * as workspaces from './workspaces'
|
||||
@@ -563,7 +563,10 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
}
|
||||
const target = history[targetIndex]
|
||||
if (!target || target.role !== 'user') throw new Error('未找到对应用户消息')
|
||||
if (kernel) {
|
||||
// 原版编辑语义(userMessage.edit.hint):产物回退至此消息前版本,再从此处重新开始对话。
|
||||
const editUndo = payload.action === 'edit' ? await rollbackEditedArtifacts(workspaceRoot, history, payload.fromMessageId!) : undefined
|
||||
try {
|
||||
if (kernel) {
|
||||
if (target.nativeSessionPath && target.nativeSessionPath !== ompSessionPath(sessionId)) throw new Error('这条历史消息没有当前 OMP 会话的分支记录')
|
||||
let entryId = target.nativeEntryId
|
||||
if (!entryId) {
|
||||
@@ -588,6 +591,10 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
}
|
||||
const entry = ompKernels.get(sessionId)
|
||||
if (entry && entryId) entry.appliedBranchId = entryId
|
||||
}
|
||||
} catch (error) {
|
||||
if (editUndo) await editUndo().catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
history = history.slice(0, targetIndex)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { promises as fs } from 'node:fs'
|
||||
import { dirname, isAbsolute, relative, resolve, sep, join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { RevertChange } from '@shared/revert'
|
||||
import { getRevertChanges } from '@shared/revert'
|
||||
import type { ChatMessage } from '@shared/types'
|
||||
|
||||
async function replaceFile(path: string, content: string | null): Promise<void> {
|
||||
if (content === null) { await fs.unlink(path); return }
|
||||
@@ -33,3 +35,14 @@ export async function restoreWorkspaceFiles(workspace: string, changes: RevertCh
|
||||
} catch (error) { await undo(); throw error }
|
||||
return undo
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑提交前的产物回退(原版 userMessage.edit.hint:产物回退至此消息前版本)。
|
||||
* 返回撤销函数;该消息之后没有可回退的写入时返回 undefined。
|
||||
* 记录不完整时由 getRevertChanges 抛错,调用方应中止提交而不是静默保留文件修改。
|
||||
*/
|
||||
export async function rollbackEditedArtifacts(workspace: string, messages: ChatMessage[], userMessageId: string): Promise<(() => Promise<void>) | undefined> {
|
||||
const changes = getRevertChanges(messages, userMessageId)
|
||||
if (!changes.length) return undefined
|
||||
return restoreWorkspaceFiles(workspace, changes)
|
||||
}
|
||||
@@ -111,6 +111,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
useEffect(() => { setSkillPicker(null); setExpertPicker(null) }, [draftKey, hidden])
|
||||
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)
|
||||
@@ -205,17 +206,23 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => {
|
||||
if ((!text && !attachments.length && !quotes.length) || running || sending) return
|
||||
followBottom.current = true
|
||||
await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode, ...(entry.editingId ? { fromMessageId: entry.editingId, action: 'edit' as const } : {}) })
|
||||
await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
|
||||
const destination = draftMoves.current.get(draftKey) ?? draftKey
|
||||
draftMoves.current.delete(draftKey)
|
||||
setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } }))
|
||||
}
|
||||
|
||||
const edit = (message: ChatMessage): void => {
|
||||
setDrafts((all) => ({ ...all, [draftKey]: { text: message.content, attachments: message.attachments ?? [], resources: message.resources ?? [], quotes: message.quotes, skills: message.skills, editingId: message.id } }))
|
||||
document.querySelector<HTMLTextAreaElement>('.wb-composer__input')?.focus()
|
||||
/** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */
|
||||
const startEdit = (message: ChatMessage): void => {
|
||||
if (running || sending) return
|
||||
setEditingMessageId(message.id)
|
||||
}
|
||||
const cancelEdit = (): void => setEditingMessageId(null)
|
||||
const submitEdit = async (message: ChatMessage, text: string): Promise<void> => {
|
||||
await send(text, { attachments: message.attachments, resources: message.resources, quotes: message.quotes,
|
||||
skillIds: message.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'edit' })
|
||||
setEditingMessageId(null)
|
||||
}
|
||||
const resend = (message: ChatMessage): Promise<void> => send(message.content, { attachments: message.attachments, resources: message.resources, quotes: message.quotes, skillIds: message.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'resend' })
|
||||
const regenerate = (_requestId: string, message: ChatMessage): Promise<void> => {
|
||||
const preceding = messages.slice(0, messages.findIndex((item) => item.id === message.id)).findLast((item) => item.role === 'user')
|
||||
return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, skillIds: preceding.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve()
|
||||
@@ -255,7 +262,6 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
/** 输入框(首页与会话内共用;首页编辑区更高,由 CSS 的 data-variant 区分) */
|
||||
const composer = (
|
||||
<>
|
||||
{entry.editingId && <div className="composer__edit-banner">编辑消息<button type="button" onClick={() => setDrafts((all) => ({ ...all, [draftKey]: { ...entry, editingId: undefined } }))}>取消</button></div>}
|
||||
<ComposerControls key={draftKey} className={isHome ? 'wb-composer--home' : undefined} sessionId={currentId} value={draft} onChange={value => { setDraft(value); if (value === '/') setSkillPicker({ owner: draftKey, anchor: document.querySelector('.wb-composer__add-button') }) }} attachments={entry.attachments} resources={entry.resources ?? []}
|
||||
onOpenSkills={anchor => setSkillPicker({ owner: draftKey, anchor })}
|
||||
onOpenExperts={anchor => { setSkillPicker(null); setExpertPicker({ owner: draftKey, anchor }); void window.ycode.experts.list().then(setLocalExperts).catch(error => setExpertError(String(error))) }}
|
||||
@@ -375,9 +381,10 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: canRevert(message),
|
||||
canRegenerate: !running && !sending, isSessionActive: true, isLastAssistant: message.id === lastAssistantId,
|
||||
requestId: message.requestId, requestMessages: message.requestId ? messages.filter((item) => item.requestId === message.requestId) : undefined,
|
||||
onEdit: edit, onResend: resend, onRegenerate: regenerate,
|
||||
onRegenerate: regenerate,
|
||||
onRevert: canRevert(message) ? () => { setRevertError(null); setRevertTarget(message) } : undefined,
|
||||
onFeedback: (target, value) => feedback(target.id, value) }} />
|
||||
onFeedback: (target, value) => feedback(target.id, value) }}
|
||||
editingMessageId={editingMessageId} onStartEdit={startEdit} onCancelEdit={cancelEdit} onSubmitEdit={submitEdit} />
|
||||
))}
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ import { computeSourceFoldSummary } from '../workbuddy/conversation/recovered-fo
|
||||
import { Fragment, useState } from 'react'
|
||||
import type { MessageActionToolbarProps } from '../workbuddy/message-actions/MessageActionToolbar'
|
||||
import { MessageActionToolbar } from '../workbuddy/message-actions/MessageActionToolbar'
|
||||
import { UserMessageToolbar } from '../workbuddy/message-actions/UserMessageToolbar'
|
||||
import { UserMessageEditor } from '../workbuddy/conversation/UserMessageEditor'
|
||||
import { PermissionConfirm, QuestionAnswerTranscript, ThinkingBlock, PlanCreateBlock, TaskBlock, ToolCallRenderer, type ConversationToolCall } from '../workbuddy/conversation'
|
||||
import { conversationLabel } from '../workbuddy/conversation/labels'
|
||||
import { downloadInHost, openPathInHost } from '../workbuddy/host'
|
||||
@@ -32,6 +34,11 @@ interface Props {
|
||||
quotedSourceIds?: readonly string[]
|
||||
darkMode?: boolean
|
||||
actions?: Omit<MessageActionToolbarProps, 'message'>
|
||||
/** 正在就地编辑的消息 id;命中时用编辑容器替换气泡内容。 */
|
||||
editingMessageId?: string | null
|
||||
onStartEdit?(message: ChatMessage): void
|
||||
onCancelEdit?(): void
|
||||
onSubmitEdit?(message: ChatMessage, text: string): Promise<void>
|
||||
onRespond?(id: string, response: InteractionResponse): Promise<void>
|
||||
sessionId?: string | null
|
||||
onBuildPlan?(plan: PlanContent): Promise<void>
|
||||
@@ -40,7 +47,7 @@ interface Props {
|
||||
onShowArtifacts?(): void
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message, onQuote, quotedSourceIds, darkMode, actions, onRespond, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element {
|
||||
export default function MessageBubble({ message, onQuote, quotedSourceIds, darkMode, actions, editingMessageId, onStartEdit, onCancelEdit, onSubmitEdit, onRespond, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element {
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [liveEventsExpanded, setLiveEventsExpanded] = useState(false)
|
||||
const perform = (action: () => Promise<unknown>): void => {
|
||||
@@ -52,21 +59,25 @@ export default function MessageBubble({ message, onQuote, quotedSourceIds, darkM
|
||||
const downloadPlan = (plan: PlanContent): void => perform(() => downloadInHost({ filename: `${plan.title.replace(/[<>:"/\\|?*]/g, '_') || 'plan'}.md`, mimeType: 'text/markdown', encoding: 'utf8', data: planMarkdown(plan) }))
|
||||
// 用户消息:右侧灰色气泡
|
||||
if (message.role === 'user') {
|
||||
const editing = editingMessageId === message.id && !!onSubmitEdit
|
||||
return (
|
||||
<div className="msg--user cr-frame cr-frame--right"><div className="cr-frame__content">
|
||||
<div className="cr-self-message"><div className="cr-self-message__content">
|
||||
<div className="bubble cr-self-bubble">
|
||||
{!!message.quotes?.length && <div className="cr-context-chips" aria-label="引用的内容">
|
||||
<div className={`bubble cr-self-bubble${editing ? ' cr-self-bubble--editing' : ''}`}>
|
||||
{!editing && !!message.quotes?.length && <div className="cr-context-chips" aria-label="引用的内容">
|
||||
{message.quotes.map((quote) => <MessageQuoteTag key={quote.id} quote={quote} readOnly />)}
|
||||
</div>}
|
||||
<div className="cr-user-content"><div className="yc-user-editable">{message.content.split('\n').map((line, index) => <p key={index}><span className="yc-user-leaf"><span>{line || <br />}</span></span></p>)}</div></div>
|
||||
{editing ? <UserMessageEditor message={message} onSubmit={(text) => onSubmitEdit!(message, text)} onCancel={() => onCancelEdit?.()} />
|
||||
: <div className="cr-user-content"><div className="yc-user-editable">{message.content.split('\n').map((line, index) => <p key={index}><span className="yc-user-leaf"><span>{line || <br />}</span></span></p>)}</div></div>}
|
||||
</div>
|
||||
{!!message.skills?.length && <div className="wb-composer__resources" aria-label="使用的技能">{message.skills.map(skill => <span className="wb-composer__resource" data-skill-uri={skill.uri} key={skill.id}>{skill.name}</span>)}</div>}
|
||||
{message.attachments?.map((attachment) => <div className="msg__attachment" key={attachment.id}>
|
||||
{!editing && !!message.skills?.length && <div className="wb-composer__resources" aria-label="使用的技能">{message.skills.map(skill => <span className="wb-composer__resource" data-skill-uri={skill.uri} key={skill.id}>{skill.name}</span>)}</div>}
|
||||
{!editing && message.attachments?.map((attachment) => <div className="msg__attachment" key={attachment.id}>
|
||||
{attachment.previewUrl ? <MarkdownContent content={`![${attachment.name.replace(/[\[\]]/g, '')}](${attachment.previewUrl})`} messageId={attachment.id} darkMode={darkMode} /> : <span>{attachment.name}</span>}
|
||||
</div>)}
|
||||
</div></div>
|
||||
<MessageActionToolbar message={message} {...actions} />
|
||||
{!editing && <UserMessageToolbar message={message} canEdit={actions?.busyState === 'idle' && !!onStartEdit} onEdit={onStartEdit}
|
||||
onCopy={actions?.onCopy} onRevert={actions?.onRevert}
|
||||
revertDisabled={actions?.canRevert === false || actions?.rollbackPending} />}
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement>
|
||||
|
||||
/**
|
||||
* WorkBuddy 5.8.3 渲染器 RevertToBeforeIcon(回收源码 .tmp/wb-src-DbE1_mgM.js)。
|
||||
* 用于用户消息工具条的“恢复此版本”入口(userMessage.action.revert)。
|
||||
*/
|
||||
export function RevertToBeforeIcon({ className, style, ...props }: IconProps): React.JSX.Element {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none" className={className} style={style} {...props}>
|
||||
<path d="M2.0917 2.9167L-0.4125 5.4209Q-0.5904 5.5888 -0.5833 5.8333Q-0.5904 6.0779 -0.4125 6.2458Q-0.2445 6.4237 0 6.4167Q0.2445 6.4237 0.4125 6.2458L3.3291 3.3291Q3.507 3.1612 3.5 2.9167Q3.507 2.6721 3.3291 2.5042L0.4125 -0.4125Q0.2445 -0.5904 0 -0.5833Q-0.2445 -0.5904 -0.4125 -0.4125Q-0.5904 -0.2445 -0.5833 0Q-0.5904 0.2445 -0.4125 0.4125L2.0917 2.9167Z" fill="currentColor" fillRule="evenodd" transform="matrix(-1 0 0 1 5.25 2.33301)" />
|
||||
<path d="M3.2083 0.5833L9.3333 0.5833Q9.5779 0.5904 9.7458 0.4125Q9.9237 0.2445 9.9167 0Q9.9237 -0.2445 9.7458 -0.4125Q9.5779 -0.5904 9.3333 -0.5833L3.2083 -0.5833Q1.6378 -0.5833 0.5272 0.5272Q-0.5833 1.6378 -0.5833 3.2083Q-0.5833 4.7789 0.5272 5.8894Q1.6378 7 3.2083 7L5.25 7Q5.4945 7.007 5.6625 6.8291Q5.8404 6.6612 5.8333 6.4167Q5.8404 6.1721 5.6625 6.0042Q5.4945 5.8263 5.25 5.8333L3.2083 5.8333Q2.121 5.8333 1.3522 5.0645Q0.5833 4.2956 0.5833 3.2083Q0.5833 2.121 1.3522 1.3522Q2.121 0.5833 3.2083 0.5833Z" fill="currentColor" fillRule="evenodd" transform="matrix(-1 0 0 1 11.6665 5.25)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑框提示图标(InfoIcon)。REA ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405
|
||||
* 运行时 svg_nodes:14×14,viewBox 0 0 16 16。
|
||||
*/
|
||||
export function EditorInfoIcon({ className, ...props }: IconProps): React.JSX.Element {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" className={className} {...props}>
|
||||
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M8 7V11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="8" cy="5" r="0.75" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** WorkBuddy 5.8.3 渲染器 LoadingSpinnerIcon(size=1 → 12px,cr-input-spin 0.8s 线性无限旋转)。 */
|
||||
export function EditorSpinnerIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ animation: 'cr-input-spin 0.8s linear infinite' }}>
|
||||
<circle cx="6" cy="6" r="5" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.5" />
|
||||
<path d="M11 6a5 5 0 0 0-5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ChatMessage } from '@shared/types'
|
||||
import { EditorInfoIcon, EditorSpinnerIcon } from './MessageEditorIcons'
|
||||
import { sourceConversationLabel } from './source-labels'
|
||||
import './message-edit-source.css'
|
||||
|
||||
/** WorkBuddy 5.8.3 `vJ = 1e4`:编辑内容上限 10000 字符。 */
|
||||
export const USER_MESSAGE_EDIT_MAX_LENGTH = 10_000
|
||||
|
||||
/** 原版 `shouldDismissEditOnOutsideMouseDown` 的豁免选择器。 */
|
||||
const DISMISS_SAFE_SELECTORS = [
|
||||
'.cr-trigger-search-panel',
|
||||
'.cr-popover',
|
||||
'.cr-sub-menu__overlay',
|
||||
'.cr-tooltip',
|
||||
'.wb-tooltip',
|
||||
'.cb-context-menu'
|
||||
].join(',')
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage
|
||||
onSubmit(text: string): Promise<void>
|
||||
onCancel(): void
|
||||
}
|
||||
|
||||
function readEditableText(editable: HTMLDivElement | null): string {
|
||||
if (!editable) return ''
|
||||
const raw = editable.innerText.replace(/\r\n/g, '\n')
|
||||
return raw.endsWith('\n') ? raw.slice(0, -1) : raw
|
||||
}
|
||||
|
||||
function hasEditableContent(text: string): boolean {
|
||||
return text.split('\n').some((line) => line.trim().length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息就地编辑容器。
|
||||
* 结构、校验、按钮与失焦规则来自 WorkBuddy 5.8.3 渲染器 UserMessageEditorContent
|
||||
* (REA 回收 .tmp/wb-src-DbE1_mgM.js)与运行时采集 ev_5dd78e06…/ev_ee345b16…。
|
||||
*/
|
||||
export function UserMessageEditor({ message, onSubmit, onCancel }: Props): React.JSX.Element {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const editableRef = useRef<HTMLDivElement>(null)
|
||||
const submittingRef = useRef(false)
|
||||
const [text, setText] = useState(message.content)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
// 非受控写入初始内容,并把光标放到末尾(原版 prepareEditDraft + 自动聚焦)。
|
||||
useEffect(() => {
|
||||
const editable = editableRef.current
|
||||
if (!editable) return
|
||||
editable.innerText = message.content
|
||||
editable.focus()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editable)
|
||||
range.collapse(false)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}, [message.id, message.content])
|
||||
|
||||
const validation = useMemo<'empty' | 'too-long' | null>(() => {
|
||||
if (!hasEditableContent(text)) return 'empty'
|
||||
if (text.length > USER_MESSAGE_EDIT_MAX_LENGTH) return 'too-long'
|
||||
return null
|
||||
}, [text])
|
||||
|
||||
const validationMessage = validation === 'empty'
|
||||
? sourceConversationLabel('userMessage.edit.emptyError')
|
||||
: validation === 'too-long'
|
||||
? sourceConversationLabel('userMessage.edit.maxLengthError', { maxLength: USER_MESSAGE_EDIT_MAX_LENGTH.toLocaleString() })
|
||||
: null
|
||||
const error = validationMessage ?? submitError
|
||||
const hasChanges = text !== message.content
|
||||
|
||||
const submit = useCallback((): void => {
|
||||
if (submittingRef.current) return
|
||||
const current = readEditableText(editableRef.current)
|
||||
if (!hasEditableContent(current) || current.length > USER_MESSAGE_EDIT_MAX_LENGTH) return
|
||||
submittingRef.current = true
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
void onSubmit(current)
|
||||
.catch((reason: unknown) => {
|
||||
const detail = reason instanceof Error ? reason.message : String(reason)
|
||||
setSubmitError(/正在运行|尚未结束|in progress/i.test(detail)
|
||||
? sourceConversationLabel('userMessage.edit.error.retryLater')
|
||||
: sourceConversationLabel('userMessage.edit.sendFailed'))
|
||||
})
|
||||
.finally(() => {
|
||||
submittingRef.current = false
|
||||
setSubmitting(false)
|
||||
})
|
||||
}, [onSubmit])
|
||||
|
||||
// 原版:提交中或有改动时不因外部 mousedown 关闭。
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (event: MouseEvent): void => {
|
||||
if (submittingRef.current || hasChanges) return
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (!target) return
|
||||
if (rootRef.current?.contains(target)) return
|
||||
if (target.closest(DISMISS_SAFE_SELECTORS)) return
|
||||
onCancel()
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown)
|
||||
}, [hasChanges, onCancel])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cr-user-message-editor" ref={rootRef}>
|
||||
<div className="cr-input-container-wrapper">
|
||||
<div className={`cr-input-container${focused ? ' cr-input-container--focused' : ''}`}>
|
||||
<div className="cr-input-editor-host">
|
||||
<div
|
||||
ref={editableRef}
|
||||
className="cr-input-editor-content"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
data-slate-editor="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={sourceConversationLabel('userMessage.action.edit')}
|
||||
onInput={(event) => setText(readEditableText(event.currentTarget))}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <div className="cr-user-message-editor__error" role="alert">{error}</div> : null}
|
||||
<div className="cr-user-message-editor__footer">
|
||||
<div className="cr-user-message-editor__hint">
|
||||
<EditorInfoIcon className="cr-user-message-editor__hint-icon" />
|
||||
<span>{sourceConversationLabel('userMessage.edit.hint')}</span>
|
||||
</div>
|
||||
<div className="cr-user-message-editor__actions">
|
||||
<button type="button" className="cr-user-message-editor__cancel" disabled={submitting} onClick={onCancel}>
|
||||
{sourceConversationLabel('userMessage.edit.cancel')}
|
||||
</button>
|
||||
<button type="button" className="cr-user-message-editor__send" disabled={submitting || !!validationMessage} aria-busy={submitting} onClick={submit}>
|
||||
{submitting ? <EditorSpinnerIcon /> : null}
|
||||
<span>{sourceConversationLabel('userMessage.edit.send')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserMessageEditor
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 用户消息就地编辑:WorkBuddy 5.8.3 回收样式表 src-CyXHA0IC.css
|
||||
* sha256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe
|
||||
* REA 证据:ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3(打开/取消编辑)、
|
||||
* ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f(保持编辑器打开)、
|
||||
* ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405(编辑态布局快照)。
|
||||
* 说明:`.cr-self-bubble--editing` 与 `.cr-frame--right .cr-frame__content:has(.cr-user-message-editor)`
|
||||
* 已在 source-styles.css 中,这里不重复。
|
||||
*/
|
||||
|
||||
.cr-user-message-editor .cr-input-container {
|
||||
box-shadow: none;
|
||||
background: 0 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding-top: 0;
|
||||
/* 原版 InputBox minHeight=50:编辑容器单行时高 50(ev_ee345b16 计算样式 min-height:50px)。 */
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.cr-user-message-editor .cr-input-container--focused,
|
||||
.cr-user-message-editor .cr-input-container:focus-within {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cr-user-message-editor .cr-input-editor-host {
|
||||
max-height: 310px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cr-user-message-editor .cr-input-editor-host [contenteditable='true'] {
|
||||
min-height: 50px !important;
|
||||
}
|
||||
|
||||
.cr-user-message-editor .cr-input-editor-host [data-slate-editor] {
|
||||
font-size: 15px;
|
||||
line-height: 1.75 !important;
|
||||
}
|
||||
|
||||
.cr-user-message-editor .cr-input-editor-host [data-slate-editor] * {
|
||||
line-height: 1.75 !important;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__error {
|
||||
color: var(--cr-text-error);
|
||||
padding-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__footer {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__hint {
|
||||
min-width: 0;
|
||||
color: var(--cr-text-tertiary);
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__hint-icon {
|
||||
color: var(--cr-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__actions {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__cancel,
|
||||
.cr-user-message-editor__send,
|
||||
.cr-user-message-editor__resume {
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 5px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__cancel:disabled,
|
||||
.cr-user-message-editor__send:disabled,
|
||||
.cr-user-message-editor__resume:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__cancel,
|
||||
.cr-user-message-editor__resume {
|
||||
border: 1px solid var(--cr-border-default);
|
||||
color: var(--cr-text-secondary);
|
||||
background: 0 0;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__cancel:not(:disabled):hover,
|
||||
.cr-user-message-editor__resume:not(:disabled):hover {
|
||||
background: var(--cr-bg-hover);
|
||||
color: var(--cr-text-primary);
|
||||
}
|
||||
|
||||
.cr-user-message-editor__send {
|
||||
background: var(--cr-bg-contrast);
|
||||
color: var(--cr-color-on-accent);
|
||||
border: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.15s;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__send:not(:disabled):hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cr-user-message-editor__resume {
|
||||
align-self: flex-start;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 与输入框共用;此处保留一份,避免编辑器组件依赖其它按需加载的样式表。 */
|
||||
@keyframes cr-input-spin {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import type { ChatMessage } from '@shared/types'
|
||||
import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons'
|
||||
import { SourceThumbsUpIcon, SourceThumbsDownIcon, SourceThumbsUpSolidIcon, SourceThumbsDownSolidIcon, SourceResendIcon, SourceRevertIcon } from './SourceActionIcons'
|
||||
import { RuntimeRegenerateIcon } from './RuntimeIcons'
|
||||
import { copyUserMessage } from './message-copy'
|
||||
import {
|
||||
buildUserMessageClipboard,
|
||||
buildCopyMessagePayload,
|
||||
getSortedRequestMessages,
|
||||
projectSafeMessage,
|
||||
@@ -77,46 +77,7 @@ export function MessageActionToolbar({
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
if (message.role === 'user') {
|
||||
const clipboard = buildUserMessageClipboard(message)
|
||||
if (onCopy) {
|
||||
try {
|
||||
await onCopy(clipboard.plainText, clipboard)
|
||||
return
|
||||
} catch {
|
||||
// Continue through the browser clipboard fallbacks.
|
||||
}
|
||||
}
|
||||
if (typeof document !== 'undefined' && typeof document.execCommand === 'function') {
|
||||
const handler = (event: ClipboardEvent): void => {
|
||||
event.preventDefault()
|
||||
event.clipboardData?.setData('text/plain', clipboard.plainText)
|
||||
event.clipboardData?.setData('text/html', clipboard.html)
|
||||
event.clipboardData?.setData(clipboard.customMimeType, clipboard.customData)
|
||||
}
|
||||
document.addEventListener('copy', handler)
|
||||
let copied = false
|
||||
try {
|
||||
copied = document.execCommand('copy')
|
||||
} catch {
|
||||
copied = false
|
||||
} finally {
|
||||
document.removeEventListener('copy', handler)
|
||||
}
|
||||
if (copied) return
|
||||
}
|
||||
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({
|
||||
'text/plain': new Blob([clipboard.plainText], { type: 'text/plain' }),
|
||||
'text/html': new Blob([clipboard.html], { type: 'text/html' })
|
||||
})])
|
||||
return
|
||||
} catch {
|
||||
// The plain-text API below is the final supported fallback.
|
||||
}
|
||||
}
|
||||
if (!navigator.clipboard?.writeText) throw new Error('剪贴板不可用')
|
||||
await navigator.clipboard.writeText(clipboard.plainText)
|
||||
await copyUserMessage(message, onCopy)
|
||||
return
|
||||
}
|
||||
const row = requestId && requestMessages ? getSortedRequestMessages(requestMessages, requestId) : []
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { ChatMessage } from '@shared/types'
|
||||
import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons'
|
||||
import { RevertToBeforeIcon } from '../conversation/MessageEditorIcons'
|
||||
import { sourceConversationLabel } from '../conversation/source-labels'
|
||||
import { formatMessageTime } from '../../components/AssistantMessageDetails'
|
||||
import { RecoveredSelectTooltip } from '../../components/source-select/RecoveredSelectTooltip'
|
||||
import { copyUserMessage, type MessageCopyWriter } from './message-copy'
|
||||
|
||||
/**
|
||||
* 原版 IconButton 用 Tooltip$1(content / placement=bottom / showArrow / hideOnClick)。
|
||||
* 回收实现由 JSX 推导属性,这里显式声明用到的子集。
|
||||
*/
|
||||
type RecoveredTooltipProps = {
|
||||
content: string
|
||||
children: React.ReactElement
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
hideOnClick?: boolean
|
||||
showArrow?: boolean
|
||||
}
|
||||
const RecoveredTooltip = RecoveredSelectTooltip as unknown as (props: RecoveredTooltipProps) => React.JSX.Element
|
||||
|
||||
export interface UserMessageToolbarProps {
|
||||
message: ChatMessage
|
||||
/** 会话忙时禁止进入编辑(原版 A = resend || canHandle('message.edit.submit'))。 */
|
||||
canEdit?: boolean
|
||||
onEdit?(message: ChatMessage): void | Promise<void>
|
||||
onCopy?: MessageCopyWriter
|
||||
onRevert?(message: ChatMessage): void | Promise<void>
|
||||
revertDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息悬停工具条。
|
||||
* 结构与内容来自 WorkBuddy 5.8.3 渲染器 ToolbarRenderer(REA 回收 .tmp/wb-src-DbE1_mgM.js):
|
||||
* `div.cr-toolbar[data-message-id][role=toolbar]` + `span.cr-toolbar__time` + 图标按钮(复制/恢复/编辑)。
|
||||
* 几何与配色证据:ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3、
|
||||
* ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405。
|
||||
*/
|
||||
export function UserMessageToolbar({ message, canEdit = false, onEdit, onCopy, onRevert, revertDisabled = false }: UserMessageToolbarProps): React.JSX.Element | null {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}, [])
|
||||
|
||||
const time = formatMessageTime(message.createdAt)
|
||||
if (!time && !onEdit && !onRevert) return null
|
||||
|
||||
const copy = async (): Promise<void> => {
|
||||
setError(null)
|
||||
try {
|
||||
await copyUserMessage(message, onCopy)
|
||||
setCopied(true)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => setCopied(false), 2000)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
}
|
||||
}
|
||||
|
||||
const iconButton = (action: string, label: string, icon: ReactNode, onClick: () => void, options: { disabled?: boolean; hideTooltipOnClick?: boolean } = {}): React.JSX.Element => (
|
||||
<RecoveredTooltip content={label} placement="bottom" hideOnClick={options.hideTooltipOnClick ?? true}>
|
||||
<button
|
||||
type="button"
|
||||
className="cr-icon-button"
|
||||
data-action={action}
|
||||
aria-label={label}
|
||||
disabled={options.disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</RecoveredTooltip>
|
||||
)
|
||||
|
||||
return (
|
||||
<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}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserMessageToolbar
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ChatMessage } from '@shared/types'
|
||||
import { buildUserMessageClipboard } from './project-safe-message'
|
||||
|
||||
export type MessageCopyWriter = (text: string, payload: unknown) => void | Promise<void>
|
||||
|
||||
/**
|
||||
* 复制用户消息:优先交给宿主 onCopy,随后按浏览器剪贴板能力逐级回退。
|
||||
* 逻辑来自原版工具栏的 copy 分支(YCode MessageActionToolbar.copy),抽成共享实现,
|
||||
* 供旧工具条与新用户消息工具条共用。
|
||||
*/
|
||||
export async function copyUserMessage(message: ChatMessage, onCopy?: MessageCopyWriter): Promise<void> {
|
||||
const clipboard = buildUserMessageClipboard(message)
|
||||
if (onCopy) {
|
||||
try {
|
||||
await onCopy(clipboard.plainText, clipboard)
|
||||
return
|
||||
} catch {
|
||||
// Continue through the browser clipboard fallbacks.
|
||||
}
|
||||
}
|
||||
if (typeof document !== 'undefined' && typeof document.execCommand === 'function') {
|
||||
const handler = (event: ClipboardEvent): void => {
|
||||
event.preventDefault()
|
||||
event.clipboardData?.setData('text/plain', clipboard.plainText)
|
||||
event.clipboardData?.setData('text/html', clipboard.html)
|
||||
event.clipboardData?.setData(clipboard.customMimeType, clipboard.customData)
|
||||
}
|
||||
document.addEventListener('copy', handler)
|
||||
let copied = false
|
||||
try {
|
||||
copied = document.execCommand('copy')
|
||||
} catch {
|
||||
copied = false
|
||||
} finally {
|
||||
document.removeEventListener('copy', handler)
|
||||
}
|
||||
if (copied) return
|
||||
}
|
||||
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({
|
||||
'text/plain': new Blob([clipboard.plainText], { type: 'text/plain' }),
|
||||
'text/html': new Blob([clipboard.html], { type: 'text/html' })
|
||||
})])
|
||||
return
|
||||
} catch {
|
||||
// The plain-text API below is the final supported fallback.
|
||||
}
|
||||
}
|
||||
if (!navigator.clipboard?.writeText) throw new Error('剪贴板不可用')
|
||||
await navigator.clipboard.writeText(clipboard.plainText)
|
||||
}
|
||||
Reference in new issue
Block a user