import { ArtifactSyncService } from './artifact-sync.service'; import { environment } from '../../environments/environment'; import { computed, inject, Injectable, signal } from '@angular/core'; import { FmodeApi } from './api/fmode-api'; import { Agent, Deliverable, DeliverableGroup, LoadState, Project, SessionFile, } from './api/models'; import { AlertService } from './alert.service'; import { LiveService } from './live.service'; import { isUserUploadUrl } from './s3-link'; /** * 交付成果服务 —— 成果域的**唯一 owner** * * 依据《数据访问层设计.md》§五。约束: * · AC-08.3:成果数据统一走本服务,**禁止组件自行请求** * · 视图只消费,不自建定时器(FR-14 AC-14.2) */ @Injectable({ providedIn: 'root' }) export class DeliverablesService { private readonly api = inject(FmodeApi); /** 成果附件搬运到云盘(2026-11-07:下沉到 load 末尾,全端生效) */ private readonly artifactSync = inject(ArtifactSyncService); /** 上云失败要弹全局提示(2026-11-10) */ private readonly alerts = inject(AlertService); private readonly _items = signal([]); private readonly _groups = signal([]); private readonly _state = signal('idle'); private readonly _error = signal(null); /** 视图切换:按项目 / 按时间线(AC-08.1) */ readonly view = signal<'project' | 'timeline'>('project'); /** 搜索关键字 */ readonly keyword = signal(''); readonly state = this._state.asReadonly(); readonly error = this._error.asReadonly(); /** 按项目分组的成果,同样遵循当前搜索关键字过滤。 */ readonly groups = computed(() => { const q = this.keyword().trim().toLowerCase(); const groups = this._groups(); if (!q) return groups; return groups .map((group) => ({ ...group, items: group.items.filter((d) => this.matches(d, q)), })) .map((group) => ({ ...group, count: group.items.length })) .filter((group) => group.items.length > 0); }); readonly all = this._items.asReadonly(); /** 搜索过滤后的条目(按时间线视图用) */ readonly filtered = computed(() => { const q = this.keyword().trim().toLowerCase(); const items = this._items(); if (!q) return items; return items.filter((d) => this.matches(d, q)); }); readonly filteredEmpty = computed( () => (this._state() === 'ready' || this._state() === 'empty') && this.keyword().trim().length > 0 && this.filtered().length === 0, ); /** 按天/月分组(时间线视图;`dayKey` 由调用方传入格式化结果) */ readonly monthGroups = computed(() => { const map = new Map(); for (const d of this.filtered()) { const key = d.deliveredAt.slice(0, 7); // YYYY-MM if (!map.has(key)) map.set(key, []); map.get(key)!.push(d); } return [...map.entries()] .sort((a, b) => b[0].localeCompare(a[0])) .map(([key, items]) => ({ key, items })); }); /** * 是否「加载完成但确实没有成果」。 * * 🔴 必须同时认 `empty` 与 `ready` 两种状态: * `load()` 在列表为空时设的是 `state='empty'`, * 只判断 `'ready'` 会让这个信号恒为 false,UI 显示不出「暂无成果」。 * 也不能把 `error` 算进来 —— 失败必须显示错误态,不能伪装成空(AC-08.4)。 */ readonly empty = computed( () => (this._state() === 'ready' || this._state() === 'empty') && this._items().length === 0, ); async ensureLoaded(): Promise { // empty 同样是「已加载过」,否则空列表时会反复请求 if (this._state() === 'ready' || this._state() === 'empty' || this._state() === 'loading') return; await this.load(); } /** * 强制重新拉取(手动「刷新」按钮用)。 * * 与 `ensureLoaded` 的区别:**跳过缓存直接加载** —— 用户点刷新就是要拿最新数据。 * 事件页的刷新依赖本方法(事件派生自成果)。 */ async refresh(): Promise { await this.load(); } async load(): Promise { this._state.set('loading'); this._error.set(null); try { const [items, groups] = await Promise.all([ this.api.fetchDeliverables({ limit: 200 }), this.api.fetchDeliverablesByProject(200), ]); this._items.set(items); this._groups.set(groups); this._state.set(items.length === 0 ? 'empty' : 'ready'); /* * 🔴 成果上云盘的触发点(2026-11-07)。 * * 原来只在桌面单聊页的 effect 里调 `syncPending` —— * 用户只在移动端、或在群聊/成果页时,容器产出的文件**永远不会入队**, * 表现就是「有文件名但点不开、没有云盘地址」。 * 下沉到加载完成的末尾:所有调用方(移动端、群聊、成果页)自动生效。 * `syncPending` 内部幂等,桌面那处 effect 可以保留。 */ void this.artifactSync.syncPending(items).then((queued) => { /* * 上云失败要让人知道(2026-11-10)。 * * 原先这里 `void` 掉结果:失败项进 `errors` signal 但没人读, * 用户只看到成果表里一条「点不开的文件名」,完全不知道发生了什么。 * 现在有失败就弹全局提示(需手动关闭),并附上第一个失败原因。 */ const failed = [...this.artifactSync.errors().values()]; if (failed.length > 0) { this.alerts.show(`${failed.length} 个成果未能上云`, failed[0]); } else if (queued > 0 && !environment.production) { // 成功入队不打扰用户,只留痕便于排查(**仅开发态**,线上不打印) console.info(`[成果上云] 已入队 ${queued} 个附件`); } }); } catch (e) { // AC-08.4:接口失败必须显示错误态,禁止伪装成「暂无成果」 this._state.set('error'); this._error.set(e instanceof Error ? e.message : '成果加载失败'); } } switchView(v: 'project' | 'timeline'): void { this.view.set(v); } /** 按 agent 取成果(详情页交付记录用) */ byAgent(agentId: string): Deliverable[] { return this._items().filter((d) => d.agentId === agentId); } /** * 按**会话**取成果(2026-11-09 用户裁决)。 * * 用户原话:「所有成果都是和它们的 Session 绑定的,不是这个容器里所有成果」。 * * ## 数据来源 * * `deliverableUrls` 里的链接分两类,**只有第一类算成果**: * ① 容器产出的成果 → 能在 `deliverables` 表里匹配到,有标题/版本等富信息 * ② 历史数据里混入的用户上传链接(`/user//chat/…`)→ **一律丢弃**, * 它们由消息流(`sender === 'me'`)在「文件」页签渲染 * * 🔴 **宁缺勿错**:没有 `urls` 就返回空,绝不回退到「整个容器的成果」。 * * @param urls 会话记录上的 `deliverableUrls` */ /** * 按会话从**数据库**拉文件(2026-11-10,右侧栏主路径)。 * * 用户裁决:每次直接按会话从数据库加载,不再依赖 * 「先加载聊天历史」或 `FmodeChatThread.deliverableUrls`。 * * 返回的 `SessionFile` 直接映射成 `Deliverable` 视图,复用现有渲染层 * (右栏组件本来就吃 `Deliverable[]`,不用改模板)。 * * 历史数据没有 `threadRoot`,查不到属预期 —— 用户明确「只保证新数据」。 */ async loadForSession(threadRoot: string): Promise { if (!threadRoot) return []; const files = await this.api.fetchFilesBySession(threadRoot); return files.map(toDeliverable); } /** * 按会话拉台账并**按来源分流**(2026-11-11 用户裁决「严格互斥」)。 * * ## 为什么必须分流 * * 用户上传的文件**也会登记进台账**(`uploadedBy:'user'`,见 * `chat.service.ts` 的 `registerFile` 调用)。而台账这一路 * (`loadForSession` → 右栏「成果」的 `fromDb`)此前不区分来源, * 于是「我发的 test-20MB.bin」同时出现在「文件」和「成果」两个页签。 * * 只补 `fromAgentFiles` 的 URL 过滤修不掉这条 —— 台账那条路压根不看 URL。 * * ## 判据:`uploadedBy` 为主,URL 形态兜底 * * `uploadedBy` 是**服务端写入时的权威标记**,云盘前缀变了也不受影响; * URL 形态(`/user//chat/`)只用于兜住 `uploadedBy` 缺失的老记录。 */ async loadSplitForSession( threadRoot: string, ): Promise<{ userFiles: Deliverable[]; agentFiles: Deliverable[] }> { if (!threadRoot) return { userFiles: [], agentFiles: [] }; const files = await this.api.fetchFilesBySession(threadRoot); /* * 防御:只认本会话的记录。 * 服务端 `listBySession` 已按 `user` + `threadRoot` 双条件查 * (`fmode-file-hub.js` 的 `opListBySession`),这里是 App 侧兜底 —— * 万一回包混入其它会话的数据,也不能让它串进当前右栏。 */ const mine = files.filter((f) => !f.threadRoot || f.threadRoot === threadRoot); const { userFiles, agentFiles } = partitionSessionFiles(mine); return { userFiles: userFiles.map(toDeliverable), agentFiles: agentFiles.map(toDeliverable) }; } /** * 按**群**拉台账并分流(2026-11-11,与单聊 `loadSplitForSession` 同口径)。 * * 伪群聊下文件有两种来源,服务端 `listByGroup` 用 * `groupId == g OR threadRoot == g` 一并覆盖,这里只管分流。 */ async loadSplitForGroup( groupId: string, ): Promise<{ userFiles: Deliverable[]; agentFiles: Deliverable[] }> { if (!groupId) return { userFiles: [], agentFiles: [] }; const files = await this.api.fetchFilesByGroup(groupId); /* 防御:只认本群的记录(服务端已按 user + 群过滤,这是 App 侧兜底) */ const mine = files.filter((f) => !f.groupId || f.groupId === groupId); const { userFiles, agentFiles } = partitionSessionFiles(mine); return { userFiles: userFiles.map(toDeliverable), agentFiles: agentFiles.map(toDeliverable) }; } /** * 把「有把握属于本群」的容器产出补归并到群(2026-11-11)。 * * 只在**确定**时才调用:`ids` 由调用方按「本群成员的 DM thread + * 时间落在派发窗口内」筛过。服务端还会再校验一次(本人 + 容器产出 + * 当前未归群),失败静默跳过 —— 宁可漏,不归错。 * * 🔴 服务端会把 `threadRoot` 一并改写为群 id,所以搬进群之后 * 单聊就查不到这条了 —— 这是「一条文件只属于一个页签」的保证。 */ async claimToGroup(groupId: string, ids: string[]): Promise { if (!groupId || ids.length === 0) return 0; try { const r = await this.api.claimFilesToGroup(groupId, ids); return r.claimed; } catch { /* 补归并失败不影响展示:这些文件仍按原归属显示 */ return 0; } } bySessionUrls(urls: readonly string[] | undefined): Deliverable[] { if (!urls || urls.length === 0) return []; const out: Deliverable[] = []; for (const url of urls) { /* * 🔴 用户上传的文件不得计入成果(2026-11-10 修复)。 * * 线上存量数据里混着用户自己上传的链接(历史版本无差别写入), * 不在这里挡一道,右栏「成果」会继续显示用户自己发的图。 * 主修在写入端(`chat.service.ts` 只认 sender='agent'),此处是兜底。 */ if (isUserUploadUrl(url)) continue; // ① 先在成果表里找完整记录 const hit = this._items().find((d) => d.artifacts.some((a) => a.url === url)); if (hit) { // 只保留本会话引用的那个附件,避免把同一成果的其他附件也带出来 const only = hit.artifacts.filter((a) => a.url === url); out.push({ ...hit, artifacts: only.length ? only : hit.artifacts }); continue; } // ② 成果表里没有 → 按 URL 造一条最小记录 const name = fileNameOfUrl(url); out.push({ id: `url:${url}`, agentId: '', title: name, /* filename/size 也让右栏能显示「有附件」而不是「暂无附件」 */ filename: name, artifacts: [{ name, url, type: '' }], tags: [], deliveredAt: '', state: 'delivered', }); } return out; } /** * 从**消息流**里取容器产出的成果(2026-11-09)。 * * 为什么除了 `bySessionUrls` 还要这个: * 会话记录的 `deliverableUrls` 要等落库+回读才生效,中间有个时间窗; * 而且历史上部分链接没被写进去(实测:最新会话 `urls=[]`)。 * 消息流是**实时的、一定有的**,用它兜底能让成果立刻可见。 * * @param files 本会话里 `sender === 'agent'` 的附件 */ fromAgentFiles(files: readonly { name: string; url: string }[]): Deliverable[] { return files /* * 🔴 用户自己上传的文件不得计入成果(2026-11-11 修复)。 * * 与 `bySessionUrls` 同口径:那一处早就挡了,这里漏了 —— * 结果我上传的文件在「文件」和「成果」两个页签各出现一次。 * * 走这条路的场景:容器回复里贴了用户文件的 URL, * 或 Matrix 把用户附件以容器身份回显 —— 两种都会被判成 `sender='agent'`。 */ .filter((f) => !isUserUploadUrl(f.url)) .map((f) => ({ id: `msg:${f.url}`, agentId: '', title: f.name, filename: f.name, artifacts: [{ name: f.name, url: f.url, type: '' }], tags: [], deliveredAt: '', state: 'delivered' as const, })); } byId(id: string): Deliverable | undefined { return this._items().find((d) => d.id === id); } /** * 合并两组成果并去重(2026-11-10)。 * * 去重键用**附件 URL** 而不是 `id`: * 同一条成果可能一边来自 `deliverableUrls` 匹配(有完整 id), * 一边来自 `fromAgentFiles`(`id = msg:`),id 对不上但其实是同一个文件。 * 优先保留前者(信息更全,有标题/版本)。 */ merge(...groups: readonly Deliverable[][]): Deliverable[] { const out: Deliverable[] = []; const seen = new Set(); for (const g of groups) { for (const d of g) { const key = d.artifacts.map((a) => a.url).join('|') || d.id; if (seen.has(key)) continue; seen.add(key); out.push(d); } } return out; } byProject(projectId: string): Deliverable[] { return this._items().filter((d) => d.projectId === projectId); } private matches(d: Deliverable, q: string): boolean { return [d.title, d.projectName, d.filename, d.subtitle, d.fileType] .filter((value): value is string => !!value) .some((value) => value.toLowerCase().includes(q)); } } /** * 台账记录按来源分流(2026-11-11 用户裁决「文件 / 成果严格互斥」)。 * * - `userFiles` → 右栏「文件」页签(我发给容器的) * - `agentFiles` → 右栏「成果」页签(容器产出的) * * 判据:`uploadedBy` 为**权威**(服务端写入时打的标,不随云盘前缀变化), * `isUserUploadUrl` 只兜 `uploadedBy` 缺失/异常的老记录。 * * 纯函数、不依赖 Angular,便于单测覆盖分流边界。 */ export function partitionSessionFiles(files: readonly SessionFile[]): { userFiles: SessionFile[]; agentFiles: SessionFile[]; } { const userFiles: SessionFile[] = []; const agentFiles: SessionFile[] = []; for (const f of files) { const isUser = f.uploadedBy === 'user' || isUserUploadUrl(f.url); (isUser ? userFiles : agentFiles).push(f); } return { userFiles, agentFiles }; } /** * 文件台账记录 → 右栏可渲染的 `Deliverable`。 * * 右栏组件吃的是 `Deliverable[]`,这里做一次投影, * 模板层一行都不用改(用户要求「直接更换为新的一套逻辑」,不是重写 UI)。 */ function toDeliverable(f: SessionFile): Deliverable { const at = f.deliveredAt ? new Date(f.deliveredAt).toISOString() : ''; return { id: f.id, agentId: f.agentId, title: f.name, filename: f.name, /* 原始字节数,供右栏显示与两路去重(2026-11-11);未知时缺省,不写 0 */ ...(f.size > 0 ? { sizeBytes: f.size } : {}), summary: f.uploadedBy === 'user' ? '用户上传' : '容器产出', artifacts: [ { name: f.name, url: f.url, type: f.mimeType, ...(f.storageKey ? { source: f.storageKey } : {}), }, ], tags: [f.uploadedBy], deliveredAt: at, state: 'delivered', }; } /** * 从云盘 URL 里取文件名(用于「只有 URL、成果表里没有」的条目)。 * * 例:`https://s3.fmode.cn/user/x/chat/202611/x-1793-e2e.txt` → `x-1793-e2e.txt` * 取不到时回落到「未命名文件」,不抛错。 */ function fileNameOfUrl(url: string): string { try { const path = url.split('?')[0]; const seg = path.split('/').filter(Boolean).pop() ?? ''; return decodeURIComponent(seg) || '未命名文件'; } catch { return '未命名文件'; } }