|
|
@@ -13,6 +13,7 @@ import {
|
|
|
} from './api/models';
|
|
|
import { DEMO_MESSAGES } from './api/demo-data';
|
|
|
import { extractS3Links, stripLinks } from './s3-link';
|
|
|
+import { DeclaredFile, parseDeclaredFiles, stripDeclaredFiles } from './declared-files';
|
|
|
import { environment } from '../../environments/environment';
|
|
|
import { LiveService } from './live.service';
|
|
|
import { SessionService } from './session.service';
|
|
|
@@ -937,9 +938,14 @@ export class ChatService {
|
|
|
*/
|
|
|
for (const m of all) {
|
|
|
if (m.sender !== 'agent') continue;
|
|
|
- /* 与实时路径同口径:必须通过文件判定才收(排除 `https://s3.fmode.cn/...` 这类占位) */
|
|
|
- for (const u of extractS3Links(m.text)) {
|
|
|
- if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
|
|
|
+ /* 与实时路径同口径:优先声明段,否则正则兜底 + 文件判定 */
|
|
|
+ const declared = parseDeclaredFiles(m.text);
|
|
|
+ if (declared !== null) {
|
|
|
+ for (const d of declared) this.sessions.addDeliverableUrl(sid, d.url);
|
|
|
+ } else {
|
|
|
+ for (const u of extractS3Links(m.text)) {
|
|
|
+ if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2067,16 +2073,34 @@ export class ChatService {
|
|
|
* 属于容器输出格式问题,已通过「链接独占一行、一次只给一个主文件」规范约束。
|
|
|
*/
|
|
|
/*
|
|
|
- * 正文里的云盘链接 → 文件卡片,并把链接从正文里**摘掉**(用户裁决:
|
|
|
- * 「卡片统一在底部,正文里的 URL 隐去」)。
|
|
|
+ * 文件来源(2026-11-10 起按优先级):
|
|
|
+ *
|
|
|
+ * ① Matrix 原生附件(`m.file` / `m.media`) —— 最可靠
|
|
|
+ * ② **容器声明段** `<!--fmode:files [...] -->` —— 结构化,不需要猜
|
|
|
+ * ③ 正文里的云盘链接(正则提取) —— 老格式的兜底
|
|
|
*
|
|
|
- * 🔴 顺序不能错:**先摘链接 → 再判断剩余内容是否为空**。
|
|
|
- * 上一版直接把 `text` 置成剥离后的结果,没判断空,
|
|
|
- * 正文只剩链接时整条消息变成空白(用户实测「回复的文字没有了」)。
|
|
|
+ * 🔴 ② 是新增的:原先只有 ①③,而 ③ 靠正则猜 URL,
|
|
|
+ * 已经踩过两次坑(容器举例的 `https://s3.fmode.cn/...` 被当成真文件、
|
|
|
+ * 用户提问里的链接被收成成果)。声明段是容器**显式说**有哪些文件。
|
|
|
+ *
|
|
|
+ * 声明段存在时**不再**走 ③ —— 容器已经明确表态,不需要再猜。
|
|
|
+ * 没有声明段时才回退到 ③(老容器仍可用)。
|
|
|
*/
|
|
|
- const cloudLinks = file ? [] : extractS3Links(m.body);
|
|
|
- const linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
|
|
|
- const bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
|
|
|
+ const declared = file ? null : parseDeclaredFiles(m.body);
|
|
|
+ let linkCard: FileCard | undefined;
|
|
|
+ let bodyText = m.body;
|
|
|
+
|
|
|
+ if (declared !== null) {
|
|
|
+ // 有声明段:正文去掉那段 JSON(用户不该看到),文件从声明里取
|
|
|
+ bodyText = stripDeclaredFiles(m.body);
|
|
|
+ const first = declared.find((d) => d.url);
|
|
|
+ if (first) linkCard = this.cardFromDeclared(first);
|
|
|
+ } else if (!file) {
|
|
|
+ // 没有声明段:回退到「从正文正则提取」(老格式)
|
|
|
+ const cloudLinks = extractS3Links(m.body);
|
|
|
+ linkCard = cloudLinks.length > 0 ? this.cardOfCloudLink(cloudLinks[0]) : undefined;
|
|
|
+ bodyText = linkCard ? stripLinks(m.body, cloudLinks) : m.body;
|
|
|
+ }
|
|
|
return {
|
|
|
id: m.eventId || `${m.ts}-${m.sender}`,
|
|
|
conversationId: cid,
|
|
|
@@ -2107,6 +2131,44 @@ export class ChatService {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 把声明段里的一项包成附件卡片(2026-11-10)。
|
|
|
+ *
|
|
|
+ * 与 `cardOfCloudLink` 的区别:这里的**文件名和大小是容器给的**,不用猜。
|
|
|
+ */
|
|
|
+ private cardFromDeclared(d: DeclaredFile): FileCard {
|
|
|
+ const ext = (d.name.split('.').pop() ?? '').toLowerCase();
|
|
|
+ const mimeByExt: Record<string, string> = {
|
|
|
+ md: 'text/markdown',
|
|
|
+ markdown: 'text/markdown',
|
|
|
+ html: 'text/html',
|
|
|
+ htm: 'text/html',
|
|
|
+ pdf: 'application/pdf',
|
|
|
+ txt: 'text/plain',
|
|
|
+ csv: 'text/csv',
|
|
|
+ json: 'application/json',
|
|
|
+ png: 'image/png',
|
|
|
+ jpg: 'image/jpeg',
|
|
|
+ jpeg: 'image/jpeg',
|
|
|
+ gif: 'image/gif',
|
|
|
+ webp: 'image/webp',
|
|
|
+ svg: 'image/svg+xml',
|
|
|
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
|
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
|
+ zip: 'application/zip',
|
|
|
+ mp4: 'video/mp4',
|
|
|
+ };
|
|
|
+ return {
|
|
|
+ kind: 'file',
|
|
|
+ name: d.name,
|
|
|
+ size: d.size,
|
|
|
+ mimeType: mimeByExt[ext] ?? 'application/octet-stream',
|
|
|
+ url: d.url,
|
|
|
+ cloudUrl: d.url,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 把一条云盘链接包成附件卡片。
|
|
|
*
|
|
|
@@ -2249,21 +2311,22 @@ export class ChatService {
|
|
|
const fileUrl = mapped.file?.cloudUrl || (mapped.file?.url?.startsWith('https://s3.') ? mapped.file.url : undefined);
|
|
|
if (fileUrl) this.sessions.addDeliverableUrl(sid, fileUrl);
|
|
|
/*
|
|
|
- * 🔴 正文里出现的 S3 链接也要认(2026-11-09 实测补充)。
|
|
|
+ * 成果链接记入会话(2026-11-10 起按优先级):
|
|
|
*
|
|
|
- * 容器产出的成果**不会发文件附件**,而是把云盘 URL 直接写在回复正文里。
|
|
|
- * 只认 `mapped.file` 的话,用户右栏「成果」永远是空的(实测确认)。
|
|
|
+ * ① 容器声明段 `<!--fmode:files-->` —— **显式声明,最可靠**
|
|
|
+ * ② 正文里的云盘链接 —— 老格式兜底,且必须通过文件判定
|
|
|
*
|
|
|
- * ⚠️ 2026-11-09 第二次修正:**必须同时满足三个条件**,否则会误收:
|
|
|
- * ① sender === 'agent' —— 用户在提问里写的示例链接不算成果
|
|
|
- * (实测:用户发「不要写 https://s3.fmode.cn/...」,
|
|
|
- * 结果那条示例被收成了「成果」)
|
|
|
- * ② 不是省略号占位 —— `https://s3.fmode.cn/...` 这种是举例,不是文件
|
|
|
- * ③ 通过文件判定 —— 有合法扩展名(复用 cardOfCloudLink 的规则)
|
|
|
+ * ⚠️ 之前只有 ②,必须同时满足:sender === 'agent' + 不是省略号占位
|
|
|
+ * + 有合法扩展名。少一条就会把用户提问里的示例链接收成成果(已实测踩坑)。
|
|
|
*/
|
|
|
if (mapped.sender === 'agent') {
|
|
|
- for (const u of extractS3Links(mapped.text)) {
|
|
|
- if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
|
|
|
+ const declared = parseDeclaredFiles(m.body);
|
|
|
+ if (declared !== null) {
|
|
|
+ for (const d of declared) this.sessions.addDeliverableUrl(sid, d.url);
|
|
|
+ } else {
|
|
|
+ for (const u of extractS3Links(mapped.text)) {
|
|
|
+ if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
/*
|