返回 DeepSeek-Reasonix
Message.tsx
根目录 / desktop / frontend / src / components / Message.tsx
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
332 lines Plain Text