|
@@ -12,6 +12,7 @@ import {
|
|
|
MatrixProvision,
|
|
MatrixProvision,
|
|
|
} from './api/models';
|
|
} from './api/models';
|
|
|
import { DEMO_MESSAGES } from './api/demo-data';
|
|
import { DEMO_MESSAGES } from './api/demo-data';
|
|
|
|
|
+import { extractS3Links, stripLinks } from './s3-link';
|
|
|
import { environment } from '../../environments/environment';
|
|
import { environment } from '../../environments/environment';
|
|
|
import { LiveService } from './live.service';
|
|
import { LiveService } from './live.service';
|
|
|
import { SessionService } from './session.service';
|
|
import { SessionService } from './session.service';
|
|
@@ -145,7 +146,7 @@ export class ChatService {
|
|
|
* 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
|
|
* 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
|
|
|
* 用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
|
|
* 用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
|
|
|
*/
|
|
*/
|
|
|
- private readonly _emptyReason = signal<'fresh' | 'unloaded' | null>(null);
|
|
|
|
|
|
|
+ private readonly _emptyReason = signal<'fresh' | 'unloaded' | 'removed' | null>(null);
|
|
|
readonly emptyReason = this._emptyReason.asReadonly();
|
|
readonly emptyReason = this._emptyReason.asReadonly();
|
|
|
|
|
|
|
|
readonly agentId = this._agentId.asReadonly();
|
|
readonly agentId = this._agentId.asReadonly();
|
|
@@ -825,6 +826,29 @@ export class ChatService {
|
|
|
lastMessageAt: lastMessageAtOf(latest.id),
|
|
lastMessageAt: lastMessageAtOf(latest.id),
|
|
|
}).id;
|
|
}).id;
|
|
|
} else if (!this.sessions.byId(sid) || this.sessions.byId(sid)!.messageCount === 0) {
|
|
} else if (!this.sessions.byId(sid) || this.sessions.byId(sid)!.messageCount === 0) {
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 已软删的会话**不允许经由 URL 重新打开**(2026-11-09 用户裁决:
|
|
|
|
|
+ * 「列表项里面隐藏,并且不能通过 URL 直达」)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ⚠️ 判据必须是「**本地目录里根本没有这条**」,不能把「有这条但 0 条消息」
|
|
|
|
|
+ * 也算进来 —— 新建对话恰恰就是 0 条消息的会话,原来那样写会让
|
|
|
|
|
+ * 用户点「新建对话」后看到「该会话已删除」(实测反馈 2026-11-09)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 三重条件缺一不可:
|
|
|
|
|
+ * ① 本地目录里没有这条(byId 取不到)
|
|
|
|
|
+ * ② 云端墓碑里有它
|
|
|
|
|
+ * ③ 不在本机「新建中」状态里(`_blankSessionId`)
|
|
|
|
|
+ */
|
|
|
|
|
+ const known = this.sessions.byId(sid);
|
|
|
|
|
+ if (!known && this._blankSessionId !== sid && this.sessions.isCloudRemoved(sid)) {
|
|
|
|
|
+ if (my !== this.seq) return;
|
|
|
|
|
+ this._sessionId.set(null);
|
|
|
|
|
+ this._agentId.set(agentId);
|
|
|
|
|
+ this._state.set('empty');
|
|
|
|
|
+ this._emptyReason.set('removed');
|
|
|
|
|
+ this._messages.set([]);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
/*
|
|
/*
|
|
|
* 🔴 云端目录里暂时没有这条会话 —— 仍按地址打开它(2026-11-06)。
|
|
* 🔴 云端目录里暂时没有这条会话 —— 仍按地址打开它(2026-11-06)。
|
|
|
*
|
|
*
|
|
@@ -904,6 +928,20 @@ export class ChatService {
|
|
|
* 更早的历史仍由 `paginate()` 按需向上翻。
|
|
* 更早的历史仍由 `paginate()` 按需向上翻。
|
|
|
*/
|
|
*/
|
|
|
this._messages.set(all);
|
|
this._messages.set(all);
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 历史消息里的云盘链接也要认(2026-11-09)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * `appendIncoming` 只处理**实时到达**的消息。刷新页面或重新打开一条
|
|
|
|
|
+ * 老会话时,消息走的是这条回读路径 —— 如果不在这里提取,
|
|
|
|
|
+ * 「打开会话 → 右栏成果空 → 再发一条新消息才出现」,用户会以为成果丢了。
|
|
|
|
|
+ */
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// ⑥ 连接状态由 SDK 同步事件驱动
|
|
// ⑥ 连接状态由 SDK 同步事件驱动
|
|
|
this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
|
|
this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
|
|
@@ -1270,7 +1308,6 @@ export class ChatService {
|
|
|
ts: Date.parse(msg.ts) || Date.now(),
|
|
ts: Date.parse(msg.ts) || Date.now(),
|
|
|
fromMe: true,
|
|
fromMe: true,
|
|
|
});
|
|
});
|
|
|
- this.sessions.commit(curSid);
|
|
|
|
|
/*
|
|
/*
|
|
|
* 🔴 演示态:消息数必须落进本地目录(2026-11-06 实测)。
|
|
* 🔴 演示态:消息数必须落进本地目录(2026-11-06 实测)。
|
|
|
* 宿主页面刷新前只写内存,目录里仍是「0 条消息」;`open()` 开头的
|
|
* 宿主页面刷新前只写内存,目录里仍是「0 条消息」;`open()` 开头的
|
|
@@ -1298,6 +1335,22 @@ export class ChatService {
|
|
|
this.pulse.noteUserAction(conversationId);
|
|
this.pulse.noteUserAction(conversationId);
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 首次发消息 = **先落库,落库成功才发送**(2026-11-09 用户要求)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 用户原话:「第一次发送消息的时候,才会开始持久化存储这个对话;
|
|
|
|
|
+ * 存储成功后,才会开始发送消息」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 原实现调 `commit()`(发出去就不管):消息照发、落库在后台跑。
|
|
|
|
|
+ * 一旦落库失败,消息已经进 Matrix,而目录里没有这条记录 ——
|
|
|
|
|
+ * 换设备就找不到这段对话,用户只在事后看到一条提示。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 现在首次落库 `await`,失败直接落到下面的 catch:
|
|
|
|
|
+ * 消息标记为「发送失败」,**不往容器发**,用户可重试。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (curSid && this.sessions.needsCommit(curSid)) {
|
|
|
|
|
+ await this.sessions.commitNow(curSid);
|
|
|
|
|
+ }
|
|
|
if (environment.useMockData) {
|
|
if (environment.useMockData) {
|
|
|
await this.simulateSend();
|
|
await this.simulateSend();
|
|
|
} else {
|
|
} else {
|
|
@@ -1478,12 +1531,39 @@ export class ChatService {
|
|
|
const mimeType = file.type || 'application/octet-stream';
|
|
const mimeType = file.type || 'application/octet-stream';
|
|
|
if (!this.roomId) throw new Error('会话未就绪');
|
|
if (!this.roomId) throw new Error('会话未就绪');
|
|
|
const sid = this._sessionId();
|
|
const sid = this._sessionId();
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 「两条都存」(2026-11-09 用户裁决)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 用户要求:文件处理完必须上传云盘,之后 App 里查看**都走云盘**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ⚠️ 但**不能**像早期那样「只走 OBS」—— 容器只认 Matrix 的
|
|
|
|
|
+ * `m.file` / `m.image` 原生事件,换成 S3 直链它会回「没收到附件」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 所以:Matrix 照常发(供容器读取与消息流回溯),
|
|
|
|
|
+ * S3 **额外**存一份(供 App 长期查看与成果归档)。
|
|
|
|
|
+ * S3 失败**不阻断发送** —— 降级为只有 Matrix,并记一条告警。
|
|
|
|
|
+ */
|
|
|
await this.mx.sendFile(
|
|
await this.mx.sendFile(
|
|
|
this.roomId,
|
|
this.roomId,
|
|
|
file,
|
|
file,
|
|
|
{ name: file.name, size: file.size, mimeType },
|
|
{ name: file.name, size: file.size, mimeType },
|
|
|
sid ?? undefined,
|
|
sid ?? undefined,
|
|
|
);
|
|
);
|
|
|
|
|
+ let cloudUrl: string | null = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const up = await this.api.uploadFile(file);
|
|
|
|
|
+ cloudUrl = up.publicUrl;
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 记进会话的成果列表(2026-11-09):用户要求的「成果按对话归档」。
|
|
|
|
|
+ * 上传成功立刻写,失败则只有 Matrix 那份,不记链接。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (sid) this.sessions.addDeliverableUrl(sid, cloudUrl);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ /* 如实记录:这条文件只在 Matrix,没进云盘 */
|
|
|
|
|
+ this._error.set(
|
|
|
|
|
+ `「${file.name}」已发送,但未存入云盘:${e instanceof Error ? e.message : '上传失败'}`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
this._uploadPct.set(Math.round(((i + 1) / pending.length) * 100));
|
|
this._uploadPct.set(Math.round(((i + 1) / pending.length) * 100));
|
|
|
// 本地预览用对象地址;Matrix 的 mxc 要等回读后才能转 http
|
|
// 本地预览用对象地址;Matrix 的 mxc 要等回读后才能转 http
|
|
|
const displayUrl = URL.createObjectURL(file);
|
|
const displayUrl = URL.createObjectURL(file);
|
|
@@ -1493,6 +1573,8 @@ export class ChatService {
|
|
|
size: file.size,
|
|
size: file.size,
|
|
|
mimeType,
|
|
mimeType,
|
|
|
url: displayUrl,
|
|
url: displayUrl,
|
|
|
|
|
+ /* 云盘地址(2026-11-09):有这个就说明两边都有,可在成果里长期访问 */
|
|
|
|
|
+ cloudUrl: cloudUrl ?? undefined,
|
|
|
};
|
|
};
|
|
|
this._messages.update((m) => [
|
|
this._messages.update((m) => [
|
|
|
...m,
|
|
...m,
|
|
@@ -1968,12 +2050,46 @@ export class ChatService {
|
|
|
*/
|
|
*/
|
|
|
const card = MatrixClientService.parseFileCard(m.body);
|
|
const card = MatrixClientService.parseFileCard(m.body);
|
|
|
const file = card ?? (m.media ? m.media : undefined);
|
|
const file = card ?? (m.media ? m.media : undefined);
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 正文里的云盘链接 → 生成文件卡片(2026-11-09)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 容器交付成果时不发附件,而是把 `https://s3.fmode.cn/...` 写在回复正文里。
|
|
|
|
|
+ * 用户要求:「应该是显示卡片,而不是链接」。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ⚠️ 2026-11-09 第二次修正 —— 两次都出过问题,记下来避免再犯:
|
|
|
|
|
+ * ① 初版「剥出链接 + 从正文删掉」→ 正文只剩链接时变成**空消息**,
|
|
|
|
|
+ * 用户什么都看不到;容器一行一个链接时还会生成**重复卡片**。
|
|
|
|
|
+ * ② 现在:**正文完整保留**(一个字不删),只为第一个链接生成卡片。
|
|
|
|
|
+ * 链接在正文里继续显示为可点击文字 —— 这是**有意为之**:
|
|
|
|
|
+ * 卡片给「文件」区块用,正文里的链接给用户直接点。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 重复的两个卡片来自**容器一条回复被拆成多条事件**,
|
|
|
|
|
+ * 属于容器输出格式问题,已通过「链接独占一行、一次只给一个主文件」规范约束。
|
|
|
|
|
+ */
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 正文里的云盘链接 → 文件卡片,并把链接从正文里**摘掉**(用户裁决:
|
|
|
|
|
+ * 「卡片统一在底部,正文里的 URL 隐去」)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 顺序不能错:**先摘链接 → 再判断剩余内容是否为空**。
|
|
|
|
|
+ * 上一版直接把 `text` 置成剥离后的结果,没判断空,
|
|
|
|
|
+ * 正文只剩链接时整条消息变成空白(用户实测「回复的文字没有了」)。
|
|
|
|
|
+ */
|
|
|
|
|
+ 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;
|
|
|
return {
|
|
return {
|
|
|
id: m.eventId || `${m.ts}-${m.sender}`,
|
|
id: m.eventId || `${m.ts}-${m.sender}`,
|
|
|
conversationId: cid,
|
|
conversationId: cid,
|
|
|
sender: m.sender === myUserId ? ('me' as const) : ('agent' as const),
|
|
sender: m.sender === myUserId ? ('me' as const) : ('agent' as const),
|
|
|
kind: m.kind as MessageKind,
|
|
kind: m.kind as MessageKind,
|
|
|
- text: file ? undefined : m.body,
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 有附件时**也要保留正文**(2026-11-09 用户反馈「正文没文字了」)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 原实现 `text: file ? undefined : m.body` —— 只要消息带附件就把正文丢掉。
|
|
|
|
|
+ * 但容器常常是「正文说明 + 文件链接」写在同一条消息里,
|
|
|
|
|
+ * 丢掉正文后用户只看到一张卡片,不知道这个文件是干什么的。
|
|
|
|
|
+ */
|
|
|
|
|
+ text: bodyText || undefined,
|
|
|
file: file
|
|
file: file
|
|
|
? {
|
|
? {
|
|
|
kind: 'file' as const,
|
|
kind: 'file' as const,
|
|
@@ -1982,7 +2098,7 @@ export class ChatService {
|
|
|
mimeType: file.mimeType,
|
|
mimeType: file.mimeType,
|
|
|
url: file.url,
|
|
url: file.url,
|
|
|
}
|
|
}
|
|
|
- : undefined,
|
|
|
|
|
|
|
+ : linkCard,
|
|
|
ts: new Date(m.ts).toISOString(),
|
|
ts: new Date(m.ts).toISOString(),
|
|
|
state: 'sent' as const,
|
|
state: 'sent' as const,
|
|
|
// 透传本地 id:合并时用它认领乐观消息(见 mergeTimeline)
|
|
// 透传本地 id:合并时用它认领乐观消息(见 mergeTimeline)
|
|
@@ -1991,6 +2107,92 @@ export class ChatService {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 把一条云盘链接包成附件卡片。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 云盘对象是 `public-read`,链接本身就能下载/预览,
|
|
|
|
|
+ * 所以 `url` 直接用它,`cloudUrl` 也指向它(后续「优先走云盘」逻辑由此生效)。
|
|
|
|
|
+ * 文件名与类型从 URL 推断。
|
|
|
|
|
+ */
|
|
|
|
|
+ private cardOfCloudLink(url: string): FileCard | undefined {
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 先判断「这是不是文件」(2026-11-09)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 原实现只要正文里有云盘域名的链接就造卡 —— 但不全是文件:
|
|
|
|
|
+ * · 目录:`https://s3.fmode.cn/bucket/`
|
|
|
|
|
+ * · 无扩展名的路径:`https://s3.fmode.cn/user/xxx/report`
|
|
|
|
|
+ * · 文档里引用的示例链接
|
|
|
|
|
+ * 这些被当成文件卡片显示,用户看到的是一堆莫名其妙的「文件」。
|
|
|
|
|
+ */
|
|
|
|
|
+ const path = url.split('?')[0].split('#')[0];
|
|
|
|
|
+ // 以 / 结尾 = 目录
|
|
|
|
|
+ if (path.endsWith('/')) return undefined;
|
|
|
|
|
+ const seg = decodeURIComponent(path.split('/').filter(Boolean).pop() ?? '');
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 排除「省略号占位」(2026-11-09)。
|
|
|
|
|
+ * 容器/用户在举例时常写 `https://s3.fmode.cn/...`,
|
|
|
|
|
+ * 末段是 `...`,`lastIndexOf('.')` 会误判成「有扩展名」。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (/^\.+$/.test(seg) || seg.includes('...') || seg.includes('…')) return undefined;
|
|
|
|
|
+ const dot = seg.lastIndexOf('.');
|
|
|
|
|
+ // 没有扩展名 = 不是文件
|
|
|
|
|
+ if (dot <= 0 || dot === seg.length - 1) return undefined;
|
|
|
|
|
+ const ext = seg.slice(dot + 1).toLowerCase();
|
|
|
|
|
+ /* 扩展名必须是纯字母且 ≥2 位(`a.1`、`x.` 这类不是文件) */
|
|
|
|
|
+ if (!/^[a-z]{2,5}$/.test(ext)) return undefined;
|
|
|
|
|
+
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 扩展名白名单:只认「确实是文件」的类型。
|
|
|
|
|
+ * 不在名单里的一律不造卡(链接留在正文里当文字)。
|
|
|
|
|
+ */
|
|
|
|
|
+ const mimeByExt: Record<string, string> = {
|
|
|
|
|
+ md: 'text/markdown',
|
|
|
|
|
+ markdown: 'text/markdown',
|
|
|
|
|
+ html: 'text/html',
|
|
|
|
|
+ htm: 'text/html',
|
|
|
|
|
+ pdf: 'application/pdf',
|
|
|
|
|
+ txt: 'text/plain',
|
|
|
|
|
+ log: 'text/plain',
|
|
|
|
|
+ csv: 'text/csv',
|
|
|
|
|
+ tsv: 'text/tab-separated-values',
|
|
|
|
|
+ json: 'application/json',
|
|
|
|
|
+ xml: 'application/xml',
|
|
|
|
|
+ yml: 'text/yaml',
|
|
|
|
|
+ yaml: 'text/yaml',
|
|
|
|
|
+ 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',
|
|
|
|
|
+ xls: 'application/vnd.ms-excel',
|
|
|
|
|
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
|
|
+ doc: 'application/msword',
|
|
|
|
|
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
|
|
|
+ zip: 'application/zip',
|
|
|
|
|
+ tar: 'application/x-tar',
|
|
|
|
|
+ gz: 'application/gzip',
|
|
|
|
|
+ mp4: 'video/mp4',
|
|
|
|
|
+ mp3: 'audio/mpeg',
|
|
|
|
|
+ };
|
|
|
|
|
+ const mime = mimeByExt[ext];
|
|
|
|
|
+ if (!mime) return undefined;
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ kind: 'file',
|
|
|
|
|
+ name: seg,
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 云盘链接不带文件大小。用 -1 表示「未知」,界面据此不显示「0 B」
|
|
|
|
|
+ * (0 B 会让人以为文件是空的,实测用户困惑)。
|
|
|
|
|
+ */
|
|
|
|
|
+ size: -1,
|
|
|
|
|
+ mimeType: mime,
|
|
|
|
|
+ url,
|
|
|
|
|
+ cloudUrl: url,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** 追加一条新到消息(按 eventId 去重;不属于当前会话的忽略) */
|
|
/** 追加一条新到消息(按 eventId 去重;不属于当前会话的忽略) */
|
|
|
private appendIncoming(m: MxMessage, myUserId: string): void {
|
|
private appendIncoming(m: MxMessage, myUserId: string): void {
|
|
|
if (!this._agentId()) return;
|
|
if (!this._agentId()) return;
|
|
@@ -2020,8 +2222,17 @@ export class ChatService {
|
|
|
const localId = mapped?.localId;
|
|
const localId = mapped?.localId;
|
|
|
this._messages.update((list) => {
|
|
this._messages.update((list) => {
|
|
|
if (!localId) return [...list, mapped];
|
|
if (!localId) return [...list, mapped];
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 把被替换掉的本地消息一并交给 `mergeTimeline`(2026-11-09 修 C7)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 原写法是 `mergeTimeline([...rest, mapped], [])` —— 第二个参数传空数组,
|
|
|
|
|
+ * `localTs` 映射表因此为空,echo 认领会**静默失效**,ts 直接换成服务端时钟,
|
|
|
|
|
+ * 等待读秒从浏览器时钟跳到服务端时钟(用户实测 95s → 100s)。
|
|
|
|
|
+ * 这里把原本地消息传进去,让 localTs 回落真正生效。
|
|
|
|
|
+ */
|
|
|
|
|
+ const mine = list.filter((x) => x.id === localId);
|
|
|
const rest = list.filter((x) => x.id !== localId);
|
|
const rest = list.filter((x) => x.id !== localId);
|
|
|
- return this.mergeTimeline([...rest, mapped], []);
|
|
|
|
|
|
|
+ return this.mergeTimeline([...rest, mapped], mine);
|
|
|
});
|
|
});
|
|
|
this.rememberDemoHistory();
|
|
this.rememberDemoHistory();
|
|
|
if (this._state() === 'empty') this._state.set('ready');
|
|
if (this._state() === 'empty') this._state.set('ready');
|
|
@@ -2031,6 +2242,30 @@ export class ChatService {
|
|
|
ts: Date.parse(mapped.ts) || Date.now(),
|
|
ts: Date.parse(mapped.ts) || Date.now(),
|
|
|
fromMe: mapped.sender === 'me',
|
|
fromMe: mapped.sender === 'me',
|
|
|
});
|
|
});
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 容器发来的附件如果是云盘直链,记进本会话的成果列表(2026-11-09)。
|
|
|
|
|
+ * 用户要求「成果按对话归档」:只有出现在这条对话里的文件才算它的成果。
|
|
|
|
|
+ */
|
|
|
|
|
+ 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 实测补充)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 容器产出的成果**不会发文件附件**,而是把云盘 URL 直接写在回复正文里。
|
|
|
|
|
+ * 只认 `mapped.file` 的话,用户右栏「成果」永远是空的(实测确认)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ⚠️ 2026-11-09 第二次修正:**必须同时满足三个条件**,否则会误收:
|
|
|
|
|
+ * ① sender === 'agent' —— 用户在提问里写的示例链接不算成果
|
|
|
|
|
+ * (实测:用户发「不要写 https://s3.fmode.cn/...」,
|
|
|
|
|
+ * 结果那条示例被收成了「成果」)
|
|
|
|
|
+ * ② 不是省略号占位 —— `https://s3.fmode.cn/...` 这种是举例,不是文件
|
|
|
|
|
+ * ③ 通过文件判定 —— 有合法扩展名(复用 cardOfCloudLink 的规则)
|
|
|
|
|
+ */
|
|
|
|
|
+ if (mapped.sender === 'agent') {
|
|
|
|
|
+ for (const u of extractS3Links(mapped.text)) {
|
|
|
|
|
+ if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
/*
|
|
/*
|
|
|
* 实时到达 + 对方发的 = 「正在回复」。
|
|
* 实时到达 + 对方发的 = 「正在回复」。
|
|
|
* 「用户发的」不算 —— 我方刚发出消息不能说明对方在回。
|
|
* 「用户发的」不算 —— 我方刚发出消息不能说明对方在回。
|