| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- 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<Deliverable[]>([]);
- private readonly _groups = signal<DeliverableGroup[]>([]);
- private readonly _state = signal<LoadState>('idle');
- private readonly _error = signal<string | null>(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<string, Deliverable[]>();
- 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<void> {
- // empty 同样是「已加载过」,否则空列表时会反复请求
- if (this._state() === 'ready' || this._state() === 'empty' || this._state() === 'loading') return;
- await this.load();
- }
- /**
- * 强制重新拉取(手动「刷新」按钮用)。
- *
- * 与 `ensureLoaded` 的区别:**跳过缓存直接加载** —— 用户点刷新就是要拿最新数据。
- * 事件页的刷新依赖本方法(事件派生自成果)。
- */
- async refresh(): Promise<void> {
- await this.load();
- }
- async load(): Promise<void> {
- 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/<uid>/chat/…`)→ **一律丢弃**,
- * 它们由消息流(`sender === 'me'`)在「文件」页签渲染
- *
- * 🔴 **宁缺勿错**:没有 `urls` 就返回空,绝不回退到「整个容器的成果」。
- *
- * @param urls 会话记录上的 `deliverableUrls`
- */
- /**
- * 按会话从**数据库**拉文件(2026-11-10,右侧栏主路径)。
- *
- * 用户裁决:每次直接按会话从数据库加载,不再依赖
- * 「先加载聊天历史」或 `FmodeChatThread.deliverableUrls`。
- *
- * 返回的 `SessionFile` 直接映射成 `Deliverable` 视图,复用现有渲染层
- * (右栏组件本来就吃 `Deliverable[]`,不用改模板)。
- *
- * 历史数据没有 `threadRoot`,查不到属预期 —— 用户明确「只保证新数据」。
- */
- async loadForSession(threadRoot: string): Promise<Deliverable[]> {
- 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:<url>`),id 对不上但其实是同一个文件。
- * 优先保留前者(信息更全,有标题/版本)。
- */
- merge(...groups: readonly Deliverable[][]): Deliverable[] {
- const out: Deliverable[] = [];
- const seen = new Set<string>();
- 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 '未命名文件';
- }
- }
|