Newer vLLM parsers put CoT in reasoning; older builds and DeepSeek still use reasoning_content. Canonicalize to the UI field and echo only the original wire field so other ChatOpenAI models stay unchanged. Point qwen3.6 at VllmChatModel. Co-authored-by: Cursor <cursoragent@cursor.com>
675 lines
22 KiB
TypeScript
675 lines
22 KiB
TypeScript
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||
|
||
import { hasSearchResultPayload } from "@/core/messages/search-results";
|
||
|
||
interface GenericMessageGroup<T = string> {
|
||
type: T;
|
||
id: string | undefined;
|
||
messages: Message[];
|
||
}
|
||
|
||
interface HumanMessageGroup extends GenericMessageGroup<"human"> {}
|
||
|
||
interface AssistantProcessingGroup extends GenericMessageGroup<"assistant:processing"> {}
|
||
|
||
interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
|
||
|
||
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
|
||
|
||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||
|
||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||
|
||
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
|
||
|
||
interface AssistantSetupReportStructureApprovalGroup extends GenericMessageGroup<"assistant:setup-report-structure-approval"> {}
|
||
|
||
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
|
||
|
||
interface AssistantTaskReportImportGroup extends GenericMessageGroup<"assistant:task-report-import"> {}
|
||
|
||
interface AssistantBusinessCardGroup extends GenericMessageGroup<"assistant:business-card"> {}
|
||
|
||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||
|
||
type MessageGroup =
|
||
| HumanMessageGroup
|
||
| AssistantProcessingGroup
|
||
| AssistantMessageGroup
|
||
| AssistantPresentFilesGroup
|
||
| AssistantClarificationGroup
|
||
| AssistantSetupAgentApprovalGroup
|
||
| AssistantSetupWritingApprovalGroup
|
||
| AssistantSetupReportStructureApprovalGroup
|
||
| AssistantResearchApprovalGroup
|
||
| AssistantTaskReportImportGroup
|
||
| AssistantBusinessCardGroup
|
||
| AssistantSubagentGroup;
|
||
|
||
export function groupMessages<T>(
|
||
messages: Message[],
|
||
mapper: (group: MessageGroup) => T,
|
||
): T[] {
|
||
if (messages.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const groups: MessageGroup[] = [];
|
||
|
||
// Returns the last group if it can still accept tool messages
|
||
// (i.e. it's an in-flight processing group, not a terminal human/assistant group).
|
||
function lastOpenGroup() {
|
||
const last = groups[groups.length - 1];
|
||
if (
|
||
last &&
|
||
last.type !== "human" &&
|
||
last.type !== "assistant" &&
|
||
last.type !== "assistant:clarification" &&
|
||
last.type !== "assistant:setup-agent-approval" &&
|
||
last.type !== "assistant:setup-writing-approval" &&
|
||
last.type !== "assistant:setup-report-structure-approval" &&
|
||
last.type !== "assistant:research-approval" &&
|
||
last.type !== "assistant:business-card"
|
||
) {
|
||
return last;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
for (const message of messages) {
|
||
if (isHiddenFromUIMessage(message)) {
|
||
continue;
|
||
}
|
||
|
||
if (message.name === "todo_reminder") {
|
||
continue;
|
||
}
|
||
|
||
if (message.type === "human") {
|
||
groups.push({ id: message.id, type: "human", messages: [message] });
|
||
continue;
|
||
}
|
||
|
||
if (message.type === "tool") {
|
||
if (isClarificationToolMessage(message)) {
|
||
// Add to the preceding processing group to preserve tool-call association,
|
||
// then also open a standalone clarification group for prominent display.
|
||
lastOpenGroup()?.messages.push(message);
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:clarification",
|
||
messages: [message],
|
||
});
|
||
} else if (isSetupAgentApprovalToolMessage(message)) {
|
||
lastOpenGroup()?.messages.push(message);
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:setup-agent-approval",
|
||
messages: [message],
|
||
});
|
||
} else if (isSetupWritingApprovalToolMessage(message)) {
|
||
lastOpenGroup()?.messages.push(message);
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:setup-writing-approval",
|
||
messages: [message],
|
||
});
|
||
} else if (isSetupReportStructureApprovalToolMessage(message)) {
|
||
lastOpenGroup()?.messages.push(message);
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:setup-report-structure-approval",
|
||
messages: [message],
|
||
});
|
||
} else {
|
||
const open = lastOpenGroup();
|
||
if (open) {
|
||
open.messages.push(message);
|
||
} else {
|
||
// After 用户协助 the last group is human/clarification, so later
|
||
// write_file results have nowhere to attach. Opening a processing
|
||
// group keeps them renderable and avoids console.error(full message)
|
||
// on every MessageList render (that froze the SSE thread).
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:processing",
|
||
messages: [message],
|
||
});
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (message.type === "ai") {
|
||
// Multi-round RAG keeps rounds/retrievals on additional_kwargs while the
|
||
// visible answer is still empty. The default rule below only opens an
|
||
// assistant bubble when hasContent() is true, which would swallow the
|
||
// live thinking cards until the final answer arrives (or forever, if the
|
||
// turn is think/search-only). Force a normal assistant row so
|
||
// MessageListItem can render MultiRoundRagAssistantBody immediately.
|
||
if (message.additional_kwargs?.multi_round_rag === true) {
|
||
groups.push({ id: message.id, type: "assistant", messages: [message] });
|
||
continue;
|
||
}
|
||
// Deep-research interaction card: virtual ai messages injected by the
|
||
// workbench (additional_kwargs.setup_research_approval) render through
|
||
// the host-provided slot. Writing phases use ordinary virtual tool calls
|
||
// and completed summaries remain normal assistant messages.
|
||
if (hasResearchApproval(message)) {
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:research-approval",
|
||
messages: [message],
|
||
});
|
||
continue;
|
||
}
|
||
// agentfx「入库」协助卡:虚拟 ai 消息(additional_kwargs.task_report_import_approval),
|
||
// 由任务工作区宿主(AgentChatPage taskBind)注入,经 taskReportImportSlot 渲染。
|
||
if (hasTaskReportImportApproval(message)) {
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:task-report-import",
|
||
messages: [message],
|
||
});
|
||
continue;
|
||
}
|
||
// 通用业务卡(报告协作方案组/命令确认/报告入口等):虚拟 ai 消息
|
||
// (additional_kwargs.report_collaboration_card),由宿主注入并经
|
||
// businessCardSlot 渲染;不传插槽的页面零渲染、零行为变化。
|
||
if (hasBusinessCard(message)) {
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:business-card",
|
||
messages: [message],
|
||
});
|
||
continue;
|
||
}
|
||
if (hasPresentFiles(message)) {
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:present-files",
|
||
messages: [message],
|
||
});
|
||
} else if (hasOnlyDeepResearchProgress(message)) {
|
||
// 写作模式把进度条插在同一次 `deep_research_report` 之后,必须并进
|
||
// 当前 processing 组,总结才能排在整条步骤条下面。新一轮写作前面
|
||
// 会有 human/assistant 隔开,不会误并到上一轮的条上。
|
||
const lastGroup = groups[groups.length - 1];
|
||
if (lastGroup?.type === "assistant:processing" && groupHasDeepResearchReport(lastGroup)) {
|
||
lastGroup.messages.push(message);
|
||
} else {
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:processing",
|
||
messages: [message],
|
||
});
|
||
}
|
||
} else if (hasSubagent(message)) {
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:subagent",
|
||
messages: [message],
|
||
});
|
||
} else if (hasReasoning(message) || hasToolCalls(message)) {
|
||
const lastGroup = groups[groups.length - 1];
|
||
// Accumulate consecutive intermediate AI messages into one processing group.
|
||
if (lastGroup?.type !== "assistant:processing") {
|
||
groups.push({
|
||
id: message.id,
|
||
type: "assistant:processing",
|
||
messages: [message],
|
||
});
|
||
} else {
|
||
lastGroup.messages.push(message);
|
||
}
|
||
}
|
||
|
||
// Not an else-if: a message with reasoning + content (but no tool calls) goes
|
||
// into the processing group above AND gets its own assistant bubble here.
|
||
if (hasContent(message) && !hasToolCalls(message)) {
|
||
groups.push({ id: message.id, type: "assistant", messages: [message] });
|
||
}
|
||
}
|
||
}
|
||
|
||
return groups
|
||
.map(mapper)
|
||
.filter((result) => result !== undefined && result !== null) as T[];
|
||
}
|
||
|
||
export function extractTextFromMessage(message: Message) {
|
||
const payload = extractToolResultPayload(message);
|
||
if (typeof payload === "string") {
|
||
return payload;
|
||
}
|
||
if (payload && typeof payload === "object") {
|
||
try {
|
||
return JSON.stringify(payload);
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* Tool / skill 返回值可能是 JSON 字符串,也可能是结构化对象
|
||
*(如内网检索:{ success, query, count, results: [{ title, content_preview }] })。
|
||
*/
|
||
export function extractToolResultPayload(
|
||
message: Message,
|
||
): string | Record<string, unknown> | undefined {
|
||
const { content } = message;
|
||
|
||
if (typeof content === "string") {
|
||
const trimmed = content.trim();
|
||
return trimmed || undefined;
|
||
}
|
||
|
||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||
return content as Record<string, unknown>;
|
||
}
|
||
|
||
if (Array.isArray(content)) {
|
||
const textParts: string[] = [];
|
||
for (const part of content) {
|
||
if (!part || typeof part !== "object") {
|
||
continue;
|
||
}
|
||
const block = part as Record<string, unknown>;
|
||
if (block.type === "text" && typeof block.text === "string") {
|
||
textParts.push(block.text);
|
||
continue;
|
||
}
|
||
if (block.type === "json" && block.json && typeof block.json === "object") {
|
||
return block.json as Record<string, unknown>;
|
||
}
|
||
if (hasSearchResultPayload(block)) {
|
||
return block;
|
||
}
|
||
}
|
||
const joined = textParts.join("\n").trim();
|
||
return joined || undefined;
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||
const THINK_OPEN = "<think>";
|
||
|
||
function splitInlineReasoning(content: string) {
|
||
const reasoningParts: string[] = [];
|
||
// 1) 先抽出所有**已闭合**的 <think>...</think>。
|
||
let working = content.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||
const normalized = reasoning.trim();
|
||
if (normalized) {
|
||
reasoningParts.push(normalized);
|
||
}
|
||
return "";
|
||
});
|
||
|
||
// 2) 流式中常见**未闭合**的 <think>(还没等到 </think> 就先到达):把开标签之后的内容
|
||
// 当作「实时思考」抽到 reasoning、从答案正文里移除。否则模型慢/先思考时,用户既会看到
|
||
// 字面 "<think>",又会因答案区长时间空白而以为卡死。这样思考内容会实时流进思考区。
|
||
const openIdx = working.indexOf(THINK_OPEN);
|
||
if (openIdx !== -1 && !working.slice(openIdx).includes("</think>")) {
|
||
const partial = working.slice(openIdx + THINK_OPEN.length).trim();
|
||
if (partial) {
|
||
reasoningParts.push(partial);
|
||
}
|
||
working = working.slice(0, openIdx);
|
||
}
|
||
|
||
return {
|
||
content: working.trim(),
|
||
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
|
||
};
|
||
}
|
||
|
||
function splitInlineReasoningFromAIMessage(message: Message) {
|
||
if (message.type !== "ai" || typeof message.content !== "string") {
|
||
return null;
|
||
}
|
||
return splitInlineReasoning(message.content);
|
||
}
|
||
|
||
export function extractContentFromMessage(message: Message) {
|
||
if (typeof message.content === "string") {
|
||
return (
|
||
splitInlineReasoningFromAIMessage(message)?.content ??
|
||
message.content.trim()
|
||
);
|
||
}
|
||
if (Array.isArray(message.content)) {
|
||
return message.content
|
||
.map((content) => {
|
||
switch (content.type) {
|
||
case "text":
|
||
return content.text;
|
||
case "image_url":
|
||
const imageURL = extractURLFromImageURLContent(content.image_url);
|
||
return ``;
|
||
default:
|
||
return "";
|
||
}
|
||
})
|
||
.join("\n")
|
||
.trim();
|
||
}
|
||
return "";
|
||
}
|
||
|
||
export function extractReasoningContentFromMessage(message: Message) {
|
||
if (message.type !== "ai") {
|
||
return null;
|
||
}
|
||
if (message.additional_kwargs) {
|
||
const reasoningContent = message.additional_kwargs.reasoning_content;
|
||
if (typeof reasoningContent === "string") {
|
||
return reasoningContent;
|
||
}
|
||
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
|
||
const reasoning = message.additional_kwargs.reasoning;
|
||
if (typeof reasoning === "string") {
|
||
return reasoning;
|
||
}
|
||
}
|
||
if (Array.isArray(message.content)) {
|
||
const part = message.content[0];
|
||
if (part && "thinking" in part) {
|
||
return part.thinking as string;
|
||
}
|
||
}
|
||
if (typeof message.content === "string") {
|
||
return splitInlineReasoning(message.content).reasoning;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function removeReasoningContentFromMessage(message: Message) {
|
||
if (message.type !== "ai" || !message.additional_kwargs) {
|
||
return;
|
||
}
|
||
delete message.additional_kwargs.reasoning_content;
|
||
}
|
||
|
||
export function extractURLFromImageURLContent(
|
||
content:
|
||
| string
|
||
| {
|
||
url: string;
|
||
},
|
||
) {
|
||
if (typeof content === "string") {
|
||
return content;
|
||
}
|
||
return content.url;
|
||
}
|
||
|
||
export function hasContent(message: Message) {
|
||
if (typeof message.content === "string") {
|
||
return (
|
||
(
|
||
splitInlineReasoningFromAIMessage(message)?.content ??
|
||
message.content.trim()
|
||
).length > 0
|
||
);
|
||
}
|
||
if (Array.isArray(message.content)) {
|
||
return message.content.length > 0;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function hasReasoning(message: Message) {
|
||
if (message.type !== "ai") {
|
||
return false;
|
||
}
|
||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||
return true;
|
||
}
|
||
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||
return true;
|
||
}
|
||
if (Array.isArray(message.content)) {
|
||
const part = message.content[0];
|
||
// Compatible with the Anthropic gateway
|
||
return (part as unknown as { type: "thinking" })?.type === "thinking";
|
||
}
|
||
if (typeof message.content === "string") {
|
||
return splitInlineReasoning(message.content).reasoning !== null;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function hasToolCalls(message: Message) {
|
||
return (
|
||
message.type === "ai" && message.tool_calls && message.tool_calls.length > 0
|
||
);
|
||
}
|
||
|
||
export function hasPresentFiles(message: Message) {
|
||
return (
|
||
message.type === "ai" &&
|
||
message.tool_calls?.some((toolCall) => toolCall.name === "present_files")
|
||
);
|
||
}
|
||
|
||
export function hasOnlyDeepResearchProgress(message: Message) {
|
||
return (
|
||
message.type === "ai" &&
|
||
Boolean(message.tool_calls?.length) &&
|
||
message.tool_calls!.every((toolCall) => toolCall.name === "deep_research_progress")
|
||
);
|
||
}
|
||
|
||
function groupHasDeepResearchReport(group: MessageGroup): boolean {
|
||
return group.messages.some(
|
||
(message) =>
|
||
message.type === "ai" &&
|
||
Boolean(message.tool_calls?.some((toolCall) => toolCall.name === "deep_research_report")),
|
||
);
|
||
}
|
||
|
||
export function isClarificationToolMessage(message: Message) {
|
||
return message.type === "tool" && message.name === "ask_clarification";
|
||
}
|
||
|
||
export function isSetupAgentApprovalToolMessage(message: Message) {
|
||
return (
|
||
message.type === "tool" &&
|
||
message.name === "setup_agent" &&
|
||
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
|
||
message.additional_kwargs.setup_agent_approval !== null
|
||
);
|
||
}
|
||
|
||
export function isSetupWritingApprovalToolMessage(message: Message) {
|
||
return (
|
||
message.type === "tool" &&
|
||
message.name === "setup_writing" &&
|
||
typeof message.additional_kwargs?.setup_writing_approval === "object" &&
|
||
message.additional_kwargs.setup_writing_approval !== null
|
||
);
|
||
}
|
||
|
||
export function isSetupReportStructureApprovalToolMessage(message: Message) {
|
||
return (
|
||
message.type === "tool" &&
|
||
message.name === "setup_report_structure" &&
|
||
typeof message.additional_kwargs?.setup_report_structure_approval === "object" &&
|
||
message.additional_kwargs.setup_report_structure_approval !== null
|
||
);
|
||
}
|
||
|
||
export function hasResearchApproval(message: Message) {
|
||
return (
|
||
message.type === "ai" &&
|
||
typeof message.additional_kwargs?.setup_research_approval === "object" &&
|
||
message.additional_kwargs.setup_research_approval !== null
|
||
);
|
||
}
|
||
|
||
/** agentfx「入库」协助卡:宿主注入的虚拟 ai 消息(additional_kwargs.task_report_import_approval)。 */
|
||
export function hasTaskReportImportApproval(message: Message) {
|
||
return (
|
||
message.type === "ai" &&
|
||
typeof message.additional_kwargs?.task_report_import_approval === "object" &&
|
||
message.additional_kwargs.task_report_import_approval !== null
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 通用业务卡(报告协作等页面):宿主注入的虚拟 ai 消息
|
||
* (additional_kwargs.report_collaboration_card),经 MessageList 的
|
||
* businessCardSlot 渲染。集中在此识别,业务卡 payload 结构由各页面自定义。
|
||
*/
|
||
export function hasBusinessCard(message: Message) {
|
||
return (
|
||
message.type === "ai" &&
|
||
typeof message.additional_kwargs?.report_collaboration_card === "object" &&
|
||
message.additional_kwargs.report_collaboration_card !== null
|
||
);
|
||
}
|
||
|
||
export function extractPresentFilesFromMessage(message: Message) {
|
||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||
return [];
|
||
}
|
||
const files: string[] = [];
|
||
for (const toolCall of message.tool_calls ?? []) {
|
||
if (
|
||
toolCall.name === "present_files" &&
|
||
Array.isArray(toolCall.args.filepaths)
|
||
) {
|
||
files.push(...(toolCall.args.filepaths as string[]));
|
||
}
|
||
}
|
||
return files;
|
||
}
|
||
|
||
export function hasSubagent(message: AIMessage) {
|
||
for (const toolCall of message.tool_calls ?? []) {
|
||
if (toolCall.name === "task") {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function findToolCallResult(
|
||
toolCallId: string,
|
||
messages: Message[],
|
||
toolName?: string,
|
||
): string | Record<string, unknown> | undefined {
|
||
for (const message of messages) {
|
||
if (message.type !== "tool" || message.tool_call_id !== toolCallId) {
|
||
continue;
|
||
}
|
||
const payload = extractToolResultPayload(message);
|
||
if (payload !== undefined) {
|
||
return payload;
|
||
}
|
||
}
|
||
|
||
// updates 流里偶发缺少 tool_call_id,但 content 已是检索结构
|
||
if (toolName) {
|
||
for (const message of messages) {
|
||
if (message.type !== "tool" || message.name !== toolName) {
|
||
continue;
|
||
}
|
||
if (message.tool_call_id && message.tool_call_id !== toolCallId) {
|
||
continue;
|
||
}
|
||
const payload = extractToolResultPayload(message);
|
||
if (payload !== undefined && hasSearchResultPayload(payload)) {
|
||
return payload;
|
||
}
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
export function isHiddenFromUIMessage(message: Message) {
|
||
if (message.additional_kwargs?.hide_from_ui === true) {
|
||
return true;
|
||
}
|
||
// Conversation-summary messages are context for the model only — never shown
|
||
// to the user. New summaries are injected transiently (never persisted), but
|
||
// threads compacted by older builds still carry a persisted `name: "summary"`
|
||
// HumanMessage, so hide those too.
|
||
if (message.name === "summary") {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Represents a file stored in message additional_kwargs.files.
|
||
* Used for optimistic UI (uploading state) and structured file metadata.
|
||
*/
|
||
export interface FileInMessage {
|
||
filename: string;
|
||
size: number; // bytes
|
||
path?: string; // virtual path, may not be set during upload
|
||
status?: "uploading" | "uploaded";
|
||
}
|
||
|
||
/**
|
||
* Strip <uploaded_files> tag from message content.
|
||
* Returns the content with the tag removed.
|
||
*/
|
||
export function stripUploadedFilesTag(content: string): string {
|
||
return content
|
||
.replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
|
||
.trim();
|
||
}
|
||
|
||
/** Strip injected <artifact ...>...</artifact> blocks from human message display. */
|
||
export function stripArtifactTag(content: string): string {
|
||
return content
|
||
.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "")
|
||
.trim();
|
||
}
|
||
|
||
export function parseUploadedFiles(content: string): FileInMessage[] {
|
||
// Match <uploaded_files>...</uploaded_files> tag
|
||
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
|
||
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec
|
||
const match = content.match(uploadedFilesRegex);
|
||
|
||
if (!match) {
|
||
return [];
|
||
}
|
||
|
||
const uploadedFilesContent = match[1];
|
||
|
||
// Check if it's "No files have been uploaded yet."
|
||
if (uploadedFilesContent?.includes("No files have been uploaded yet.")) {
|
||
return [];
|
||
}
|
||
|
||
// NOTE: do NOT bail on "(empty)" here. The backend writes "(empty)" only for
|
||
// the "uploaded in this message" section; files uploaded in previous messages
|
||
// are still listed below it. Bailing dropped those valid entries. The file
|
||
// regex below naturally skips the "(empty)" line, so parsing stays correct.
|
||
|
||
// Parse file list
|
||
// Format: - filename (size)\n Path: /path/to/file
|
||
const fileRegex = /- ([^\n(]+)\s*\(([^)]+)\)\s*\n\s*Path:\s*([^\n]+)/g;
|
||
const files: FileInMessage[] = [];
|
||
let fileMatch;
|
||
|
||
while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) {
|
||
files.push({
|
||
filename: fileMatch[1].trim(),
|
||
size: parseInt(fileMatch[2].trim(), 10) ?? 0,
|
||
path: fileMatch[3].trim(),
|
||
});
|
||
}
|
||
|
||
return files;
|
||
}
|