feat(conversation): replicate table quote-to-chat action (TODO 02)
This commit is contained in:
1 parent
e335588826
commit
ed31a7c1b4
20 files changed
+939
-46
No files matched your search
+5
-2
@@ -532,7 +532,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
const win = getWindow()
|
||||
const { sessionId, text } = payload
|
||||
if (!(await sessions.listSessions()).some((session) => session.id === sessionId)) throw new Error('会话不存在')
|
||||
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length)) throw new Error('消息为空')
|
||||
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length && !payload.quotes?.length)) throw new Error('消息为空')
|
||||
if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('该会话正在运行')
|
||||
const attachments = await Promise.all((payload.attachments ?? []).map((item) => getAttachment(item.id)))
|
||||
const promptAttachments = await attachmentPrompt(attachments)
|
||||
@@ -601,6 +601,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
planId: payload.planId,
|
||||
attachments,
|
||||
resources: references,
|
||||
quotes: payload.quotes?.length ? payload.quotes : undefined,
|
||||
skills: capabilitySnapshot.references,
|
||||
capabilityContext: capabilitySnapshot.context || undefined,
|
||||
expertId: context.expertId ?? undefined,
|
||||
@@ -661,7 +662,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
}
|
||||
runEmitters.set(runId, emit)
|
||||
const interactionBroker = createInteractionBroker(runId, controller.signal, emit)
|
||||
const promptText = [text, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', capabilitySnapshot.context].filter(Boolean).join('\n\n')
|
||||
// 对话中引用的内容块(原版 message-quote)随消息一起交给模型;表格 markdown 原样附带。
|
||||
const quoteText = (payload.quotes ?? []).map((quote) => quote.markdown.trim()).filter(Boolean).join('\n\n')
|
||||
const promptText = [text, quoteText, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', capabilitySnapshot.context].filter(Boolean).join('\n\n')
|
||||
|
||||
// 回合在后台推进,立刻把 runId 还给渲染进程
|
||||
const initialMessages = payload.action ? structuredClone([...history, userMessage, assistant]) : undefined
|
||||
|
||||
@@ -20,7 +20,7 @@ import SourceSkillPicker from '../capabilities/SourceSkillPicker'
|
||||
import { SkillsIcon } from '../capabilities/SkillSourceIcons'
|
||||
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
|
||||
import { QuestionFloating } from '../workbuddy/conversation'
|
||||
import type { AgentMode, ChatAttachment, PermissionMode } from '@shared/conversation'
|
||||
import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode } from '@shared/conversation'
|
||||
import type { ChatMessage } from '@shared/types'
|
||||
import { getRevertChanges } from '@shared/revert'
|
||||
import { collectWorkbenchArtifacts } from '../workbuddy/workbench/workbench-state'
|
||||
@@ -56,7 +56,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const selectedWorkspaceId = useSettingsStore((s) => s.selectedWorkspaceId)
|
||||
const defaultWorkspaceRoot = useSettingsStore((s) => s.workspace)
|
||||
|
||||
const [drafts, setDrafts] = useState<Record<string, { text: string; attachments: ChatAttachment[]; resources?: string[]; skills?: Array<Pick<SkillRecord, 'id' | 'name'>>; editingId?: string; autoFilledPrompt?: string }>>({})
|
||||
const [drafts, setDrafts] = useState<Record<string, { text: string; attachments: ChatAttachment[]; resources?: string[]; quotes?: MessageQuote[]; skills?: Array<Pick<SkillRecord, 'id' | 'name'>>; editingId?: string; autoFilledPrompt?: string }>>({})
|
||||
const draftMoves = useRef(new Map<string, string>())
|
||||
const [skillPicker, setSkillPicker] = useState<{ owner: string; anchor: HTMLElement | null } | null>(null)
|
||||
const closeSkillPicker = useCallback(() => setSkillPicker(null), [])
|
||||
@@ -75,6 +75,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const draftKey = currentId ?? `new-${draftId}`
|
||||
const entry = drafts[draftKey] ?? { text: '', attachments: [] }
|
||||
const draft = entry.text
|
||||
const quotedSourceIds = useMemo(() => (entry.quotes ?? []).map((item) => item.sourceId), [entry.quotes])
|
||||
const setDraft = useCallback((value: string | ((current: string) => string)): void => {
|
||||
setDrafts((all) => { const current = all[draftKey] ?? { text: '', attachments: [] }; const text = typeof value === 'function' ? value(current.text) : value; return { ...all, [draftKey]: { ...current, text, autoFilledPrompt: text === current.autoFilledPrompt ? current.autoFilledPrompt : undefined } } })
|
||||
}, [draftKey])
|
||||
@@ -121,19 +122,33 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const [homeAction, setHomeAction] = useState<string | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const followBottom = useRef(true)
|
||||
const quoteTable = useCallback((markdown: string): void => {
|
||||
setDraft((current) => current ? `${current}\n\n${markdown}` : markdown)
|
||||
document.querySelector<HTMLTextAreaElement>('.wb-composer__input')?.focus()
|
||||
}, [setDraft])
|
||||
/** 把表格引用挂到当前草稿(原版 message.quote.add:引用与正文分开保存,不移写正文)。 */
|
||||
const quoteTable = useCallback((quote: MessageQuote): void => {
|
||||
setDrafts((all) => {
|
||||
const current = all[draftKey] ?? { text: '', attachments: [] }
|
||||
const quotes = current.quotes ?? []
|
||||
if (quotes.some((item) => item.id === quote.id)) return all
|
||||
return { ...all, [draftKey]: { ...current, quotes: [...quotes, quote] } }
|
||||
})
|
||||
requestAnimationFrame(() => document.querySelector<HTMLTextAreaElement>('.wb-composer__input')?.focus())
|
||||
}, [draftKey])
|
||||
const removeQuote = useCallback((quote: MessageQuote): void => {
|
||||
setDrafts((all) => {
|
||||
const current = all[draftKey]
|
||||
if (!current?.quotes) return all
|
||||
return { ...all, [draftKey]: { ...current, quotes: current.quotes.filter((item) => item.id !== quote.id) } }
|
||||
})
|
||||
}, [draftKey])
|
||||
useEffect(() => {
|
||||
const quote = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<{ text: string; sessionId?: string }>).detail
|
||||
if (detail?.sessionId !== (currentId ?? undefined) || typeof detail.text !== 'string') return
|
||||
quoteTable(detail.text)
|
||||
setDraft((current) => current ? `${current}\n\n${detail.text}` : detail.text)
|
||||
requestAnimationFrame(() => document.querySelector<HTMLTextAreaElement>('.wb-composer__input')?.focus())
|
||||
}
|
||||
window.addEventListener('ycode:quote-text', quote)
|
||||
return () => window.removeEventListener('ycode:quote-text', quote)
|
||||
}, [currentId, quoteTable])
|
||||
}, [currentId, setDraft])
|
||||
|
||||
const title = sessions.find((s) => s.id === currentId)?.title ?? '新对话'
|
||||
const isHome = currentId === null || messages.length === 0
|
||||
@@ -187,23 +202,23 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [currentId])
|
||||
|
||||
const submit = async ({ text, attachments, resources, mode }: ComposerSendPayload): Promise<void> => {
|
||||
if ((!text && !attachments.length) || running || sending) return
|
||||
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, 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, ...(entry.editingId ? { fromMessageId: entry.editingId, action: 'edit' as const } : {}) })
|
||||
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 ?? [], skills: message.skills, editingId: message.id } }))
|
||||
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()
|
||||
}
|
||||
const resend = (message: ChatMessage): Promise<void> => send(message.content, { attachments: message.attachments, resources: message.resources, skillIds: message.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'resend' })
|
||||
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, skillIds: preceding.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve()
|
||||
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()
|
||||
}
|
||||
const lastAssistantId = messages.findLast((message) => message.role === 'assistant')?.id
|
||||
const question = messages.flatMap((message) => message.interactions ?? []).find((item) => item.kind === 'question' && ['pending', 'responding', 'error'].includes(item.status))
|
||||
@@ -285,6 +300,16 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
onSelect={(_text, answer) => respond(question.id, { kind: 'question', answer })}
|
||||
onSkip={() => void respond(question.id, { kind: 'question', answer: { questions: question.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true }).catch(() => undefined)} />}
|
||||
onResourcesChange={(resources) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '', attachments: [] }), resources } }))}
|
||||
quotes={entry.quotes ?? []}
|
||||
onRemoveQuote={removeQuote}
|
||||
onActivateQuote={(quote) => {
|
||||
const target = document.querySelector<HTMLElement>(`[data-message-quote-source-id="${CSS.escape(quote.sourceId)}"]`)
|
||||
const block = target?.closest<HTMLElement>('.cr-table-block') ?? target?.parentElement
|
||||
if (!block) return
|
||||
block.scrollIntoView({ block: 'center' })
|
||||
block.setAttribute('data-message-quote-anchor-active', 'true')
|
||||
window.setTimeout(() => block.removeAttribute('data-message-quote-anchor-active'), 1200)
|
||||
}}
|
||||
onAttachmentsChange={(attachments) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '' }), attachments } }))}
|
||||
attachmentHost={{ pick: () => window.ycode.attachments.pick(), stage: ({ filename, ...input }) => window.ycode.attachments.stage({ name: filename, ...input }), remove: (id) => window.ycode.attachments.remove(id) }}
|
||||
onRemoveAttachment={(id) => messages.some((message) => message.attachments?.some((item) => item.id === id)) ? Promise.resolve() : window.ycode.attachments.remove(id)}
|
||||
@@ -342,6 +367,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
<div className="chat-thread cr-message-list__content"><div className="cr-document">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} sessionId={currentId} onQuote={quoteTable} darkMode={darkMode} onRespond={respond}
|
||||
quotedSourceIds={quotedSourceIds}
|
||||
artifactCount={!running && !sending && message.id === lastAssistantId ? artifactCount : 0}
|
||||
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}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ImagePreview } from '../workbuddy/images/ImagePreview'
|
||||
import { requiresHostImageResolution, type ImagePreviewRequest, type ImageUrlResolver } from '../workbuddy/images/image-url'
|
||||
import { downloadImageInHost, downloadInHost, openPathInHost, resolveCodeInHost, resolveImageInHost } from '../workbuddy/host'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import type { MessageQuote } from '@shared/conversation'
|
||||
import { TableMessageQuoteAction } from '../workbuddy/conversation/TableMessageQuoteAction'
|
||||
|
||||
export { CopyButton } from '../workbuddy/components/CodeBlock'
|
||||
export { parseCodeBlockMeta, resolveCodeBlockFenceInfo } from '../workbuddy/markdown/code-fence'
|
||||
@@ -20,7 +22,11 @@ export type { CodeBlockFileMetadata, CodeBlockFenceInfo } from '../workbuddy/mar
|
||||
export interface MarkdownContentProps {
|
||||
content: string
|
||||
streaming?: boolean
|
||||
onQuote?: (markdown: string) => void
|
||||
onQuote?: (quote: MessageQuote) => void
|
||||
/** 已加入对话的引用来源,用于隐藏按钮并标记表格。 */
|
||||
quotedSourceIds?: readonly string[]
|
||||
/** 内容块来源标识;与原版 `${messageId}-text-${index}` 一致。 */
|
||||
sourceId?: string
|
||||
messageId?: string
|
||||
darkMode?: boolean
|
||||
imageUrlResolver?: ImageUrlResolver
|
||||
@@ -34,8 +40,11 @@ export function getMappedSource(source: string, map: [number, number] | null): s
|
||||
}
|
||||
|
||||
/** YCode integration adapter; source-derived renderer lives in workbuddy/markdown. */
|
||||
export default function MarkdownContent({ content, streaming = false, onQuote, messageId, darkMode = false, imageUrlResolver = resolveImageInHost }: MarkdownContentProps): ReactElement {
|
||||
export default function MarkdownContent({ content, streaming = false, onQuote, quotedSourceIds, sourceId, messageId, darkMode = false, imageUrlResolver = resolveImageInHost }: MarkdownContentProps): ReactElement {
|
||||
const fallbackId = useId()
|
||||
/** 内容块标识:原版为 `${messageId}-text-${index}`,缺省回退到消息 id。 */
|
||||
const blockSourceId = sourceId ?? messageId
|
||||
const quoted = useMemo(() => new Set(quotedSourceIds ?? []), [quotedSourceIds])
|
||||
const workspace = useSettingsStore((state) => state.workspace)
|
||||
const paths = useMemo(() => workspace ? [workspace] : [], [workspace])
|
||||
const [imagePreview, setImagePreview] = useState<ImagePreviewRequest | null>(null)
|
||||
@@ -60,15 +69,19 @@ export default function MarkdownContent({ content, streaming = false, onQuote, m
|
||||
applyMathPlugin(md)
|
||||
return md
|
||||
}, [mathVersion])
|
||||
const renderTableAction = useCallback(({ markdown, disabled }: TableActionContext) => onQuote && markdown ? (
|
||||
<button type="button" className="markdown-content__table-quote" disabled={disabled} onClick={() => onQuote(markdown)}>添加到对话</button>
|
||||
) : null, [onQuote])
|
||||
const renderTableAction = useCallback((context: TableActionContext) => {
|
||||
const { markdown, disabled } = context
|
||||
if (!onQuote || !markdown) return null
|
||||
// 原版 createTableMessageQuoteSourceId: `${sourceId}:table:${tableId}`
|
||||
const id = context.sourceId && context.tableId ? `${context.sourceId}:table:${context.tableId}` : ''
|
||||
return <TableMessageQuoteAction markdown={markdown} sourceId={id} added={quoted.has(id)} disabled={disabled} onAdd={onQuote} />
|
||||
}, [onQuote, quoted])
|
||||
const value = useMemo(() => ({ mdInstance, highlightVersion, renderTableAction: onQuote ? renderTableAction : undefined,
|
||||
requestId: messageId || fallbackId, paths, resolveCode: resolveCodeInHost, pathClickHandler: { onPathClick: openPathInHost },
|
||||
imageUrlResolver, preferHostImageResolution: true, onPreviewImage, onDownload: downloadInHost, isDarkMode: darkMode }),
|
||||
[mdInstance, highlightVersion, onQuote, renderTableAction, messageId, fallbackId, paths, onPreviewImage, darkMode, imageUrlResolver])
|
||||
return <>
|
||||
<ReactMarkdownProvider value={value}><ReactMarkdown isFinished={!streaming} messageId={messageId}>{content}</ReactMarkdown></ReactMarkdownProvider>
|
||||
<ReactMarkdownProvider value={value}><ReactMarkdown isFinished={!streaming} messageId={messageId} sourceId={blockSourceId}>{content}</ReactMarkdown></ReactMarkdownProvider>
|
||||
{imagePreview ? createPortal(<ImagePreview {...imagePreview} onClose={closeImagePreview} onDownload={downloadImageInHost} />, document.body) : null}
|
||||
</>
|
||||
}
|
||||
@@ -17,16 +17,19 @@ import { PermissionConfirm, QuestionAnswerTranscript, ThinkingBlock, PlanCreateB
|
||||
import { conversationLabel } from '../workbuddy/conversation/labels'
|
||||
import { downloadInHost, openPathInHost } from '../workbuddy/host'
|
||||
import { openArtifactPreview } from '../stores/resources'
|
||||
import type { InteractionResponse, PlanContent } from '@shared/conversation'
|
||||
import type { InteractionResponse, MessageQuote, PlanContent } from '@shared/conversation'
|
||||
import MarkdownContent from './MarkdownContent'
|
||||
import StreamingFooter from '../workbuddy/conversation/StreamingFooter'
|
||||
import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration'
|
||||
import { getMessageParts } from '@shared/message-parts'
|
||||
import { projectWorkBuddyHistoryTool } from '@shared/workbuddy-history-projection'
|
||||
import { MessageQuoteTag } from '../workbuddy/conversation/MessageQuoteTag'
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage
|
||||
onQuote?(markdown: string): void
|
||||
onQuote?(quote: MessageQuote): void
|
||||
/** 当前输入区已引用的来源;用于隐藏表格按钮并高亮表格。 */
|
||||
quotedSourceIds?: readonly string[]
|
||||
darkMode?: boolean
|
||||
actions?: Omit<MessageActionToolbarProps, 'message'>
|
||||
onRespond?(id: string, response: InteractionResponse): Promise<void>
|
||||
@@ -37,7 +40,7 @@ interface Props {
|
||||
onShowArtifacts?(): void
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message, onQuote, darkMode, actions, onRespond, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element {
|
||||
export default function MessageBubble({ message, onQuote, quotedSourceIds, darkMode, actions, 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,7 +55,12 @@ export default function MessageBubble({ message, onQuote, darkMode, actions, onR
|
||||
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"><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>
|
||||
<div className="bubble cr-self-bubble">
|
||||
{!!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>
|
||||
</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}>
|
||||
{attachment.previewUrl ? <MarkdownContent content={`![${attachment.name.replace(/[\[\]]/g, '')}](${attachment.previewUrl})`} messageId={attachment.id} darkMode={darkMode} /> : <span>{attachment.name}</span>}
|
||||
@@ -224,6 +232,8 @@ export default function MessageBubble({ message, onQuote, darkMode, actions, onR
|
||||
<MarkdownContent
|
||||
content={part.text}
|
||||
messageId={message.id}
|
||||
sourceId={`${message.id}-text-${index}`}
|
||||
quotedSourceIds={quotedSourceIds}
|
||||
darkMode={darkMode}
|
||||
streaming={streaming && index === parts.length - 1}
|
||||
onQuote={onQuote}
|
||||
|
||||
@@ -282,7 +282,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
},
|
||||
async send(text, options = {}) {
|
||||
const trimmed = text.trim()
|
||||
if ((!trimmed && !options.attachments?.length) || get().loading) return
|
||||
if ((!trimmed && !options.attachments?.length && !options.quotes?.length) || get().loading) return
|
||||
let sessionId = get().currentSessionId
|
||||
if (!sessionId) {
|
||||
if (get().sending) return
|
||||
|
||||
@@ -7,6 +7,8 @@ import './composer.css'
|
||||
import './composer-source.css'
|
||||
import SourceComposerAddMenu, { type AddMenuEntry, type SubmenuContext } from './add-menu/SourceComposerAddMenu'
|
||||
import { SourceFilesSubmenu } from './add-menu/SourceSubmenus'
|
||||
import type { MessageQuote } from '@shared/conversation'
|
||||
import { MessageQuoteCard } from '../conversation/MessageQuoteCard'
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -20,6 +22,8 @@ export interface ComposerSendPayload {
|
||||
text: string
|
||||
attachments: ChatAttachment[]
|
||||
resources: string[]
|
||||
/** 引用进对话的内容块(WorkBuddy message.quote)。 */
|
||||
quotes: MessageQuote[]
|
||||
mode: PermissionMode
|
||||
}
|
||||
|
||||
@@ -31,6 +35,11 @@ export interface ComposerControlsProps {
|
||||
onAttachmentsChange?(attachments: ChatAttachment[]): void
|
||||
resources?: string[]
|
||||
onResourcesChange?(resources: string[]): void
|
||||
/** 已引用进对话的内容块(表格等)。 */
|
||||
quotes?: MessageQuote[]
|
||||
onRemoveQuote?(quote: MessageQuote): void
|
||||
/** 点击引用标签回到来源内容。 */
|
||||
onActivateQuote?(quote: MessageQuote): void
|
||||
onRemoveResourcePath?(path: string): void | Promise<void>
|
||||
attachmentHost?: AttachmentHost
|
||||
onRemoveAttachment?(id: string): void | Promise<void>
|
||||
@@ -73,9 +82,10 @@ export function buildComposerSendPayload(
|
||||
text: string,
|
||||
attachments: ChatAttachment[],
|
||||
resources: string[],
|
||||
mode: PermissionMode
|
||||
mode: PermissionMode,
|
||||
quotes: MessageQuote[] = []
|
||||
): ComposerSendPayload {
|
||||
return { text: text.trim(), attachments, resources: [...resources], mode }
|
||||
return { text: text.trim(), attachments, resources: [...resources], quotes: [...quotes], mode }
|
||||
}
|
||||
|
||||
async function encodeFile(file: File): Promise<string> {
|
||||
@@ -120,6 +130,9 @@ export function ComposerControls({
|
||||
onAttachmentsChange,
|
||||
resources,
|
||||
onResourcesChange,
|
||||
quotes = [],
|
||||
onRemoveQuote,
|
||||
onActivateQuote,
|
||||
onRemoveResourcePath,
|
||||
attachmentHost,
|
||||
onRemoveAttachment,
|
||||
@@ -174,6 +187,8 @@ export function ComposerControls({
|
||||
const resourcePaths = resources ?? localResources
|
||||
const resourcesRef = useRef(resourcePaths)
|
||||
resourcesRef.current = resourcePaths
|
||||
const quotesRef = useRef(quotes)
|
||||
quotesRef.current = quotes
|
||||
|
||||
const beginBusy = (): void => {
|
||||
busyCountRef.current += 1
|
||||
@@ -323,7 +338,7 @@ export function ComposerControls({
|
||||
}
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (sendInFlightRef.current || disabled || sending || busyCountRef.current > 0 || (!value.trim() && attachments.length === 0 && resourcesRef.current.length === 0)) return
|
||||
if (sendInFlightRef.current || disabled || sending || busyCountRef.current > 0 || (!value.trim() && attachments.length === 0 && resourcesRef.current.length === 0 && quotesRef.current.length === 0)) return
|
||||
const tooLarge = attachments.filter((attachment) => attachment.size > MAX_ATTACHMENT_BYTES)
|
||||
if (tooLarge.length) {
|
||||
setError(`文件超过 20 MB:${tooLarge.map((item) => item.name).join('、')}`)
|
||||
@@ -333,7 +348,7 @@ export function ComposerControls({
|
||||
sendInFlightRef.current = true
|
||||
beginBusy()
|
||||
try {
|
||||
await onSend(buildComposerSendPayload(value, attachments, resourcesRef.current, permissionMode))
|
||||
await onSend(buildComposerSendPayload(value, attachments, resourcesRef.current, permissionMode, quotesRef.current))
|
||||
} catch (reason) {
|
||||
setError(errorText(reason))
|
||||
} finally {
|
||||
@@ -457,6 +472,12 @@ export function ComposerControls({
|
||||
{dragging && <div className="wb-composer__drop-overlay" aria-hidden="true">放下文件以添加附件</div>}
|
||||
<div className="cr-input-box__main">
|
||||
{capabilityReferences}
|
||||
{quotes.length > 0 && (
|
||||
<div className="cr-input-banner-rail cr-message-quote-rail" aria-label="引用的内容">
|
||||
{quotes.map((quote) => <MessageQuoteCard key={quote.id} quote={quote}
|
||||
onRemove={onRemoveQuote} onActivate={onActivateQuote} />)}
|
||||
</div>
|
||||
)}
|
||||
{attachments.length > 0 && (
|
||||
<div className="wb-composer__attachments" aria-label="附件">
|
||||
{attachments.map((attachment) => (
|
||||
@@ -541,7 +562,7 @@ export function ComposerControls({
|
||||
<span className="cr-send-button__icon"><IconStop /></span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="wb-composer__send cr-send-button cr-input-toolbar__send" title="发送" aria-label="发送" onClick={() => void submit()} disabled={disabled || sending || busy || (!value.trim() && attachments.length === 0 && resourcePaths.length === 0)}>
|
||||
<button type="button" className="wb-composer__send cr-send-button cr-input-toolbar__send" title="发送" aria-label="发送" onClick={() => void submit()} disabled={disabled || sending || busy || (!value.trim() && attachments.length === 0 && resourcePaths.length === 0 && quotes.length === 0)}>
|
||||
<span className="cr-send-button__icon"><RuntimeSendIcon /></span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MouseEvent, ReactElement } from 'react'
|
||||
import type { MessageQuote } from '@shared/conversation'
|
||||
import { ChipCloseIcon } from './MessageQuoteIcons'
|
||||
import './message-quote-source.css'
|
||||
|
||||
export interface MessageQuoteCardProps {
|
||||
quote: MessageQuote
|
||||
/** 作者与摘要之间的分隔符,原版 chatInput.quoteSeparator。 */
|
||||
separator?: string
|
||||
removeLabel?: string
|
||||
onRemove?(quote: MessageQuote): void
|
||||
onActivate?(quote: MessageQuote): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入区的引用卡片(WorkBuddy QuoteReferenceCard)。
|
||||
* 结构:左侧强调条 + 正文(作者/摘要)+ 移除按钮,尺寸与配色取自回收样式表。
|
||||
*/
|
||||
export function MessageQuoteCard({ quote, separator = ':', removeLabel = '移除引用', onRemove, onActivate }: MessageQuoteCardProps): ReactElement {
|
||||
const remove = (event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation()
|
||||
onRemove?.(quote)
|
||||
}
|
||||
return (
|
||||
<div className="cr-input-quote-card" data-message-quote-source-id={quote.sourceId} data-reference-id={quote.id}
|
||||
onClick={onActivate ? () => onActivate(quote) : undefined}>
|
||||
<span className="cr-input-quote-card__accent" aria-hidden="true" />
|
||||
<span className="cr-input-quote-card__body">
|
||||
{quote.title ? <span className="cr-input-quote-card__author">{quote.title}{separator}</span> : null}
|
||||
<span className="cr-input-quote-card__summary">{quote.preview}</span>
|
||||
</span>
|
||||
{onRemove && <button type="button" className="cr-input-quote-card__remove" aria-label={removeLabel} title={removeLabel} onClick={remove}><ChipCloseIcon /></button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
/**
|
||||
* WorkBuddy 5.8.3 引用相关图标。
|
||||
* 来源:REA 回收的渲染器 bundle `.tmp/wb-src-DbE1_mgM.js`
|
||||
* (`MessageQuoteAddIcon` / `MessageQuoteTextIcon` 定义原文),
|
||||
* 以及 REA 布局证据 `ev_38cca1ac66606ab07fb495035e3509f5c4d8ec3e5e2b74d0fdcfcfadb120da3b`
|
||||
* 中 `.cr-message-quote-table-menu svg` 的 16×16 与两条 path 的 d/transform。
|
||||
*/
|
||||
|
||||
/** 表格“添加到对话”按钮图标(对话气泡 + 加号)。 */
|
||||
export function MessageQuoteAddIcon(): ReactElement {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" transform="matrix(1 0 0 1 0.773201 0.778992)"
|
||||
d="M7.5868 0.0001C7.499 -0.0001 7.4144 -0 7.3335 0C7.2971 0.0001 7.2615 0.0001 7.2266 0.0001C7.1914 0.0001 7.1554 0.0001 7.1187 0C6.4251 -0.0005 5.4655 -0.0012 4.5 0.17C3.4838 0.3502 2.392 0.7316 1.5605 1.5636C0.1676 2.9577 0 4.8385 0 6.2804C0 7.7222 0.1678 9.6021 1.5605 10.9962C2.45 11.8863 3.5897 12.2648 4.6387 12.4298L4.7324 11.837L4.8252 11.2442C3.9083 11.1001 3.0494 10.7892 2.4092 10.1485C1.3802 9.1185 1.1993 7.6782 1.1992 6.2804C1.1992 4.8825 1.3801 3.4423 2.4092 2.4122C2.9967 1.8242 3.8153 1.5103 4.71 1.3517C5.5721 1.1988 6.4411 1.1991 7.1476 1.1993L7.2266 1.1993L7.3054 1.1993C7.3966 1.1993 7.4905 1.1992 7.5868 1.1995L7.5868 0.0001ZM13.2418 6.861C13.1877 8.0684 12.9308 9.2608 12.0439 10.1485C11.4695 10.7233 10.7192 11.0335 9.9062 11.1954C9.0891 11.3581 8.2533 11.3624 7.5342 11.3604C6.8348 11.3587 6.2261 11.7597 5.7373 12.2286C5.2367 12.709 4.7714 13.3484 4.3535 14.005L5.3662 14.6495C5.7651 14.0227 6.17 13.4771 6.5684 13.0948C6.9785 12.7013 7.2983 12.56 7.5312 12.5606C8.2453 12.5626 9.1916 12.5611 10.1406 12.3721C11.0936 12.1824 12.094 11.7954 12.8926 10.9962C14.1048 9.7828 14.389 8.2013 14.4424 6.861L13.2418 6.861Z" />
|
||||
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" transform="matrix(1 0 0 1 9.19556 0.777834)"
|
||||
d="M3.61 3.6L3.61 6L2.41 6L2.41 3.6L0 3.6L0 2.4L2.41 2.4L2.41 0L3.61 0L3.61 2.4L6.02 2.4L6.02 3.6L3.61 3.6Z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
/** 文本引用标签图标(带 T 的对话气泡)。 */
|
||||
export function MessageQuoteTextIcon(): ReactElement {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path fill="currentColor" fillRule="evenodd" transform="matrix(1 0 0 1 1.19996 1.19991)"
|
||||
d="M0 6.2449L0 7.3551Q-0.0001 9.7054 0.1102 10.5133Q0.295 11.8673 1.0139 12.5861Q1.7327 13.305 3.0867 13.4898Q3.8946 13.6001 6.2449 13.6L6.3231 13.6L7.2769 13.6L7.3551 13.6Q9.7054 13.6001 10.5133 13.4898Q11.8673 13.305 12.5861 12.5861Q13.305 11.8673 13.4898 10.5133Q13.6001 9.7054 13.6 7.3551L13.6 7.2769L13.6 6.3231L13.6 6.2449Q13.6001 3.8946 13.4898 3.0867Q13.305 1.7327 12.5861 1.0139Q11.8673 0.295 10.5133 0.1102Q9.7054 -0.0001 7.3551 0L6.2449 0Q3.8946 -0.0001 3.0867 0.1102Q1.7327 0.295 1.0139 1.0139Q0.295 1.7327 0.1102 3.0867Q-0.0001 3.8946 0 6.2449ZM1.2 7.2769L1.2 6.3231L1.2 6.2449Q1.1999 3.9761 1.2992 3.2489Q1.4293 2.2955 1.8624 1.8624Q2.2955 1.4293 3.2489 1.2992Q3.9761 1.1999 6.2449 1.2L6.3231 1.2L7.2769 1.2L7.3551 1.2Q9.6239 1.1999 10.3511 1.2992Q11.3045 1.4293 11.7376 1.8624Q12.1707 2.2955 12.3008 3.2489Q12.4001 3.9761 12.4 6.2449L12.4 6.3231L12.4 7.2769L12.4 7.3551Q12.4001 9.6239 12.3008 10.3511Q12.1707 11.3045 11.7376 11.7376Q11.3045 12.1707 10.3511 12.3008Q9.6239 12.4001 7.3551 12.4L7.2769 12.4L6.3231 12.4L6.2449 12.4Q3.9761 12.4001 3.2489 12.3008Q2.2955 12.1707 1.8624 11.7376Q1.4293 11.3045 1.2992 10.3511Q1.1999 9.6239 1.2 7.3551L1.2 7.2769ZM3.3 4.1986L10.3 4.1986L10.3 5.3986L7.4 5.3986L7.4 10.6497L6.2 10.6497L6.2 5.3986L3.3 5.3986L3.3 4.1986Z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
/** 引用卡片移除按钮图标(ChipCloseIcon)。 */
|
||||
export function ChipCloseIcon(): ReactElement {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path fill="currentColor" fillRule="evenodd" transform="matrix(1 0 0 1 2.5651 2.5651)"
|
||||
d="M6.3046 5.4349L10.8698 10L10 10.8698L5.4349 6.3046L0.8698 10.8698L0 10L4.5651 5.4349L0 0.8698L0.8698 0L5.4349 4.5651L10 0L10.8698 0.8698L6.3046 5.4349Z" />
|
||||
</svg>
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { MouseEvent, ReactElement } from 'react'
|
||||
import type { MessageQuote } from '@shared/conversation'
|
||||
import { MessageQuoteTextIcon } from './MessageQuoteIcons'
|
||||
import './message-quote-source.css'
|
||||
|
||||
export interface MessageQuoteTagProps {
|
||||
quote: MessageQuote
|
||||
/** 发送后的历史消息里为只读标签:不可移除,配色切到 message-quote 只读态。 */
|
||||
readOnly?: boolean
|
||||
onRemove?(quote: MessageQuote): void
|
||||
/** 点击标签时回到原表格并高亮。 */
|
||||
onActivate?(quote: MessageQuote): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入区与用户消息共用的引用标签。
|
||||
* 结构复刻 WorkBuddy 内容块标签:图标容器 + 文本 + 悬停替换出的移除按钮。
|
||||
*/
|
||||
export function MessageQuoteTag({ quote, readOnly = false, onRemove, onActivate }: MessageQuoteTagProps): ReactElement {
|
||||
const removable = !readOnly && !!onRemove
|
||||
const remove = (event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation()
|
||||
onRemove?.(quote)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={['cr-message-quote-tag', readOnly ? 'cr-message-quote-tag--read-only' : '', removable ? 'cr-message-quote-tag--removable' : ''].filter(Boolean).join(' ')}
|
||||
data-content-block-meta-type="message-quote"
|
||||
data-message-quote-id={quote.id}
|
||||
data-message-quote-source-id={quote.sourceId}
|
||||
role={onActivate ? 'button' : undefined}
|
||||
tabIndex={onActivate ? 0 : undefined}
|
||||
title={quote.preview}
|
||||
onClick={onActivate ? () => onActivate(quote) : undefined}
|
||||
onKeyDown={onActivate ? (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onActivate(quote) } } : undefined}
|
||||
>
|
||||
<span className="cr-message-quote-tag__icon-container"><MessageQuoteTextIcon /></span>
|
||||
<span className="cr-message-quote-tag__text">{quote.preview}</span>
|
||||
{removable && <button type="button" className="cr-message-quote-tag__close" aria-label={`移除引用 ${quote.preview}`} title={`移除引用 ${quote.preview}`} onClick={remove}>×</button>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, type ReactElement } from 'react'
|
||||
import type { MessageQuote } from '@shared/conversation'
|
||||
import { MessageQuoteAddIcon } from './MessageQuoteIcons'
|
||||
import './message-quote-source.css'
|
||||
|
||||
export interface TableMessageQuoteActionProps {
|
||||
markdown: string
|
||||
/** `${messageSourceId}:table:${tableId}`,与原版 createTableMessageQuoteSourceId 一致。 */
|
||||
sourceId: string
|
||||
/** 已加入对话时隐藏按钮并给表格加品牌色描边。 */
|
||||
added: boolean
|
||||
/** 流式未完成的表格不可引用。 */
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
onAdd(quote: MessageQuote): void | Promise<void>
|
||||
}
|
||||
|
||||
/** 原版 markdown 表格上方的“添加到对话”菜单。 */
|
||||
/** 原版 getTableMessageQuoteMessageId:从 `${messageId}-text-${index}` 还原消息 id。 */
|
||||
export function getTableMessageQuoteMessageId(sourceId: string | undefined): string | undefined {
|
||||
return sourceId?.match(/^(.*)-text-\d+$/)?.[1]
|
||||
}
|
||||
|
||||
export function TableMessageQuoteAction({ markdown, sourceId, added, disabled = false, label = '添加到对话', onAdd }: TableMessageQuoteActionProps): ReactElement | null {
|
||||
const click = useCallback((): void => {
|
||||
void onAdd({
|
||||
id: sourceId,
|
||||
kind: 'table',
|
||||
sourceId,
|
||||
messageId: getTableMessageQuoteMessageId(sourceId.match(/^(.*):table:[^:]*$/)?.[1]),
|
||||
markdown,
|
||||
preview: markdown.replace(/\s+/g, ' ').trim()
|
||||
})
|
||||
}, [markdown, onAdd, sourceId])
|
||||
if (!sourceId || !markdown.trim()) return null
|
||||
return (
|
||||
<div
|
||||
className={`cr-message-quote-table-menu${added ? ' cr-message-quote-table-menu--added' : ''}`}
|
||||
data-message-quote-table-added={added ? 'true' : undefined}
|
||||
data-message-quote-source-id={sourceId}
|
||||
>
|
||||
{added ? null : <button type="button" className="cr-message-quote-table-action" disabled={disabled} aria-label={label} onClick={click}>
|
||||
<MessageQuoteAddIcon />
|
||||
<span>{label}</span>
|
||||
</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* WorkBuddy 5.8.3 消息引用(添加到对话)样式。
|
||||
* 来源:REA 回收的样式表 src-CyXHA0IC.css(SHA-256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe)
|
||||
* 中的 `.cr-message-quote-table-menu`、`.cr-message-quote-table-action`、`.cr-table-block:hover …`
|
||||
* 与模块化标签规则 `._tag_1krka_19` / `._iconContainer_1krka_38` / `._icon_1krka_38` / `._text_1krka_66`
|
||||
* (后者的哈希后缀在回收产物中为重导出常量,这里按其声明值复刻)。
|
||||
* 几何交叉验证:REA 证据 ev_38cca1ac66606ab07fb495035e3509f5c4d8ec3e5e2b74d0fdcfcfadb120da3b
|
||||
* ——菜单 116×38、左侧绝对定位 top:8px right:8px、按钮 106×28 / 14px / 22px / padding 3px 8px / gap 4px / 圆角 6px。
|
||||
*/
|
||||
|
||||
/*
|
||||
* 原版 5.8.3 的回收样式表里没有 --cr-brand-secondary 的定义,但同版本
|
||||
* contexts-BkjwYSZT.css 在 `var(--cr-brand-secondary,#40d1b3)` 中给出了兜底值。
|
||||
* 这里沿用该兜底值,避免 border-color 变成无效声明回退到文字色。
|
||||
* 若后续拿到原版运行时 token 定义,应以真实值替换。
|
||||
*/
|
||||
.chat-pane.cr-theme {
|
||||
--cr-brand-secondary: #40d1b3;
|
||||
}
|
||||
|
||||
/* 表格悬浮“添加到对话”菜单 */
|
||||
.cr-message-quote-table-menu {
|
||||
z-index: 3;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: max-content;
|
||||
border: 1px solid var(--cr-border-default);
|
||||
background: var(--cr-bg-primary);
|
||||
box-shadow: var(--cr-shadow-panel-light);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 8px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
transition: opacity .15s;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cr-message-quote-table-menu:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.cr-message-quote-table-menu--added {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cr-message-quote-table-action {
|
||||
width: max-content;
|
||||
color: var(--cr-text-primary);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
transition: background-color .15s;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.cr-message-quote-table-action svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cr-message-quote-table-action:hover:not(:disabled) {
|
||||
background: var(--cr-bg-secondary);
|
||||
}
|
||||
|
||||
.cr-message-quote-table-action:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 被引用表格:已加入时保留品牌色描边,悬停引用标签时高亮 */
|
||||
.cr-table-block:has(> [data-message-quote-table-added=true]) .cr-table-block__inner {
|
||||
border-color: var(--cr-brand-secondary);
|
||||
}
|
||||
|
||||
.cr-table-block[data-message-quote-anchor-active=true] .cr-table-block__inner,
|
||||
.cr-table-block:has([data-message-quote-anchor-active=true]) .cr-table-block__inner {
|
||||
border-color: var(--cr-brand-secondary);
|
||||
background: var(--cr-bg-brand-subtle);
|
||||
}
|
||||
|
||||
/* 输入区引用标签(模块化 _tag_1krka_19 + message-quote 变体) */
|
||||
.cr-message-quote-tag {
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
background-color: var(--cr-tag-skill-bg);
|
||||
height: 22px;
|
||||
color: var(--cr-text-primary);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: calc(100% - 4px);
|
||||
margin-inline: 2px;
|
||||
padding: 2px 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.cr-message-quote-tag[data-content-block-meta-type=message-quote] {
|
||||
background-color: var(--cr-tag-skill-bg);
|
||||
color: var(--cr-text-primary);
|
||||
}
|
||||
|
||||
.cr-message-quote-tag[data-content-block-meta-type=message-quote]:hover {
|
||||
background-color: var(--cr-tag-skill-bg-hover);
|
||||
}
|
||||
|
||||
.cr-message-quote-tag[data-content-block-meta-type=message-quote]:focus,
|
||||
.cr-message-quote-tag[data-content-block-meta-type=message-quote]:focus-visible {
|
||||
background-color: var(--cr-tag-skill-bg-active);
|
||||
color: var(--cr-text-primary);
|
||||
}
|
||||
|
||||
.cr-message-quote-tag--read-only {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cr-message-quote-tag[data-content-block-meta-type=message-quote].cr-message-quote-tag--read-only {
|
||||
background-color: var(--cr-tag-message-quote-bg);
|
||||
}
|
||||
|
||||
.cr-message-quote-tag[data-content-block-meta-type=message-quote].cr-message-quote-tag--read-only:focus,
|
||||
.cr-message-quote-tag[data-content-block-meta-type=message-quote].cr-message-quote-tag--read-only:focus-visible {
|
||||
background-color: var(--cr-tag-message-quote-bg-active);
|
||||
}
|
||||
|
||||
.cr-message-quote-tag__icon-container {
|
||||
visibility: visible;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.cr-message-quote-tag__icon-container > svg,
|
||||
.cr-message-quote-tag__icon-container > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cr-message-quote-tag__text {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cr-message-quote-tag__close {
|
||||
box-sizing: content-box;
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -4px;
|
||||
padding: 4px;
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 4px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.cr-message-quote-tag--removable:hover .cr-message-quote-tag__icon-container {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.cr-message-quote-tag--removable:hover .cr-message-quote-tag__close {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* 短语样式引用(悬浮提示 + 圆角标签) */
|
||||
.cr-message-quote-phrase-trigger {
|
||||
vertical-align: middle;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 160px;
|
||||
margin-inline: 4px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.cr-message-quote-phrase-trigger > [data-content-block-meta-type=message-quote] {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.cr-message-quote-phrase-tag {
|
||||
box-sizing: border-box;
|
||||
width: fit-content;
|
||||
max-width: 160px;
|
||||
height: 23px;
|
||||
color: var(--cr-text-primary);
|
||||
border-radius: 100px;
|
||||
gap: 4px;
|
||||
padding: 1px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.cr-message-quote-phrase-tag .cr-message-quote-tag__icon-container,
|
||||
.cr-message-quote-phrase-tag .cr-message-quote-tag__icon-container > svg,
|
||||
.cr-message-quote-phrase-tag .cr-message-quote-tag__icon-container > img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.cr-message-quote-phrase-tag .cr-message-quote-tag__text {
|
||||
min-width: 0;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.cr-message-quote-phrase-tooltip-surface {
|
||||
border: .5px solid var(--cr-border-default);
|
||||
background: var(--cr-bg-elevated);
|
||||
width: min(400px, 100vw - 24px);
|
||||
color: var(--cr-text-primary);
|
||||
border-radius: 16px;
|
||||
padding: 0;
|
||||
box-shadow: 0 6px 24px -4px #00000014, 0 4px 6px -2px #0000000f;
|
||||
}
|
||||
|
||||
.cr-message-quote-phrase-tooltip {
|
||||
overscroll-behavior: contain;
|
||||
max-height: min(240px, 100vh - 48px);
|
||||
color: var(--cr-text-primary);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cr-message-quote-phrase-tooltip__source {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cr-message-quote-phrase-tooltip__source > svg {
|
||||
flex: 0 0 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.cr-message-quote-phrase-tooltip__source > span {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
/* 输入区引用容器:原版 _container_1krka_1 */
|
||||
.cr-message-quote-container {
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 18px;
|
||||
line-height: 18px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* 输入区引用卡片:原版 QuoteReferenceCard / input-banner-rail */
|
||||
.cr-input-banner-rail {
|
||||
box-sizing: border-box;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 0 16px 8px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cr-message-quote-rail {
|
||||
display: block;
|
||||
padding: 0 16px 8px;
|
||||
}
|
||||
|
||||
.cr-input-quote-card {
|
||||
box-sizing: border-box;
|
||||
background: var(--cr-item-hover-bg);
|
||||
width: 100%;
|
||||
color: var(--cr-text-tertiary);
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
transition: background .15s;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cr-input-quote-card__accent {
|
||||
background: var(--cr-border-faint);
|
||||
border-radius: 1px;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.cr-input-quote-card__body {
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cr-input-quote-card__author {
|
||||
min-width: 0;
|
||||
color: var(--cr-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 0 auto;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cr-input-quote-card__summary {
|
||||
min-width: 0;
|
||||
color: var(--cr-text-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cr-input-quote-card__remove {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--cr-text-tertiary);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
flex: 0 0 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
transition: color .1s;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.cr-input-quote-card__remove > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cr-input-quote-card__remove:hover {
|
||||
color: var(--cr-text-primary);
|
||||
}
|
||||
@@ -13,6 +13,25 @@ export interface ChatAttachment {
|
||||
path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息引用(WorkBuddy `message.quote.add`)。
|
||||
* 表格引用保存 markdown 快照,发送后随用户消息一起持久化。
|
||||
*/
|
||||
export interface MessageQuote {
|
||||
id: string
|
||||
kind: 'table'
|
||||
/** 稳定来源标识(渲染器 sourceId + table id),用于同一条回复内去重与高亮。 */
|
||||
sourceId: string
|
||||
/** 产生该引用的助手消息 id,可回溯到原表格。 */
|
||||
messageId?: string
|
||||
/** 表格 markdown 快照。 */
|
||||
markdown: string
|
||||
/** 引用来源标题(原版 display.title);未采集到原版取值时留空,不臆造文本。 */
|
||||
title?: string
|
||||
/** 输入区与消息气泡中的展示摘要。 */
|
||||
preview: string
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
id?: string
|
||||
option?: string
|
||||
|
||||
+3
-1
@@ -13,7 +13,7 @@ import type {
|
||||
AppInfo
|
||||
} from './types'
|
||||
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
|
||||
import type { ChatAttachment, InteractionResponse, PermissionMode } from './conversation'
|
||||
import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation'
|
||||
import type { CustomModelInput, ModelRegistryView, ModelTestResult } from './models'
|
||||
import type { SessionContext, WorkspaceRegistryView } from './workspaces'
|
||||
import type { BrowserHostApi } from './browser-host'
|
||||
@@ -39,6 +39,8 @@ export interface SendPayload {
|
||||
sessionId: string
|
||||
text: string
|
||||
attachments?: ChatAttachment[]
|
||||
/** 表格等内容的引用快照,随用户消息持久化。 */
|
||||
quotes?: MessageQuote[]
|
||||
permissionMode?: PermissionMode
|
||||
/** Retry/edit from this message; descendants are preserved in an archived branch. */
|
||||
fromMessageId?: string
|
||||
|
||||
+3
-1
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
/** 会话内的一条消息(对外视图) */
|
||||
import type { ArtifactContent, ChatAttachment, ChatInteraction, PlanContent, TaskContent } from './conversation'
|
||||
import type { ArtifactContent, ChatAttachment, ChatInteraction, MessageQuote, PlanContent, TaskContent } from './conversation'
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
@@ -18,6 +18,8 @@ export interface ChatMessage {
|
||||
parts?: MessagePart[]
|
||||
attachments?: ChatAttachment[]
|
||||
resources?: string[]
|
||||
/** 发送时引用进对话的表格等内容块快照(WorkBuddy messageQuote)。 */
|
||||
quotes?: MessageQuote[]
|
||||
skills?: Array<{ id: string; name: string; uri: string }>
|
||||
/** Native-history reconstruction; never includes connector credentials. */
|
||||
capabilityContext?: string
|
||||
|
||||
Reference in new issue
Block a user