import { environment } from '../../../environments/environment'; import { tsOf } from '../time'; import { prettifyStorageKey } from '../s3-link'; import { Scope } from '../acl'; import { FmodeApi } from './fmode-api'; import { Agent, AgentStatus, ChatThreadRecord, ChatThreadUpsert, WorkspaceRecord, WorkspaceUpsert, Artifact, ArtifactJob, AuthSession, ChannelConfig, Deliverable, DeliverableGroup, FileEntry, MatrixProvision, Me, SessionFile, OrgMembership, OrgUser, OrgUserPage, Task, UploadTicket, WorkGroup, UsageSession, } from './models'; /** * 真实数据源实现 —— 走 fmode 平台云函数 + REST(《API-契约.md》) * * 依据《数据访问层设计.md》§一/§二:本类是 `FmodeApi` 的**真实实现**, * 由 `api.provider.ts` 在 `environment.useMockData === false` 时装配。 * * ## 约定 * * - 云函数统一入口 `POST https://server.fmode.cn/api/functions` * (头 `X-Parse-Application-Id` / `X-Parse-Session-Token`;体 `{id, token, params}`) * - 错误处理:HTTP 200 但 `body.code` 为 401/403 → 视为业务错误并抛出, * 文案取 `msg` → `mess` → `message` * - 云函数返回的 `host` / `channels` / `balance` 是**字符串形态 JSON**, * 映射层兼容「已是对象」与「是字符串」两种形态(存量 `parseJsonLoose` 的教训) * * ## 前端安全边界(黄金规则 6 / NFR-03) * * 前端**不含任何服务端密钥**:无 masterKey、无 OBS AK/SK、无 Matrix 派生密码。 * Matrix 凭据一律由 provision 云函数服务端签发。 * * ## 一期未实现项 * * `fetchTasks` / `fetchTaskDetail` 的云函数接收端**尚未实现**(SPEC FR-07b 依赖声明), * 故**显式抛错**而非静默返回空数组 —— 空数组会把「未接入」伪装成「没有数据」。 */ /* * 端点来自环境配置(黄金规则 6:serverURL 只在初始化处配置一次)。 * 见 `src/environments/environment.base.ts`。 */ const FUNCTION_API = environment.endpoints.functionsBase; const PARSE_BASE = environment.endpoints.parseServer; const PARSE_APP_ID = environment.endpoints.parseAppId; /** 云函数 objectId(《API-契约》§二) */ const FN = { agentStatus: 'xlosi3m46q', /** 容器重命名(2026-11-05 新建,独立于 agentStatus) */ agentRename: 'AqXGNNVRNb', deliverables: 'lfYlgU7SkK', provision: 'RP5Ht55NeX', tokenSync: 'zl4yU8PAEB', uploadUrl: 'AlP56LCKFm', workgroup: 'V1EBgm05y2', /** 会话目录(2026-10-31 新建,Function.name = fmodeagent-chatthreads) */ chatThreads: 'oR5O67i1Cm', /** * 文件台账(2026-11-10 新建,Function.name = fmode-file-hub) * * 为什么另起一个:老成果函数 `lfYlgU7SkK` 的 report 用字段白名单写入, * 里面没有 `threadRoot`,容器传了也被静默丢弃,成果无法归属到会话。 * 用户裁决:现有云函数一行不动,新建独立函数走新接口。 */ fileHub: 'THXAOGKiPK', /** * 文件正文代理(2026-11-11 新建,Function.name = fmode-file-read)。 * * 🔴 为什么要它:云盘 s3.fmode.cn 是 public-read 但**没配 CORS**, * 浏览器 fetch 必被拦(「加载失败:Failed to fetch」), * .md / .txt 在页内根本渲染不出来。 * 由服务端代取文本,浏览器只调本域(已配 ACAO:*)。 * * ⚠️ 已部署(2026-11-11,`tocXyQjyh6`)。未部署时此 ID 为空,`readText` 会抛错, * 调用方回落到「在新窗口打开」。 */ fileRead: 'tocXyQjyh6', /** * 组织成员管理(2026-11-10 新建,Function.name = fmodeagent-users)。 * * 🔴 源码见 `cloud/functions/fmodeagent-users.js`;**部署后**把真实 objectId 填到这里。 * 未部署时该调用会 404,页面显示错误态而不是空白。 */ orgUsers: 'mgR4EA2n5a', } as const; /** 兼容「已是对象」与「是 JSON 字符串」两种形态 */ function parseJsonLoose>(v: unknown): T | undefined { if (v == null) return undefined; if (typeof v === 'object') return v as T; if (typeof v !== 'string' || !v.trim()) return undefined; try { return JSON.parse(v) as T; } catch { return undefined; } } export class ParseApi extends FmodeApi { /** * 会话令牌读取器 + 请求范围判定器。 * * - **令牌**由 `SessionService` 唯一持有,**每次调用实时读取**(避免登录/退登后持过期副本) * - **scope** 由 `acl.scopeFor(me)` 判定(超管 all / 公司管理员 company / 普通用户 mine); * 🔴 **不得硬编码 `'all'`** —— 那会让每个用户都看到全平台别人的容器(越权) */ constructor( private readonly readToken: () => string = () => '', private readonly readScope: () => Scope = () => 'mine', ) { super(); } /** 当前会话令牌(读取器为空时回落到内部缓存,供单测注入) */ private get sessionToken(): string { return this.readToken() || this.fallbackToken; } /** 仅单测/无 DI 场景使用的回落令牌 */ private fallbackToken = ''; setToken(token: string): void { this.fallbackToken = token; } // ---------- 鉴权 ---------- async sendMobileCode(mobile: string): Promise { // R4:POST /api/apig/message {mobile, company};🔴 code === 1 才算成功 const res = await this.rawFetch(`${PARSE_BASE.replace('/parse', '')}/api/apig/message`, { method: 'POST', body: JSON.stringify({ mobile, company: LOGIN_COMPANY }), }); const d = (await res.json()) as { code?: number; msg?: string; mess?: string }; if (d.code !== 1) throw new Error(friendlySendCodeError(pickMessage(d), res.status)); } async loginWithMobile(mobile: string, code: string): Promise { // R5:GET /api/fmode/mobile?mobile&code&company;🔴 code === 200 && data.token const url = `${PARSE_BASE.replace('/parse', '')}/api/fmode/mobile?mobile=${encodeURIComponent( mobile, )}&code=${encodeURIComponent(code)}&company=${LOGIN_COMPANY}`; const res = await this.rawFetch(url, { method: 'GET' }); const d = (await res.json()) as { code?: number; data?: { token?: string }; msg?: string; mess?: string; }; const token = d.data?.token; if (d.code !== 200 || !token) throw new Error(pickMessage(d) ?? '登录失败'); this.setToken(token); const me = await this.fetchMe(token); return { token, me }; } async loginWithToken(token: string): Promise { const me = await this.fetchMe(token); this.setToken(token); return { token, me }; } async fetchMe(token: string): Promise { // R1:**GET** /parse/users/me(头 X-Parse-Session-Token) // 🔴 实测教训:POST 会 404(返回 HTML 错误页);契约文档曾误写为 POST。 const res = await this.rawFetch(`${PARSE_BASE}/users/me`, { method: 'GET', headers: { 'X-Parse-Session-Token': token }, }); if (!res.ok) throw new Error(`Token 校验失败(HTTP ${res.status})`); const u = (await res.json()) as Record; const objectId = String(u['objectId'] ?? ''); // R2:_User JSON **不透出 relation**,roles 必须单独查(存量 v0.3.3 著名坑: // 超管因缺 roles 被误判为 company-member → 列表空) const roles = objectId ? await this.fetchRoles(token, objectId).catch(() => [] as string[]) : []; const companyId = (u['company'] as { objectId?: string } | undefined)?.objectId; // 所属组织名:实测 Company 表有 name(如 E4KpGvTEto → 飞码AI)。 // 取不到时为 undefined → UI 显示占位,**不编造**。 const companyName = companyId ? await this.fetchCompanyName(token, companyId).catch(() => undefined) : undefined; return { id: objectId, name: String(u['nickname'] ?? u['username'] ?? ''), mobile: u['mobile'] ? String(u['mobile']) : undefined, avatar: u['avatar'] ? String(u['avatar']) : undefined, companyId, companyName, roles, }; } private async fetchRoles(token: string, userId: string): Promise { // R2:GET /parse/roles?where={users:Pointer<_User>}&limit=50 const where = JSON.stringify({ users: { __type: 'Pointer', className: '_User', objectId: userId }, }); const res = await this.rawFetch( `${PARSE_BASE}/roles?where=${encodeURIComponent(where)}&limit=50`, { headers: { 'X-Parse-Session-Token': token } }, ); if (!res.ok) return []; const d = (await res.json()) as { results?: { name?: string }[] }; return (d.results ?? []).map((r) => String(r.name ?? '')).filter(Boolean); } /** 取组织名(`Company.name`);失败返回 undefined(不回退假值) */ private async fetchCompanyName(token: string, companyId: string): Promise { const res = await this.rawFetch( `${PARSE_BASE}/classes/Company/${encodeURIComponent(companyId)}?keys=name`, { headers: { 'X-Parse-Session-Token': token } }, ); if (!res.ok) return undefined; const d = (await res.json()) as { name?: string }; return d.name ? String(d.name) : undefined; } // ---------- 数字生命(实况) ---------- async fetchAgents(): Promise { const d = await this.callFunction<{ code: number; agents?: unknown[] }>(FN.agentStatus, { op: 'query', // 🔴 按角色判定(acl.scopeFor)—— 超管 all / 公司管理员 company / 普通用户 mine。 // 曾硬编码 'all' → 每个用户都拿到全平台 51 个容器(越权)。 scope: this.readScope(), }); if (d.code !== 200 || !Array.isArray(d.agents)) { throw new Error(pickMessage(d) ?? '实况数据查询失败'); } return d.agents.map((raw) => mapAgent(raw as Record)); } /** * 重命名容器(2026-11-04)。 * * ## 当前状态:**后端接口未实现** * * 走**独立云函数** `fmodecontainer-rename`(2026-11-05 上线)。 * 服务端独立校验权限:公司管理员只能改本公司容器,超管可改全部。 * * 🔴 失败**如实抛错**,不写本地缓存 —— 否则会出现 * 「我这改了、同事看到的还是旧名」的假象。 */ async renameAgent(agentId: string, displayName: string): Promise { const d = await this.callFunction<{ code: number; agent?: unknown; msg?: string }>( FN.agentRename, { op: 'rename', agentId, displayName: displayName.trim() }, ); if (d.code !== 200) { throw new Error(pickMessage(d) ?? `重命名失败(服务端返回 ${d.code})`); } return d.agent ? mapAgent(d.agent as Record) : null; } async fetchAgentStatus(agentId: string): Promise { const d = await this.callFunction<{ code: number; agent?: unknown }>(FN.agentStatus, { op: 'query', scope: this.readScope(), agentId, }); // 单条查询:code 404/403 → 返回 null(页面自行显示「不存在/无权限」) if (d.code !== 200 || !d.agent) return null; return mapAgent(d.agent as Record); } // ---------- Matrix ---------- async provisionMatrix(agentId: string): Promise { const d = await this.callFunction(FN.provision, { agentId, }); // 校验规则(《API-契约》§2.3):必须 code===200 && accessToken && userMxId if (d.code !== 200 || !d.accessToken || !d.userMxId) { throw new Error(pickMessage(d) ?? 'FMODE 开通失败'); } return d; } async validateMatrix(agentId: string): Promise { const d = await this.callFunction(FN.provision, { agentId, validate: 1, }); if (d.code !== 200 || !d.accessToken || !d.userMxId) { throw new Error(pickMessage(d) ?? 'FMODE 凭据校验失败'); } return d; } // ---------- 会话目录(云函数 oR5O67i1Cm / fmodeagent-chatthreads) ---------- /** * 拉取当前用户的会话目录。 * * 🔴 云函数按 sessionToken 强制归属当前用户;这里**不传任何用户参数**。 * 返回结构在 function 内已归一(毫秒时间戳、驼峰字段)。 */ async fetchChatThreads(agentId: string, opts?: { includeDeleted?: boolean }): Promise { const d = await this.callFunction<{ code: number; threads?: ChatThreadRecord[]; mess?: string }>( FN.chatThreads, { op: 'list', agentId, ...(opts?.includeDeleted ? { includeDeleted: 1 } : {}) }, ); if (d.code !== 200) throw new Error(d.mess ?? '会话目录读取失败'); return Array.isArray(d.threads) ? d.threads : []; } async upsertChatThreads(items: ChatThreadUpsert[]): Promise { if (items.length === 0) return; const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, { op: 'upsert', items, }); if (d.code !== 200) throw new Error(d.mess ?? '会话目录同步失败'); } async patchChatThread(input: { threadRoot: string; title?: string; archived?: boolean; /** 归属工作区;`''` = 移回日常对话(2026-11-01 起上云,换设备可见) */ workspaceId?: string; }): Promise { const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, { op: 'patch', ...input, }); // 404 = 云端还没有这条(本地先建、还没同步过)→ 视为成功,下次 upsert 会补上 if (d.code !== 200 && d.code !== 404) throw new Error(d.mess ?? '会话更新失败'); } // ---------- 工作区(2026-11-01 上云) ---------- /** * 拉取工作区列表。 * * 与工作区相关的 op 都复用同一个云函数 `oR5O67i1Cm`(fmodeagent-chatthreads), * 不另开函数 —— 它们同属「用户自己的对话组织数据」,部署与回滚应该一起走。 */ async fetchWorkspaces(): Promise { const d = await this.callFunction<{ code: number; workspaces?: WorkspaceRecord[]; mess?: string }>( FN.chatThreads, { op: 'wsList' }, ); if (d.code !== 200) throw new Error(d.mess ?? '工作区读取失败'); return Array.isArray(d.workspaces) ? d.workspaces : []; } async upsertWorkspaces(items: WorkspaceUpsert[]): Promise { if (items.length === 0) return; const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, { op: 'wsUpsert', items, }); if (d.code !== 200) throw new Error(d.mess ?? '工作区同步失败'); } async removeWorkspace(id: string): Promise { const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, { op: 'wsRemove', id, }); if (d.code !== 200) throw new Error(d.mess ?? '工作区删除失败'); } async removeChatThread(input: { threadRoot: string; roomId?: string; agentId?: string }): Promise { const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, { op: 'remove', ...input, }); if (d.code !== 200) throw new Error(d.mess ?? '会话删除失败'); } // ---------- 交付成果 ---------- async fetchDeliverables(opts?: { agentId?: string; limit?: number }): Promise { const params: Record = { action: 'list', limit: opts?.limit ?? 200 }; if (opts?.agentId) params['agentId'] = opts.agentId; const d = await this.callFunction<{ code: number; deliverables?: unknown[] }>( FN.deliverables, params, ); if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果查询失败'); return (d.deliverables ?? []) .map((x) => mapDeliverable(x as Record)) /* * 🔴 过滤「非成果」记录(用户实测:右栏出现两条没有意义的文档): * * · `title` 为空/纯符号 → 没有任何信息,点了也看不懂是什么 * · `title` 恰为 `probe` 且无附件 → 上报器的**探针测试**,不是交付物 * * 过滤放在映射层(全项目唯一入口),页面不必各自判断。 */ .filter((x) => { const t = x.title.trim(); if (!t || !/[\p{L}\p{N}]/u.test(t)) return false; if (/^probe$/i.test(t) && x.artifacts.length === 0) return false; return true; }); } async fetchDeliverablesByProject(limit?: number): Promise { const d = await this.callFunction<{ code: number; groups?: unknown[] }>(FN.deliverables, { action: 'byProject', limit: limit ?? 200, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果分组查询失败'); return (d.groups ?? []).map((g) => { const raw = g as Record; const items = Array.isArray(raw['items']) ? raw['items'] .map((x) => mapDeliverable(x as Record)) // 与 fetchDeliverables 同一过滤口径(见上) .filter((x) => { const t = x.title.trim(); if (!t || !/[\p{L}\p{N}]/u.test(t)) return false; if (/^probe$/i.test(t) && x.artifacts.length === 0) return false; return true; }) : []; return { project: raw['project'] ? String(raw['project']) : undefined, projectName: String(raw['projectName'] ?? '未归类'), count: Number(raw['count']) || items.length, items, }; }); } // ---------- 任务看板(FR-07b:云函数接收端未实现) ---------- async fetchTasks(_agentId: string): Promise { // 🔴 不得静默返回空数组(那会把「未接入」伪装成「没有数据」) throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)'); } async fetchTaskDetail(_taskId: string): Promise { throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)'); } // ---------- 上传(OBS / 华为云,统一走 S3 预签名) ---------- async requestUploadUrl( filename: string, mimeType: string, size: number, ): Promise { const d = await this.callFunction<{ code: number; uploadUrl?: string; key?: string; publicUrl?: string; headers?: Record; }>(FN.uploadUrl, { filename, mimeType, size }); // 《API-契约》§2.5:缺 uploadUrl 即抛错,不得静默降级为本地 blob if (d.code !== 200 || !d.uploadUrl || !d.key) { throw new Error(pickMessage(d) ?? '上传签名获取失败'); } return { uploadUrl: d.uploadUrl, key: d.key, publicUrl: d.publicUrl ?? `https://s3.fmode.cn/${d.key}`, headers: d.headers, }; } async uploadFile( file: File, onProgress?: (pct: number) => void, ): Promise<{ key: string; publicUrl: string }> { const ticket = await this.requestUploadUrl( file.name, file.type || 'application/octet-stream', file.size, ); // S3 直传(华为云 OBS 兼容 S3 协议);前端不经手任何 AK/SK const headers: Record = { 'Content-Type': file.type || 'application/octet-stream', 'x-obs-acl': 'public-read', ...ticket.headers, }; await this.putWithProgress(ticket.uploadUrl, headers, file, onProgress); return { key: ticket.key, publicUrl: ticket.publicUrl }; } // ---------- 成果文件搬运队列 ---------- async enqueueArtifactJob(job: { deliverableId: string; agentId: string; source: string; filename: string; index: number; }): Promise<{ id: string }> { // 只报「传哪个」;地址与头由云函数签好写进队列(客户端不接触密钥) const d = await this.callFunction<{ code: number; id?: string }>(FN.deliverables, { action: 'enqueueArtifact', ...job, }); if (d.code !== 200 || !d.id) { throw new Error(pickMessage(d) ?? '搬运任务创建失败'); } return { id: d.id }; } async fetchArtifactJobs(agentId: string): Promise { const d = await this.callFunction<{ code: number; jobs?: unknown[] }>(FN.deliverables, { action: 'pendingArtifacts', agentId, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '搬运任务查询失败'); return (d.jobs ?? []).map((x) => x as ArtifactJob); } // ---------- 文件台账(fmode-file-hub,2026-11-10) ---------- async registerFile(entry: FileEntry): Promise<{ id: string; duplicated?: boolean }> { const d = await this.callFunction<{ code: number; id?: string; duplicated?: boolean }>( FN.fileHub, { action: 'registerFile', ...entry }, ); if (d.code !== 200 || !d.id) { throw new Error(pickMessage(d) ?? '文件登记失败'); } return { id: d.id, duplicated: d.duplicated }; } /** * 读云盘文本文件正文(2026-11-11)。 * * 走云函数 `fmode-file-read` 代取 —— 云盘没配 CORS,浏览器直连必失败。 * * @throws 未部署 / 非文本 / 过大 / 网络失败时抛错,调用方自行降级(新窗口打开) */ async readText(url: string): Promise { if (!FN.fileRead) throw new Error('文件读取服务未部署'); const d = await this.callFunction<{ code: number; text?: string; msg?: string }>(FN.fileRead, { action: 'readText', url, }); if (d.code !== 200 || typeof d.text !== 'string') { throw new Error(d.msg ?? '读取失败'); } return d.text; } /** * 按**群**拉文件台账(2026-11-11,v0.2.0)。 * * 服务端同时匹配 `groupId` 与 `threadRoot` —— 后者保证**没有 groupId 的 * 存量群内上传记录**也能查到(无需数据迁移)。 */ async fetchFilesByGroup(groupId: string, limit = 200): Promise { if (!groupId) return []; const d = await this.callFunction<{ code: number; files?: unknown[] }>(FN.fileHub, { action: 'listByGroup', groupId, limit, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '群文件加载失败'); return (d.files ?? []).map((x) => x as SessionFile); } /** * 把容器产出**补归并**到群(2026-11-11,v0.2.0)。 * * 服务端会把 `threadRoot` 一并改写为群 id —— 这是「不串台」的关键: * 搬进群之后单聊就查不到了,一条文件只出现在一个页签。 * * 只接受「本人 + 容器产出 + 当前未归群」的记录,其余静默跳过。 */ async claimFilesToGroup(groupId: string, ids: string[]): Promise<{ claimed: number }> { if (!groupId || ids.length === 0) return { claimed: 0 }; const d = await this.callFunction<{ code: number; claimed?: number; msg?: string }>(FN.fileHub, { action: 'claimToGroup', groupId, ids: ids.slice(0, 100), }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '归并失败'); return { claimed: d.claimed ?? 0 }; } async fetchFilesBySession(threadRoot: string, limit = 100): Promise { const d = await this.callFunction<{ code: number; files?: unknown[] }>(FN.fileHub, { action: 'listBySession', threadRoot, limit, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '会话文件加载失败'); return (d.files ?? []).map((x) => x as SessionFile); } // ---------- 工作组 ---------- async createWorkGroup(input: { name: string; goal?: string; memberAgentIds: string[]; }): Promise<{ id: string; roomId: string }> { const d = await this.callFunction<{ code: number; id?: string; roomId?: string }>(FN.workgroup, { action: 'create', name: input.name, goal: input.goal ?? '', memberAgentIds: input.memberAgentIds, }); if (d.code !== 200 || !d.id || !d.roomId) { throw new Error(pickMessage(d) ?? '创建工作组失败'); } return { id: d.id, roomId: d.roomId }; } async listWorkGroups(): Promise { const d = await this.callFunction<{ code: number; groups?: unknown[] }>(FN.workgroup, { action: 'list', }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '工作组查询失败'); return (d.groups ?? []) as WorkGroup[]; } async inviteToWorkGroup(groupId: string, agentIds: string[]): Promise { const d = await this.callFunction<{ code: number }>(FN.workgroup, { action: 'invite', groupId, agentIds, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '邀请失败'); } async removeFromWorkGroup(groupId: string, agentId: string): Promise { const d = await this.callFunction<{ code: number }>(FN.workgroup, { action: 'remove', groupId, agentId, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '移出失败'); } async archiveWorkGroup(groupId: string): Promise { const d = await this.callFunction<{ code: number }>(FN.workgroup, { action: 'archive', groupId, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '归档失败'); } // ---------- 组织成员管理(2026-11-10) ---------- /* * 🔴 这组接口**不做任何前端权限判断**:谁能调用由服务端决定, * 前端只负责把 403 如实显示出来。这样即使前端被改,也越不了权。 */ async listOrgUsers(query: { keyword?: string; page?: number; pageSize?: number; }): Promise { const d = await this.callFunction<{ code: number; items?: OrgUser[]; total?: number; page?: number; pageSize?: number }>( FN.orgUsers, { op: 'list', ...query }, ); if (d.code !== 200) throw new Error(pickMessage(d) ?? '成员列表加载失败'); return { items: d.items ?? [], total: d.total ?? 0, page: d.page ?? query.page ?? 1, pageSize: d.pageSize ?? query.pageSize ?? 20, }; } async addOrgUser(userId: string): Promise { const d = await this.callFunction<{ code: number; item?: OrgUser }>(FN.orgUsers, { op: 'add', userId, }); if (d.code !== 200 || !d.item) throw new Error(pickMessage(d) ?? '添加成员失败'); return d.item; } async updateOrgUser( userId: string, patch: { nickname?: string; mobile?: string; password?: string }, ): Promise { const d = await this.callFunction<{ code: number; item?: OrgUser }>(FN.orgUsers, { op: 'update', userId, ...patch, }); if (d.code !== 200 || !d.item) throw new Error(pickMessage(d) ?? '保存失败'); return d.item; } async removeOrgUser(userId: string): Promise { const d = await this.callFunction<{ code: number }>(FN.orgUsers, { op: 'remove', userId }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '删除失败'); } async fetchOrgMembership(): Promise { const d = await this.callFunction & { code: number }>( FN.orgUsers, { op: 'me' }, ); if (d.code !== 200) throw new Error(pickMessage(d) ?? '查询组织归属失败'); return { userId: d.userId ?? '', hasOrg: !!d.hasOrg, companyId: d.companyId ?? '', companyName: d.companyName ?? '', }; } async ackArtifactJob(input: { id: string; ok: boolean; publicUrl?: string; error?: string; }): Promise { const d = await this.callFunction<{ code: number }>(FN.deliverables, { action: 'ackArtifact', objectId: input.id, ok: input.ok, publicUrl: input.publicUrl, error: input.error, }); if (d.code !== 200) throw new Error(pickMessage(d) ?? '搬运任务回执失败'); } /** PUT 直传;浏览器 XMLHttpRequest 才能拿到上传进度 */ private putWithProgress( url: string, headers: Record, file: File, onProgress?: (pct: number) => void, ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url, true); for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v); xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) resolve(); else reject(new Error(`上传失败(HTTP ${xhr.status})`)); }; xhr.onerror = () => reject(new Error('上传失败:网络错误')); xhr.send(file); }); } // ---------- 内部 ---------- /** * 云函数统一调用(《API-契约》§1.1)。 * * ## 🔴 平台抖动时的表现(2026-10-29 实测) * * 平台服务端会出现间歇性故障。实测连续 5 次请求: * `超时 / 超时 / 503 / 200 / 200`。 * * 浏览器此时报的是 **CORS 错误**(`No 'Access-Control-Allow-Origin'`), * 那只是表象 —— 真正原因是服务端 503 或直接断开, * **连响应头都没返回**,浏览器于是判定为跨域失败。 * * 所以: * · 网络层失败(fetch 直接抛)→ 明确告知「连不上平台」,不是用户的操作问题 * · 503 / 502 / 504 → 告知「平台暂时不可用」并带上可重试的意思 * · 其它非 2xx → 保留原状态码 */ private async callFunction(id: string, params: Record): Promise { /* * 平台抖动是**已知会发生**的(实测 5 次里 2 次超时、1 次 503)。 * 瞬时可恢复的错误自动重试一次 —— 用户不该因为一次抖动就看到报错。 * 只重试「网络失败 / 502 / 503 / 504」,业务错误(4xx)不重试。 */ const attempt = async (): Promise => this.rawFetch(FUNCTION_API, { method: 'POST', headers: this.authHeaders(), body: JSON.stringify({ id, token: this.sessionToken, params }), }); let res: Response | null = null; let lastErr: unknown = null; for (let i = 0; i < 2; i++) { try { res = await attempt(); // 可恢复的服务端错误 → 短暂等待后重试一次 if ((res.status === 502 || res.status === 503 || res.status === 504) && i === 0) { await new Promise((r) => setTimeout(r, 800)); continue; } break; } catch (e) { lastErr = e; if (i === 0) { await new Promise((r) => setTimeout(r, 800)); continue; } throw new Error('连不上平台服务(网络或服务端不可用),请稍后重试'); } } if (!res) { void lastErr; throw new Error('连不上平台服务(网络或服务端不可用),请稍后重试'); } if (!res.ok) { if (res.status === 503 || res.status === 502 || res.status === 504) { throw new Error(`平台暂时不可用(HTTP ${res.status}),请稍后重试`); } /* * 🔴 403 要翻译成人话(2026-11-12 用户实测)。 * * 同公司同事打开某个容器时,provision 云函数会返回 403。 * 原本只抛「云函数调用失败(HTTP 403)」,用户不知道是网络、账号还是权限问题。 * * ⚠️ 注意:产品目标是「同公司成员可共用全部容器」(见 * `docs/03-interface/需求-同公司共用容器-20261112.md`), * 当前 403 属于服务端尚未放开的限制,不是正常状态。 * 所以文案用「暂时不可用」而不是「你没有权限」—— * 避免让用户以为这是永久性的个人权限问题。 */ if (res.status === 403) { throw new Error( '这个容器暂时无法使用(平台侧授权未开放)。同公司成员共用容器需平台侧开通,请联系管理员。', ); } if (res.status === 401) { throw new Error('登录已失效,请重新登录。'); } throw new Error(`云函数调用失败(HTTP ${res.status})`); } return (await res.json()) as T; } /** * 云函数调用用的请求头。 * * 🔴 **不要带 `X-Parse-Application-Id`**(2026-11-11 线上实测)。 * * 线上 `https://ai.fmode.cn` 调用 `server.fmode.cn/api/functions` 时, * 浏览器预检直接被拒: * * Request header field x-parse-application-id is not allowed * by Access-Control-Allow-Headers in preflight response. * * 根因在**服务端网关**:预检响应取决于 `Access-Control-Request-Headers` * 里各字段的**大小写**—— * · 全小写 → `Access-Control-Allow-Headers: *`(放行) * · 首字母大写(浏览器实际发的 `Content-Type, X-Parse-Application-Id`) * → 回固定白名单,**里面没有 x-parse-application-id** → 被拒 * * 而这个头对云函数**完全无用**:鉴权靠 URL 里的函数 `id` + body 里的 * `token`(/`X-Parse-Session-Token`)。实测去掉后调用正常返回。 * * 开发态之所以没暴露:`environment.ts` 的 `functionsBase` 是相对路径 * `/api/functions`,同源、不走预检。 */ /** * 云函数调用用的请求头 —— **一个 `X-Parse-*` 都不带**。 * * ## 为什么(2026-11-11 线上实测,两轮踩坑) * * 线上 `https://ai.fmode.cn` 跨域调 `server.fmode.cn/api/functions`, * 浏览器先发 OPTIONS 预检。**服务端的预检响应不稳定**: * * ``` * 连续 10 次 OPTIONS 统计: * Access-Control-Allow-Headers: * → 4 次(放行) * Access-Control-Allow-Headers: Authorization,DNT,...,Content-Type,Range → 6 次(拒) * ``` * * 后端的固定白名单里**没有任何 `x-parse-*`**。网关有多个实例、配置不一致, * 所以表现为「时好时坏」:4/10 成功 → 用户看到部分请求报 CORS, * 但因为请求彼此独立,主流程往往还能走通。 * * 第一轮只去掉了 `X-Parse-Application-Id`,问题立刻在 * `X-Parse-Session-Token` 上重现 —— 逐个删是治标的。现在**全部不带**。 * * ## 不带头怎么鉴权 * * 云函数从 body 的 `params.token` 取(`fmode-file-hub.js` 的 `_sessionToken()` * 里 `params.token` 是明确回退路径)。实测只带 body、不带任何头, * 云函数正常返回数据。 * * ⚠️ 只对**云函数入口**这样处理;Parse REST / 登录等接口照旧带头, * 不走这条路径。 */ private authHeaders(): Record { return { 'Content-Type': 'application/json' }; } private rawFetch(url: string, init: RequestInit): Promise { /* * 🔴 **默认不带任何 `X-Parse-*` 头**,只有 Parse REST 白名单才带。 * * ## 为什么(两次线上的 CORS 事故) * * 服务端网关有多个实例、预检响应不一致: * Access-Control-Allow-Headers: * → 放行(4/10 次) * Authorization,DNT,...,Content-Type,Range → 拒绝(6/10 次) * 固定白名单里**没有任何 `x-parse-*`**,命中那一台就把整条请求拦掉。 * * ## 踩过的坑(两次,都是上线后才炸) * * ① 第一版只剥离「云函数入口」→ `/api/apig/message` 被拦 * ② 第二版改成「`/api/` 路径」→ 新接口只要不在这条规则里,照样会被拦 * * **教训:枚举「哪些不带」永远会漏,必须反过来枚举「哪些带」。** * * ## 现在的规则(白名单式,默认安全) * * | 目标 | 带 X-Parse-* | * |---|---| * | Parse REST(`/parse/users/me`、`/parse/roles` 等) | ✅ 带(Parse Server 硬性要求) | * | **其它一切**(`/api/functions`、`/api/fmode/*`、`/api/apig/*`、未来新增) | ❌ 不带 | * * 这样**新增任何接口都默认不会被 CORS 拦**,除非它确实属于 Parse REST。 * `/api/*` 下的端点鉴权都在 body / query 里(`token` 字段),不依赖这些头。 */ const needsParseHeaders = /\/parse(\/|$)/.test(url); const incoming = (init.headers as Record) ?? {}; const headers: Record = needsParseHeaders ? { 'X-Parse-Application-Id': PARSE_APP_ID } : {}; for (const [k, v] of Object.entries(incoming)) { /* * 非 Parse REST:调用方误传的 `X-Parse-*` 也一律丢掉 —— * 宁可鉴权失败(有明确报错),也不要被 CORS 静默拦成 ERR_FAILED。 */ if (!needsParseHeaders && k.toLowerCase().startsWith('x-parse-')) continue; headers[k] = v; } /* * 🔴 有 body 的方法必须带 `Content-Type: application/json`。 * * 实测教训:`POST /api/apig/message` 若**不带该头**,服务端**读不到 body**, * 于是报「缺少参数company」—— 而 body 里其实带了 company。 * 这个误导性报错曾让我误判为「限流」,排查花了很久。 * * 故此处对带 body 的请求**自动补默认 Content-Type**(调用方显式指定则尊重其值)。 */ if (init.body != null && !headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } return fetch(url, { ...init, headers }); } } /** 登录公司常量(《API-契约》§1.2) */ const LOGIN_COMPANY = 'E4KpGvTEto'; /** 错误文案取值顺序:msg → mess → message */ function pickMessage(d: unknown): string | undefined { if (!d || typeof d !== 'object') return undefined; const o = d as Record; for (const k of ['msg', 'mess', 'message']) { const v = o[k]; if (typeof v === 'string' && v.trim()) return v; } return undefined; } /** * 发码错误 → 友好文案。 * * ## 🔴 为什么要做这一层(实测教训) * * 服务端在**限流**状态下会返回**误导性文案**,实测对照: * * | 请求 | 服务端返回 | * |---|---| * | 带 company(首次) | `200 {"code":1,"data":{"msg":"发送成功"}}` | * | 带 company(频繁) | `500 {"mess":"该号码发送验证码过于频繁"}` | * | **不带** company | `500 {"mess":"缺少参数company"}` | * * 即:我们的请求**带了** company 且首次能成功,但**短时间重复请求**时 * 服务端可能错报成「缺少参数company」,误导用户以为参数配错了。 * * 故此处把「缺参数」在**限流上下文**(HTTP 500 而非 400)下转成更可能的真相提示, * 并在文案中保留原始信息以便排查(不吞掉事实)。 */ function friendlySendCodeError(raw: string | undefined, status: number): string { const m = raw ?? ''; if (/频繁|过于频繁|too many|rate/i.test(m)) { return '发送过于频繁,请稍后再试(同一号码通常 60 秒内只能发一次)'; } // 实测:限流态下服务端会误报「缺少参数company」;保留原始信息供排查 if (/缺少参数\s*company/i.test(m)) { return '验证码发送失败:服务端返回「缺少参数company」。若已多次点击,通常是发送过于频繁,请稍后再试。(原始:' + m + ')'; } if (m) return m; return `验证码发送失败(HTTP ${status})`; } /** * `channels` 归一:兼容**数组形态**的非标准容器上报。 * * 🔴 实测(`agent-node-lj-test`):容器发的是 `["wecom"]`,而平台契约是**对象** * `{"matrix":true,"matrixConfig":{...},"wecom":true,...}`。 * 不归一 → `channels.matrix` 为 `undefined` → `matrixReady=false` → UI 显示「未接入」, * 但该容器 Matrix 通道其实**是通的**(实测双向消息可达)。 * * 数组语义:元素即**已点亮的通道名**,故 `["matrix","wecom"]` → 两者为 true。 * 空数组视为「未上报通道」而非「零通道」(返回 undefined,避免 UI 显示"未接入"误导)。 */ function normalizeChannels(v: unknown): ChannelConfig | undefined { const parsed = parseJsonLoose(v); if (parsed == null) return undefined; if (Array.isArray(parsed)) { const names = parsed.filter((x): x is string => typeof x === 'string'); if (names.length === 0) return undefined; return { matrix: names.includes('matrix'), wecom: names.includes('wecom'), }; } // 对象形态:契约标准,原样透传(含 matrixConfig 细节) return parsed as ChannelConfig; } /** * `host` 归一:兼容 `cpuLoad1`(裸负载)→ `cpuPercent`(百分比)。 * * 🔴 实测:容器发 `{"cpuLoad1":5.65,"cpuLoad5":5.66,"memPercent":51,...}`, * 缺 `cpuPercent` 键 → CPU 不显示(memPercent/diskPercent 因键名对上才正常)。 * * 换算口径 `cpuLoad1 / 核数 * 100`(实测 56 核实测 9.2 → 16.4 与容器自证一致)。 * ⚠️ 核数未知时**不猜**:宁可返回 undefined 让 UI 显示「未上报」,也不编一个假数字。 * 核数来源优先级:`cpuCores`(容器自报)→ 无。 */ function normalizeHost(v: unknown): Agent['host'] { const h = parseJsonLoose>(v); if (!h) return undefined; const num = (x: unknown): number | undefined => typeof x === 'number' && Number.isFinite(x) ? x : undefined; // 契约标准的 cpuPercent 优先,不被派生值覆盖 let cpuPercent = num(h['cpuPercent']); if (cpuPercent == null) { const load = num(h['cpuLoad1']); const cores = num(h['cpuCores']); if (load != null && cores != null && cores > 0) { cpuPercent = Math.min(100, Math.max(0, (load / cores) * 100)); } } const memPercent = num(h['memPercent']); const diskPercent = num(h['diskPercent']); // 三项指标全无 → 视为未上报(避免 UI 显示 0% 误导为"机器空闲") if (cpuPercent == null && memPercent == null && diskPercent == null) return undefined; return { cpuPercent, memPercent, diskPercent }; } /** * `heartbeatAt` 归一(三级回落)。 * * 🔴 实测:容器不发 `heartbeatAt`,心跳时间藏在 `host.probedAt` * (采集时刻);两者皆无时回落 `updatedAtClient.iso`(云函数写入时刻)。 * 不回落的后果:UI 判「心跳超时」→ 在线容器被显示成离线。 */ function normalizeHeartbeat(raw: Record, host: unknown): string | undefined { /* * 🔴 必须经 `tsOf` 归一,不能 `String()`。 * 云函数里 heartbeatAt 是 Parse Date → `{__type:'Date', iso:'…'}`, * `String()` 得到 `"[object Object]"` → 下游 `Date.parse()` 得 NaN → * 「心跳超时」判定永远为假(实测:状态永远显示「空闲」,从不报「状态未同步」)。 */ const isoOf = (v: unknown): string | undefined => { const t = tsOf(v); return Number.isFinite(t) ? new Date(t).toISOString() : undefined; }; const direct = isoOf(raw['heartbeatAt']); if (direct) return direct; const h = parseJsonLoose<{ probedAt?: string }>(host); if (h?.probedAt) return isoOf(h.probedAt); const uac = raw['updatedAtClient'] as { iso?: string } | undefined; if (uac?.iso) return isoOf(uac.iso); return undefined; } /** 云函数原始记录 → `Agent` 领域模型(映射只在此处发生) */ function mapAgent(raw: Record): Agent { const channels = normalizeChannels(raw['channels']); const balance = parseJsonLoose<{ remainCny?: number; remainRmb?: number; cny?: number; usedCny?: number; pageUrl?: string; source?: string; }>(raw['balance']); const host = normalizeHost(raw['host']); const heartbeatAt = normalizeHeartbeat(raw, raw['host']); const user = raw['user'] as Record | undefined; return { id: String(raw['agentId'] ?? raw['objectId'] ?? ''), name: String(raw['botName'] ?? raw['name'] ?? ''), avatar: raw['botAvatar'] ? String(raw['botAvatar']) : undefined, gender: (raw['gender'] as Agent['gender']) ?? 'unknown', status: (raw['status'] as AgentStatus) ?? 'offline', currentTask: raw['currentTask'] ? String(raw['currentTask']) : undefined, currentAction: raw['currentAction'] ? String(raw['currentAction']) : undefined, progress: typeof raw['progress'] === 'number' ? (raw['progress'] as number) : undefined, heartbeatAt, /* * 心跳距今秒数(2026-11-07):云函数直接给出,比从 heartbeatAt 推算更可靠 —— * 实测该字段有值而 heartbeatAt 为 undefined。供状态展示判定「其实已离线」。 */ heartbeatAgeSec: typeof raw['heartbeatAgeSec'] === 'number' ? (raw['heartbeatAgeSec'] as number) : undefined, updatedAt: raw['updatedAt'] ? String(raw['updatedAt']) : undefined, /* * 会话级状态(2026-11-09,方案 A)。 * * 容器上报的 `status` 是**整个容器一个值**,同一容器的多个会话共用, * 导致「会话 A 在跑」时会话 B 也显示正在执行(用户实测)。 * 容器把每个会话的状态写进 `extra.currentSessions`(`extra` 已在平台白名单里)。 * * 形状不对一律忽略 —— 宁可回落到全局 status,也不能因解析失败显示「未知」。 */ currentSessions: (() => { const ex = parseJsonLoose>(raw['extra']); const list = ex?.['currentSessions']; if (!Array.isArray(list)) return undefined; const out = list.flatMap((it) => { if (!it || typeof it !== 'object') return []; const s = it as Record; const key = typeof s['sessionKey'] === 'string' ? s['sessionKey'] : ''; if (!key) return []; const st = s['status'] === 'running' ? 'running' : 'idle'; /* * `userId`(2026-11-11):容器共享给多人时区分「谁的会话」。 * 形状不对就省略 —— 宁可退回 roomId 匹配,也不要拿脏值去比对。 */ const uid = typeof s['userId'] === 'string' && s['userId'].startsWith('@') ? s['userId'] : undefined; /* 会话级任务摘要(2026-11-11):形状不对就省略,顶栏退回通用文案 */ const task = typeof s['task'] === 'string' && s['task'].trim() ? s['task'].trim() : undefined; /* 最后更新时刻(2026-11-11):用于判断 running 是否已过期 */ const upd = typeof s['updatedAt'] === 'number' && Number.isFinite(s['updatedAt']) ? s['updatedAt'] : undefined; return [ { sessionKey: key, ...(uid ? { userId: uid } : {}), ...(task ? { task } : {}), ...(upd ? { updatedAt: upd } : {}), status: st as 'running' | 'idle', turnStartAt: typeof s['turnStartAt'] === 'number' && Number.isFinite(s['turnStartAt']) ? s['turnStartAt'] : undefined, }, ]; }); return out.length ? out : undefined; })(), host, balance: balance ? { // 映射层归一:兼容旧键 remainRmb,以及容器复刻脚本发的 cny remainCny: balance.remainCny ?? balance.remainRmb ?? balance.cny, usedCny: typeof balance.usedCny === 'number' ? balance.usedCny : undefined, pageUrl: balance.pageUrl, /* * `source: 'owner_token'` = 云函数用**主人账号的令牌**实时取的余额。 * 容器账单挂在主人账号上 → 同一主人的所有容器报同一个数,UI 要标注「账号余额」。 */ sharedAccount: balance.source === 'owner_token' ? true : undefined, } : undefined, channels: channels ? { matrix: channels.matrix, homeserver: channels.homeserver, matrixConfig: channels.matrixConfig, wecom: channels.wecom, wecomBotId: channels.wecomBotId, } : undefined, /** * 🔴 `usage` 此前**完全没有映射** —— 云函数返回了、映射层丢掉了, * 造成「Token 用量」永远空白(实测:库里 totalTokens=2238055,页面不显示)。 * * 键名兼容:容器发 `inputTokens`/`outputTokens`, * 契约旧字段是 `promptTokens`/`completionTokens`,两者都收。 */ usage: (() => { const u = parseJsonLoose>(raw['usage']); if (!u) return undefined; const num = (x: unknown): number | undefined => typeof x === 'number' && Number.isFinite(x) ? x : undefined; const usage = { totalTokens: num(u['totalTokens']), promptTokens: num(u['promptTokens']) ?? num(u['inputTokens']), completionTokens: num(u['completionTokens']) ?? num(u['outputTokens']), updatedAt: u['updatedAt'] ? String(u['updatedAt']) : undefined, raw: u, }; // 三项全无 → 视为未上报(避免 UI 显示 0) return usage.totalTokens == null && usage.promptTokens == null && usage.completionTokens == null ? undefined : usage; })(), owner: user ? { id: String(user['objectId'] ?? ''), name: String(user['nickname'] ?? user['username'] ?? ''), avatar: user['avatar'] ? String(user['avatar']) : undefined, mobile: user['mobile'] ? String(user['mobile']) : undefined, } : undefined, // 容器标识(排障反查来源用;云函数读的是 hostname/containerRef) containerRef: raw['containerRef'] ? String(raw['containerRef']) : undefined, // Agent token 健康灯:expired 时余额等依赖 token 的字段会降级 sessionStatus: (() => { const s = String(raw['sessionStatus'] ?? '').toLowerCase(); return s === 'ok' || s === 'expired' ? (s as 'ok' | 'expired') : 'unknown'; })(), // 会话级用量明细(可选,缺失不影响总量展示) usageSessions: (() => { const list = parseJsonLoose(raw['usageSessions']); if (!Array.isArray(list) || list.length === 0) return undefined; return list as UsageSession[]; })(), companyId: (raw['company'] as { objectId?: string } | undefined)?.objectId, // 「是否已接入 FMODE」= channels.matrix 为真(或已有 matrixConfig.userId) matrixReady: !!channels?.matrix || !!channels?.matrixConfig?.userId, // 未读数**不来自服务端**:云函数不返回该字段,真实计数由 // `UnreadService` 依据 Matrix 事件流在客户端维护(见该服务注释)。 // 此处恒为 0,UI 不得消费(消费点一律走 UnreadService.countOf)。 unread: 0, }; } /** * 从存储键里还原一个「像文件名」的展示名。 * * 实现已移到 `core/s3-link.ts`(消息卡片也要用,而 `chat.service` * 不能反向依赖本文件)。这里重新导出,保持既有调用点不变。 */ export { prettifyStorageKey }; /** 成果原始记录 → `Deliverable` 领域模型 */ function mapDeliverable(raw: Record): Deliverable { const status = String(raw['status'] ?? ''); /* * 🔴 `artifacts` 是**自由数组**,实测两种形态混用(同一张表里都有): * * ``` * ["/opt/data/git-repos/agent-status-reporter/REPORT.md"] ← 字符串数组(容器最常这样写) * [{ name: "report.pdf", url: "https://…", type: "pdf" }] ← 对象数组(契约标准) * ``` * * 此前只处理对象数组,字符串形态进来后 `name` 变成空串 → * 右栏渲染成「仅有 · 的文档」(用户实测反馈)。 * * 字符串形态的分辨口径: * · 以 `http` 开头 → 视作已是可访问地址 * · 其它(容器内路径) → **未同步**,由 `ArtifactSyncService` 交给容器上传到云盘 */ const rawArts = Array.isArray(raw['artifacts']) ? (raw['artifacts'] as unknown[]) : []; const artifacts: Artifact[] = rawArts .map((a): Artifact | null => { if (typeof a === 'string') { const s = a.trim(); if (!s) return null; const isUrl = /^https?:\/\//i.test(s); // 与对象形态同口径:优先从存储键还原展示名,不直接拿末段当名字 const name = prettifyStorageKey(s); return { name, url: isUrl ? s : '', type: name.split('.').pop() || '', // 非 URL 形态的原始值(容器内路径),保留给同步服务 source: isUrl ? undefined : s, } as Artifact; } if (a && typeof a === 'object') { const o = a as Record; const name = String(o['name'] ?? o['filename'] ?? ''); const rawUrl = String(o['url'] ?? ''); /* * 🔴 对象形态也要认 `source`(2026-11-07)。 * * 容器的 artifact 常写成 `{ name, url: '/opt/data/x.md' }`(容器内路径), * 原实现把整个 `url` 原样带出 → 前端拿它去 fetch 必然失败, * 且**无法入队搬运**(同步服务只认 `source`)。 * 现在与非 URL 字符串形态同口径:路径进 `source`,`url` 置空。 */ const isUrl = /^https?:\/\//i.test(rawUrl); const fallbackSource = String(o['source'] ?? ''); const url = isUrl ? rawUrl : ''; const source = isUrl ? undefined : rawUrl || fallbackSource || undefined; if (!name && !url && !source) return null; /* * 显示名与存储键**分离**(2026-11-10 用户裁决)。 * * 存储键由服务端生成(形如 `0-mwa2gx2gog-weekly-report.md` 或纯随机 ID), * 中文文件名进不了签名 URL,所以**不能**拿 url 末段当显示名。 * 优先用 `name` 字段;没有才回退,且回退时剥掉服务端加的前缀段。 */ const displayName = name || prettifyStorageKey(url) || prettifyStorageKey(source ?? '') || '未命名文件'; return { name: displayName, url, type: String(o['type'] ?? ''), ...(source ? { source } : {}), }; } return null; }) .filter((x): x is Artifact => x != null); const first = artifacts[0]; const filename = first?.name || undefined; const rawType = first?.type || filename?.split('.').pop() || ''; const fileType = rawType ? rawType.replace(/^\./, '').toUpperCase() : undefined; return { id: String(raw['objectId'] ?? raw['id'] ?? ''), agentId: String(raw['agentId'] ?? ''), title: String(raw['title'] ?? ''), summary: raw['summary'] ? String(raw['summary']) : undefined, projectId: raw['project'] ? String(raw['project']) : undefined, projectName: raw['projectName'] ? String(raw['projectName']) : undefined, artifacts, tags: Array.isArray(raw['tags']) ? (raw['tags'] as string[]).map(String) : [], deliveredAt: String(raw['deliveredAt'] ?? ''), filename, fileType, // 契约未返回文件大小 → 保持空(UI 不该显示 0 B) size: raw['size'] ? String(raw['size']) : undefined, // 契约 §2.2:status 取值为推断 —— 含 fail/error 子串即失败 state: /fail|error/i.test(status) ? 'failed' : 'delivered', }; }