deliverables.service.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import { ArtifactSyncService } from './artifact-sync.service';
  2. import { computed, inject, Injectable, signal } from '@angular/core';
  3. import { FmodeApi } from './api/fmode-api';
  4. import {
  5. Agent,
  6. Deliverable,
  7. DeliverableGroup,
  8. LoadState,
  9. Project,
  10. SessionFile,
  11. } from './api/models';
  12. import { AlertService } from './alert.service';
  13. import { LiveService } from './live.service';
  14. import { isUserUploadUrl } from './s3-link';
  15. /**
  16. * 交付成果服务 —— 成果域的**唯一 owner**
  17. *
  18. * 依据《数据访问层设计.md》§五。约束:
  19. * · AC-08.3:成果数据统一走本服务,**禁止组件自行请求**
  20. * · 视图只消费,不自建定时器(FR-14 AC-14.2)
  21. */
  22. @Injectable({ providedIn: 'root' })
  23. export class DeliverablesService {
  24. private readonly api = inject(FmodeApi);
  25. /** 成果附件搬运到云盘(2026-11-07:下沉到 load 末尾,全端生效) */
  26. private readonly artifactSync = inject(ArtifactSyncService);
  27. /** 上云失败要弹全局提示(2026-11-10) */
  28. private readonly alerts = inject(AlertService);
  29. private readonly _items = signal<Deliverable[]>([]);
  30. private readonly _groups = signal<DeliverableGroup[]>([]);
  31. private readonly _state = signal<LoadState>('idle');
  32. private readonly _error = signal<string | null>(null);
  33. /** 视图切换:按项目 / 按时间线(AC-08.1) */
  34. readonly view = signal<'project' | 'timeline'>('project');
  35. /** 搜索关键字 */
  36. readonly keyword = signal('');
  37. readonly state = this._state.asReadonly();
  38. readonly error = this._error.asReadonly();
  39. /** 按项目分组的成果,同样遵循当前搜索关键字过滤。 */
  40. readonly groups = computed(() => {
  41. const q = this.keyword().trim().toLowerCase();
  42. const groups = this._groups();
  43. if (!q) return groups;
  44. return groups
  45. .map((group) => ({
  46. ...group,
  47. items: group.items.filter((d) => this.matches(d, q)),
  48. }))
  49. .map((group) => ({ ...group, count: group.items.length }))
  50. .filter((group) => group.items.length > 0);
  51. });
  52. readonly all = this._items.asReadonly();
  53. /** 搜索过滤后的条目(按时间线视图用) */
  54. readonly filtered = computed(() => {
  55. const q = this.keyword().trim().toLowerCase();
  56. const items = this._items();
  57. if (!q) return items;
  58. return items.filter((d) => this.matches(d, q));
  59. });
  60. readonly filteredEmpty = computed(
  61. () =>
  62. (this._state() === 'ready' || this._state() === 'empty') &&
  63. this.keyword().trim().length > 0 &&
  64. this.filtered().length === 0,
  65. );
  66. /** 按天/月分组(时间线视图;`dayKey` 由调用方传入格式化结果) */
  67. readonly monthGroups = computed(() => {
  68. const map = new Map<string, Deliverable[]>();
  69. for (const d of this.filtered()) {
  70. const key = d.deliveredAt.slice(0, 7); // YYYY-MM
  71. if (!map.has(key)) map.set(key, []);
  72. map.get(key)!.push(d);
  73. }
  74. return [...map.entries()]
  75. .sort((a, b) => b[0].localeCompare(a[0]))
  76. .map(([key, items]) => ({ key, items }));
  77. });
  78. /**
  79. * 是否「加载完成但确实没有成果」。
  80. *
  81. * 🔴 必须同时认 `empty` 与 `ready` 两种状态:
  82. * `load()` 在列表为空时设的是 `state='empty'`,
  83. * 只判断 `'ready'` 会让这个信号恒为 false,UI 显示不出「暂无成果」。
  84. * 也不能把 `error` 算进来 —— 失败必须显示错误态,不能伪装成空(AC-08.4)。
  85. */
  86. readonly empty = computed(
  87. () =>
  88. (this._state() === 'ready' || this._state() === 'empty') && this._items().length === 0,
  89. );
  90. async ensureLoaded(): Promise<void> {
  91. // empty 同样是「已加载过」,否则空列表时会反复请求
  92. if (this._state() === 'ready' || this._state() === 'empty' || this._state() === 'loading') return;
  93. await this.load();
  94. }
  95. /**
  96. * 强制重新拉取(手动「刷新」按钮用)。
  97. *
  98. * 与 `ensureLoaded` 的区别:**跳过缓存直接加载** —— 用户点刷新就是要拿最新数据。
  99. * 事件页的刷新依赖本方法(事件派生自成果)。
  100. */
  101. async refresh(): Promise<void> {
  102. await this.load();
  103. }
  104. async load(): Promise<void> {
  105. this._state.set('loading');
  106. this._error.set(null);
  107. try {
  108. const [items, groups] = await Promise.all([
  109. this.api.fetchDeliverables({ limit: 200 }),
  110. this.api.fetchDeliverablesByProject(200),
  111. ]);
  112. this._items.set(items);
  113. this._groups.set(groups);
  114. this._state.set(items.length === 0 ? 'empty' : 'ready');
  115. /*
  116. * 🔴 成果上云盘的触发点(2026-11-07)。
  117. *
  118. * 原来只在桌面单聊页的 effect 里调 `syncPending` ——
  119. * 用户只在移动端、或在群聊/成果页时,容器产出的文件**永远不会入队**,
  120. * 表现就是「有文件名但点不开、没有云盘地址」。
  121. * 下沉到加载完成的末尾:所有调用方(移动端、群聊、成果页)自动生效。
  122. * `syncPending` 内部幂等,桌面那处 effect 可以保留。
  123. */
  124. void this.artifactSync.syncPending(items).then((queued) => {
  125. /*
  126. * 上云失败要让人知道(2026-11-10)。
  127. *
  128. * 原先这里 `void` 掉结果:失败项进 `errors` signal 但没人读,
  129. * 用户只看到成果表里一条「点不开的文件名」,完全不知道发生了什么。
  130. * 现在有失败就弹全局提示(需手动关闭),并附上第一个失败原因。
  131. */
  132. const failed = [...this.artifactSync.errors().values()];
  133. if (failed.length > 0) {
  134. this.alerts.show(`${failed.length} 个成果未能上云`, failed[0]);
  135. } else if (queued > 0) {
  136. // 成功入队不打扰用户,只留痕便于排查
  137. console.info(`[成果上云] 已入队 ${queued} 个附件`);
  138. }
  139. });
  140. } catch (e) {
  141. // AC-08.4:接口失败必须显示错误态,禁止伪装成「暂无成果」
  142. this._state.set('error');
  143. this._error.set(e instanceof Error ? e.message : '成果加载失败');
  144. }
  145. }
  146. switchView(v: 'project' | 'timeline'): void {
  147. this.view.set(v);
  148. }
  149. /** 按 agent 取成果(详情页交付记录用) */
  150. byAgent(agentId: string): Deliverable[] {
  151. return this._items().filter((d) => d.agentId === agentId);
  152. }
  153. /**
  154. * 按**会话**取成果(2026-11-09 用户裁决)。
  155. *
  156. * 用户原话:「所有成果都是和它们的 Session 绑定的,不是这个容器里所有成果」。
  157. *
  158. * ## 数据来源
  159. *
  160. * `deliverableUrls` 里的链接分两类,**只有第一类算成果**:
  161. * ① 容器产出的成果 → 能在 `deliverables` 表里匹配到,有标题/版本等富信息
  162. * ② 历史数据里混入的用户上传链接(`/user/<uid>/chat/…`)→ **一律丢弃**,
  163. * 它们由消息流(`sender === 'me'`)在「文件」页签渲染
  164. *
  165. * 🔴 **宁缺勿错**:没有 `urls` 就返回空,绝不回退到「整个容器的成果」。
  166. *
  167. * @param urls 会话记录上的 `deliverableUrls`
  168. */
  169. /**
  170. * 按会话从**数据库**拉文件(2026-11-10,右侧栏主路径)。
  171. *
  172. * 用户裁决:每次直接按会话从数据库加载,不再依赖
  173. * 「先加载聊天历史」或 `FmodeChatThread.deliverableUrls`。
  174. *
  175. * 返回的 `SessionFile` 直接映射成 `Deliverable` 视图,复用现有渲染层
  176. * (右栏组件本来就吃 `Deliverable[]`,不用改模板)。
  177. *
  178. * 历史数据没有 `threadRoot`,查不到属预期 —— 用户明确「只保证新数据」。
  179. */
  180. async loadForSession(threadRoot: string): Promise<Deliverable[]> {
  181. if (!threadRoot) return [];
  182. const files = await this.api.fetchFilesBySession(threadRoot);
  183. return files.map(toDeliverable);
  184. }
  185. bySessionUrls(urls: readonly string[] | undefined): Deliverable[] {
  186. if (!urls || urls.length === 0) return [];
  187. const out: Deliverable[] = [];
  188. for (const url of urls) {
  189. /*
  190. * 🔴 用户上传的文件不得计入成果(2026-11-10 修复)。
  191. *
  192. * 线上存量数据里混着用户自己上传的链接(历史版本无差别写入),
  193. * 不在这里挡一道,右栏「成果」会继续显示用户自己发的图。
  194. * 主修在写入端(`chat.service.ts` 只认 sender='agent'),此处是兜底。
  195. */
  196. if (isUserUploadUrl(url)) continue;
  197. // ① 先在成果表里找完整记录
  198. const hit = this._items().find((d) => d.artifacts.some((a) => a.url === url));
  199. if (hit) {
  200. // 只保留本会话引用的那个附件,避免把同一成果的其他附件也带出来
  201. const only = hit.artifacts.filter((a) => a.url === url);
  202. out.push({ ...hit, artifacts: only.length ? only : hit.artifacts });
  203. continue;
  204. }
  205. // ② 成果表里没有 → 按 URL 造一条最小记录
  206. const name = fileNameOfUrl(url);
  207. out.push({
  208. id: `url:${url}`,
  209. agentId: '',
  210. title: name,
  211. /* filename/size 也让右栏能显示「有附件」而不是「暂无附件」 */
  212. filename: name,
  213. artifacts: [{ name, url, type: '' }],
  214. tags: [],
  215. deliveredAt: '',
  216. state: 'delivered',
  217. });
  218. }
  219. return out;
  220. }
  221. /**
  222. * 从**消息流**里取容器产出的成果(2026-11-09)。
  223. *
  224. * 为什么除了 `bySessionUrls` 还要这个:
  225. * 会话记录的 `deliverableUrls` 要等落库+回读才生效,中间有个时间窗;
  226. * 而且历史上部分链接没被写进去(实测:最新会话 `urls=[]`)。
  227. * 消息流是**实时的、一定有的**,用它兜底能让成果立刻可见。
  228. *
  229. * @param files 本会话里 `sender === 'agent'` 的附件
  230. */
  231. fromAgentFiles(files: readonly { name: string; url: string }[]): Deliverable[] {
  232. return files.map((f) => ({
  233. id: `msg:${f.url}`,
  234. agentId: '',
  235. title: f.name,
  236. filename: f.name,
  237. artifacts: [{ name: f.name, url: f.url, type: '' }],
  238. tags: [],
  239. deliveredAt: '',
  240. state: 'delivered' as const,
  241. }));
  242. }
  243. byId(id: string): Deliverable | undefined {
  244. return this._items().find((d) => d.id === id);
  245. }
  246. /**
  247. * 合并两组成果并去重(2026-11-10)。
  248. *
  249. * 去重键用**附件 URL** 而不是 `id`:
  250. * 同一条成果可能一边来自 `deliverableUrls` 匹配(有完整 id),
  251. * 一边来自 `fromAgentFiles`(`id = msg:<url>`),id 对不上但其实是同一个文件。
  252. * 优先保留前者(信息更全,有标题/版本)。
  253. */
  254. merge(...groups: readonly Deliverable[][]): Deliverable[] {
  255. const out: Deliverable[] = [];
  256. const seen = new Set<string>();
  257. for (const g of groups) {
  258. for (const d of g) {
  259. const key = d.artifacts.map((a) => a.url).join('|') || d.id;
  260. if (seen.has(key)) continue;
  261. seen.add(key);
  262. out.push(d);
  263. }
  264. }
  265. return out;
  266. }
  267. byProject(projectId: string): Deliverable[] {
  268. return this._items().filter((d) => d.projectId === projectId);
  269. }
  270. private matches(d: Deliverable, q: string): boolean {
  271. return [d.title, d.projectName, d.filename, d.subtitle, d.fileType]
  272. .filter((value): value is string => !!value)
  273. .some((value) => value.toLowerCase().includes(q));
  274. }
  275. }
  276. /**
  277. * 文件台账记录 → 右栏可渲染的 `Deliverable`。
  278. *
  279. * 右栏组件吃的是 `Deliverable[]`,这里做一次投影,
  280. * 模板层一行都不用改(用户要求「直接更换为新的一套逻辑」,不是重写 UI)。
  281. */
  282. function toDeliverable(f: SessionFile): Deliverable {
  283. const at = f.deliveredAt ? new Date(f.deliveredAt).toISOString() : '';
  284. return {
  285. id: f.id,
  286. agentId: f.agentId,
  287. title: f.name,
  288. filename: f.name,
  289. summary: f.uploadedBy === 'user' ? '用户上传' : '容器产出',
  290. artifacts: [
  291. {
  292. name: f.name,
  293. url: f.url,
  294. type: f.mimeType,
  295. ...(f.storageKey ? { source: f.storageKey } : {}),
  296. },
  297. ],
  298. tags: [f.uploadedBy],
  299. deliveredAt: at,
  300. state: 'delivered',
  301. };
  302. }
  303. /**
  304. * 从云盘 URL 里取文件名(用于「只有 URL、成果表里没有」的条目)。
  305. *
  306. * 例:`https://s3.fmode.cn/user/x/chat/202611/x-1793-e2e.txt` → `x-1793-e2e.txt`
  307. * 取不到时回落到「未命名文件」,不抛错。
  308. */
  309. function fileNameOfUrl(url: string): string {
  310. try {
  311. const path = url.split('?')[0];
  312. const seg = path.split('/').filter(Boolean).pop() ?? '';
  313. return decodeURIComponent(seg) || '未命名文件';
  314. } catch {
  315. return '未命名文件';
  316. }
  317. }