| 1 | import { createContext, lazy, memo, Suspense, useContext, useMemo, useState } from "react"; |
| 2 | import { BrainCircuit, FileText, MessageSquare } from "lucide-react"; |
| 3 | import { Markdown } from "./Markdown"; |
| 4 | import { CopyButton } from "./CopyButton"; |
| 5 | import { parseAttachmentRefsForDisplay } from "../lib/attachmentDisplay"; |
| 6 | import { useT } from "../lib/i18n"; |
| 7 | import { Tooltip } from "./Tooltip"; |
| 8 | import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay"; |
| 9 | import { invocationSegmentsFromMessage, type InvocationMetadataMap } from "../lib/invocationDisplay"; |
| 10 | import type { Item } from "../lib/useController"; |
| 11 | import { InvocationBadge } from "./InvocationBadge"; |
| 12 | import { CodeViewer } from "./CodeViewer"; |
| 13 | import { formatSelectionLabels, languageFor, parseSelectedTextContext, stripSelectionLabels } from "../lib/selectedTextContext"; |
| 14 | import type { PresentedFileView } from "../lib/chatViewSource"; |
| 15 | import type { TurnFileView } from "../lib/turnFiles"; |
| 16 | import { ChatFileTurnProvider } from "./ChatFileLinkContext"; |
| 17 | |
| 18 | const MemoryCitations = lazy(() => import("./MemoryCitations").then((module) => ({ default: module.MemoryCitations }))); |
| 19 | const SearchSourcesPanel = lazy(() => import("./SearchSourcesPanel").then((module) => ({ default: module.SearchSourcesPanel }))); type AssistantItem = Extract<Item, { kind: "assistant" }>; |
| 20 | const MessageAttachments = lazy(() => import("./MessageAttachments").then(module => ({ default: module.MessageAttachments }))); |
| 21 | export const InvocationMetadataContext = createContext<InvocationMetadataMap>({}); |
| 22 | type ImSourceMessage = { |
| 23 | provider: string; |
| 24 | label: string; |
| 25 | sender: string; |
| 26 | chat: string; |
| 27 | text: string; |
| 28 | }; |
| 29 | |
| 30 | const IM_SOURCE_START = "[[reasonix-im]]"; |
| 31 | const IM_SOURCE_END = "[[/reasonix-im]]"; |
| 32 | |
| 33 | function parseImSourceMessage(text: string): ImSourceMessage | null { |
| 34 | // Display-only metadata: keep IM sender/chat details out of model prompts. |
| 35 | if (!text.startsWith(IM_SOURCE_START)) return null; |
| 36 | const end = text.indexOf(IM_SOURCE_END); |
| 37 | if (end < 0) return null; |
| 38 | const metaBlock = text.slice(IM_SOURCE_START.length, end).trim(); |
| 39 | const body = text.slice(end + IM_SOURCE_END.length).replace(/^\r?\n/, ""); |
| 40 | const meta: Record<string, string> = {}; |
| 41 | for (const line of metaBlock.split(/\r?\n/)) { |
| 42 | const index = line.indexOf("="); |
| 43 | if (index <= 0) continue; |
| 44 | const key = line.slice(0, index).trim().toLowerCase(); |
| 45 | const value = line.slice(index + 1).trim(); |
| 46 | if (key) meta[key] = value; |
| 47 | } |
| 48 | return { |
| 49 | provider: meta.provider || "", |
| 50 | label: meta.label || "", |
| 51 | sender: meta.sender || meta.senderid || "", |
| 52 | chat: meta.chat || meta.chat_type || "", |
| 53 | text: body, |
| 54 | }; |
| 55 | } |
| 56 | |
| 57 | function imSourceLabel(source: ImSourceMessage, t: ReturnType<typeof useT>): string { |
| 58 | if (source.label.trim()) return source.label.trim(); |
| 59 | const provider = source.provider.trim().toLowerCase(); |
| 60 | if (provider === "lark") return "Lark"; |
| 61 | if (provider === "weixin" || provider === "wechat") return t("settings.botWeixin"); |
| 62 | return t("settings.botFeishu"); |
| 63 | } |
| 64 | |
| 65 | type PastedBlockInfo = { |
| 66 | label: string; |
| 67 | content: string; |
| 68 | }; |
| 69 | |
| 70 | const PASTE_LABEL_RE = /\[(?:已粘贴文本|已貼上文字|Pasted text) #\d+ · \d+ (?:行|lines)\]/g; |
| 71 | |
| 72 | export function parsePastedBlocks(text: string, submitText?: string): PastedBlockInfo[] { |
| 73 | const labels = text.match(PASTE_LABEL_RE); |
| 74 | if (!labels || labels.length === 0 || !submitText) return []; |
| 75 | const unique = [...new Set(labels)]; |
| 76 | const blocks: PastedBlockInfo[] = []; |
| 77 | for (const label of unique) { |
| 78 | const beginMarker = `--- Begin ${label} ---`; |
| 79 | const endMarker = `--- End ${label} ---`; |
| 80 | const beginIdx = submitText.indexOf(beginMarker); |
| 81 | const endIdx = submitText.indexOf(endMarker); |
| 82 | if (beginIdx < 0 || endIdx <= beginIdx) continue; |
| 83 | const contentStart = beginIdx + beginMarker.length; |
| 84 | const content = submitText.slice(contentStart, endIdx).replace(/^\r?\n/, ""); |
| 85 | blocks.push({ label, content }); |
| 86 | } |
| 87 | return blocks; |
| 88 | } |
| 89 | |
| 90 | export type SelectedTextBlockInfo = { |
| 91 | label: string; |
| 92 | content: string; |
| 93 | path?: string; |
| 94 | start: number; |
| 95 | end: number; |
| 96 | kind: "chat" | "code" | "terminal"; |
| 97 | }; |
| 98 | |
| 99 | export function parseSelectedTextBlocks(text: string, submitText?: string): SelectedTextBlockInfo[] { |
| 100 | const entries = parseSelectedTextContext(submitText); |
| 101 | if (entries.length === 0) return []; |
| 102 | const suffix = formatSelectionLabels(entries); |
| 103 | if (!suffix || !text.endsWith(suffix)) return []; |
| 104 | |
| 105 | // Composer owns the exact trailing label suffix. Deriving it from the JSON |
| 106 | // entries avoids consuming label-shaped or unterminated authored prose. |
| 107 | let start = text.length - suffix.length; |
| 108 | return entries.map((entry) => { |
| 109 | const label = formatSelectionLabels([entry]); |
| 110 | const kind = entry.path ? "code" : entry.source === "terminal" ? "terminal" : "chat"; |
| 111 | const block = { |
| 112 | label, |
| 113 | content: entry.text, |
| 114 | path: entry.path, |
| 115 | start, |
| 116 | end: start + label.length, |
| 117 | kind, |
| 118 | } satisfies SelectedTextBlockInfo; |
| 119 | start = block.end + 1; |
| 120 | return block; |
| 121 | }); |
| 122 | } |
| 123 | |
| 124 | function messageDate(value?: number): Date { |
| 125 | return new Date(typeof value === "number" && Number.isFinite(value) && value > 0 ? value : Date.now()); |
| 126 | } |
| 127 | |
| 128 | function formatMessageTime(date: Date): string { |
| 129 | const hours = String(date.getHours()).padStart(2, "0"); |
| 130 | const minutes = String(date.getMinutes()).padStart(2, "0"); |
| 131 | return `${hours}:${minutes}`; |
| 132 | } |
| 133 | |
| 134 | export function UserMessage({ |
| 135 | text, |
| 136 | submitText, |
| 137 | failed, |
| 138 | turn, |
| 139 | anchorId, |
| 140 | id, |
| 141 | createdAt, |
| 142 | }: { |
| 143 | text: string; |
| 144 | submitText?: string; |
| 145 | failed?: boolean; |
| 146 | turn?: number; |
| 147 | anchorId?: string; |
| 148 | id?: string; |
| 149 | createdAt?: number; |
| 150 | }) { |
| 151 | const t = useT(); |
| 152 | const invocationMetadata = useContext(InvocationMetadataContext); |
| 153 | const imSource = parseImSourceMessage(text); |
| 154 | const actionText = stripMemoryCompilerExecution(imSource?.text ?? text); |
| 155 | const hasMemoryCompiler = Boolean(submitText?.includes("<memory-compiler-execution>")); |
| 156 | const selectedTextEntries = useMemo(() => parseSelectedTextContext(submitText), [submitText]); |
| 157 | const editableActionText = stripSelectionLabels(actionText, selectedTextEntries); |
| 158 | const { text: editableDisplayText, attachments: parsedAttachments } = parseAttachmentRefsForDisplay(editableActionText); |
| 159 | const selectionLabels = formatSelectionLabels(selectedTextEntries); |
| 160 | const displayText = [editableDisplayText, selectionLabels].filter(Boolean).join(editableDisplayText && selectionLabels ? " " : ""); |
| 161 | const invocationSegments = imSource ? [] : invocationSegmentsFromMessage(displayText, submitText, invocationMetadata); |
| 162 | const hasInvocationSegments = invocationSegments.some((segment) => segment.type === "invocation"); |
| 163 | const sourceLabel = imSource ? imSourceLabel(imSource, t) : ""; |
| 164 | const sentAt = createdAt === undefined ? null : messageDate(createdAt); |
| 165 | const pasteBlocks = useMemo(() => parsePastedBlocks(displayText, submitText), [displayText, submitText]); |
| 166 | const selectedTextBlocks = useMemo(() => parseSelectedTextBlocks(displayText, submitText), [displayText, submitText]); |
| 167 | const [expandedBlockKeys, setExpandedBlockKeys] = useState<Record<string, boolean>>({}); |
| 168 | |
| 169 | type DisplaySegment = |
| 170 | | { type: "text"; content: string } |
| 171 | | { type: "block"; key: string; block: PastedBlockInfo; kind: "paste" } |
| 172 | | { type: "block"; key: string; block: SelectedTextBlockInfo; kind: "chat" | "code" | "terminal" }; |
| 173 | |
| 174 | const displaySegments = useMemo((): DisplaySegment[] => { |
| 175 | if (pasteBlocks.length === 0 && selectedTextBlocks.length === 0) return [{ type: "text", content: displayText }]; |
| 176 | const segments: DisplaySegment[] = []; |
| 177 | const ordered: Array< |
| 178 | | { block: PastedBlockInfo; start: number; end: number; kind: "paste" } |
| 179 | | { block: SelectedTextBlockInfo; start: number; end: number; kind: "chat" | "code" | "terminal" } |
| 180 | > = [ |
| 181 | ...pasteBlocks.map((block) => { |
| 182 | const start = displayText.indexOf(block.label); |
| 183 | return { block, start, end: start + block.label.length, kind: "paste" as const }; |
| 184 | }), |
| 185 | ...selectedTextBlocks.map((block) => ({ block, start: block.start, end: block.end, kind: block.kind })), |
| 186 | ].filter((block) => block.start >= 0).sort((a, b) => a.start - b.start); |
| 187 | let cursor = 0; |
| 188 | for (const item of ordered) { |
| 189 | if (item.start < cursor) continue; |
| 190 | // Text before the label: strip the trailing newline that separated the |
| 191 | // label from the preceding line so the card sits tight against the text. |
| 192 | if (item.start > cursor) { |
| 193 | let before = displayText.slice(cursor, item.start); |
| 194 | before = before.replace(/\n$/, ""); |
| 195 | if (before) segments.push({ type: "text", content: before }); |
| 196 | } |
| 197 | const key = `${item.kind}:${item.start}:${item.block.label}`; |
| 198 | if (item.kind === "paste") { |
| 199 | segments.push({ type: "block", key, block: item.block, kind: item.kind }); |
| 200 | } else { |
| 201 | segments.push({ type: "block", key, block: item.block, kind: item.kind }); |
| 202 | } |
| 203 | cursor = item.end; |
| 204 | } |
| 205 | // Strip the leading newline that followed the label. |
| 206 | const remaining = displayText.slice(cursor).replace(/^\n/, ""); |
| 207 | if (remaining.trim()) segments.push({ type: "text", content: remaining }); |
| 208 | return segments.length > 0 ? segments : [{ type: "text", content: displayText }]; |
| 209 | }, [displayText, pasteBlocks, selectedTextBlocks]); |
| 210 | |
| 211 | const toggleBlockExpand = (key: string) => { |
| 212 | setExpandedBlockKeys((prev) => ({ |
| 213 | ...prev, |
| 214 | [key]: !prev[key], |
| 215 | })); |
| 216 | }; |
| 217 | return ( |
| 218 | <div |
| 219 | className={`msg msg--user${imSource ? " msg--im-source" : ""}${failed ? " msg--user-failed" : ""}`} |
| 220 | id={anchorId} |
| 221 | data-question-anchor={anchorId} |
| 222 | data-turn={turn} |
| 223 | data-im-source={imSource?.provider || undefined} |
| 224 | data-history-restore={id && id.startsWith("h") ? "" : undefined} |
| 225 | data-entrance={id || undefined} |
| 226 | > |
| 227 | <div className="msg__body" data-transcript-selectable="message"> |
| 228 | {imSource ? ( |
| 229 | <div className="im-source-card"> |
| 230 | <div className="im-source-card__head" data-transcript-selection-ignore> |
| 231 | <MessageSquare size={14} /> |
| 232 | <span>{t("msg.fromIm", { source: sourceLabel })}</span> |
| 233 | </div> |
| 234 | {displayText && <div className="im-source-card__text">{displayText}</div>} |
| 235 | {(imSource.sender || imSource.chat) && ( |
| 236 | <div className="im-source-card__meta" data-transcript-selection-ignore> |
| 237 | {imSource.sender && <span>{t("msg.imSender", { id: imSource.sender })}</span>} |
| 238 | {imSource.chat && <span>{imSource.chat}</span>} |
| 239 | </div> |
| 240 | )} |
| 241 | </div> |
| 242 | ) : ( |
| 243 | <> |
| 244 | {hasInvocationSegments && pasteBlocks.length === 0 && selectedTextBlocks.length === 0 ? ( |
| 245 | <div className="msg__text msg__rich-text"> |
| 246 | {invocationSegments.map((segment, index) => segment.type === "text" |
| 247 | ? <span key={`text:${segment.start}:${index}`}>{segment.content}</span> |
| 248 | : ( |
| 249 | <InvocationBadge |
| 250 | key={`invocation:${segment.invocation.name}:${segment.offset}:${index}`} |
| 251 | invocation={segment.invocation} |
| 252 | kind={segment.invocation.kind} |
| 253 | variant="message" |
| 254 | /> |
| 255 | ))} |
| 256 | </div> |
| 257 | ) : displaySegments.map((seg, i) => { |
| 258 | if (seg.type === "text") { |
| 259 | return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null; |
| 260 | } |
| 261 | const expanded = Boolean(expandedBlockKeys[seg.key]); |
| 262 | return ( |
| 263 | <div className="msg-pasted" key={seg.key}> |
| 264 | <div className="msg-pasted-block"> |
| 265 | <div className="msg-pasted-head" data-transcript-selection-ignore> |
| 266 | {seg.kind === "code" ? <FileText size={15} /> : <MessageSquare size={15} />} |
| 267 | <span className="msg-pasted-label">{seg.block.label}</span> |
| 268 | <div className="msg-pasted-actions"> |
| 269 | <Tooltip label={t(expanded ? "msg.pastedCollapseTooltip" : "msg.pastedExpandTooltip")}> |
| 270 | <button type="button" onClick={() => toggleBlockExpand(seg.key)}> |
| 271 | {expanded ? t("common.collapse") : t("composer.pastedExpand")} |
| 272 | </button> |
| 273 | </Tooltip> |
| 274 | </div> |
| 275 | </div> |
| 276 | {expanded && ( |
| 277 | <div className="msg-pasted-expanded"> |
| 278 | {seg.kind === "chat" |
| 279 | ? <Markdown text={seg.block.content} /> |
| 280 | : seg.kind === "code" || seg.kind === "terminal" |
| 281 | ? <CodeViewer value={seg.block.content} language={seg.kind === "terminal" ? "console" : languageFor(seg.block.path ?? "")} maxHeight={360} /> |
| 282 | : seg.block.content} |
| 283 | </div> |
| 284 | )} |
| 285 | </div> |
| 286 | </div> |
| 287 | ); |
| 288 | })} |
| 289 | </> |
| 290 | )} |
| 291 | {failed && <div className="msg__send-failed" data-transcript-selection-ignore>{t("msg.sendFailed")}</div>} |
| 292 | {parsedAttachments.length > 0 && <Suspense fallback={null}><MessageAttachments attachments={parsedAttachments} /></Suspense>} |
| 293 | </div> |
| 294 | <div className="msg-meta" role="group" aria-label={t("msg.copy")}> |
| 295 | {sentAt && ( |
| 296 | <time className="msg-meta__time" dateTime={sentAt.toISOString()} title={sentAt.toLocaleString()}> |
| 297 | {formatMessageTime(sentAt)} |
| 298 | </time> |
| 299 | )} |
| 300 | {hasMemoryCompiler && ( |
| 301 | <span className="msg-meta__indicator" title={t("msg.memoryCompilerApplied")} aria-hidden="true"> |
| 302 | <BrainCircuit size={14} /> |
| 303 | </span> |
| 304 | )} |
| 305 | <CopyButton text={actionText} label={t("msg.copy")} showInlineLabel={false} className="msg-meta__btn msg-meta__copy" /> |
| 306 | </div> |
| 307 | </div> |
| 308 | ); |
| 309 | } |
| 310 | |
| 311 | export const AssistantMessage = memo(function AssistantMessage({ item, presentedFiles = [], modifiedFiles = [], turnKey, factsVersion = 0, tabId, hostId }: { |
| 312 | item: AssistantItem; presentedFiles?: readonly PresentedFileView[]; modifiedFiles?: readonly TurnFileView[]; |
| 313 | turnKey?: string; factsVersion?: number; tabId?: string; hostId?: string; |
| 314 | }) { |
| 315 | const hasText = item.streaming || item.text.trim() !== ""; |
| 316 | const hasFootnotes = Boolean(item.searchSources?.length); |
| 317 | const body = <Markdown text={item.text} streaming={item.streaming} cacheKey={item.id} wasStreamed={item.wasStreamed} />; |
| 318 | return ( |
| 319 | <div className="msg msg--assistant" data-history-restore={item.id.startsWith("h") ? "" : undefined} data-entrance={item.id}> |
| 320 | {(hasText || hasFootnotes) && ( |
| 321 | <div className="msg__body" data-transcript-selectable="message"> |
| 322 | {hasText && (turnKey |
| 323 | ? <ChatFileTurnProvider turnKey={turnKey} factsVersion={factsVersion} presentedFiles={presentedFiles} modifiedFiles={modifiedFiles} tabId={tabId} hostId={hostId}>{body}</ChatFileTurnProvider> |
| 324 | : body)} |
| 325 | <Suspense fallback={null}><SearchSourcesPanel sources={item.searchSources} /></Suspense> |
| 326 | </div> |
| 327 | )} |
| 328 | {Boolean(item.memoryCitations?.length) && <Suspense fallback={null}><MemoryCitations citations={item.memoryCitations} /></Suspense>} |
| 329 | </div> |
| 330 | ); |
| 331 | }); |
| 332 |