import { ArtifactSyncService } from './artifact-sync.service'; 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) { // 成功入队不打扰用户,只留痕便于排查 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); } 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.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)); } } /** * 文件台账记录 → 右栏可渲染的 `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, 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 '未命名文件'; } }