import { Injectable } from '@angular/core'; import { environment } from '../../../environments/environment'; import { FmodeApi } from './fmode-api'; import { DEMO_AGENTS, DEMO_DELIVERABLES, DEMO_ME, DEMO_TASKS, } from './demo-data'; import { Agent, ArtifactJob, AuthSession, ChatThreadRecord, ChatThreadUpsert, Deliverable, DeliverableGroup, FileEntry, MatrixProvision, Me, SessionFile, OrgMembership, OrgUser, OrgUserPage, Task, UploadTicket, WorkGroup, WorkspaceRecord, WorkspaceUpsert, } from './models'; /** * 演示数据源实现(`environment.useMockData === true` 时启用) * * 行为约定: * · 模拟网络延迟(`environment.mockLatency`)→ 让加载态真实可见 * · 支持失败注入(`environment.mockFailureRate`)→ 让错误态与重试真实可测 * · **不伪造成功**:任务看板按 SPEC FR-07b 的依赖声明, * 演示数据来自 data.js(用户授权的演示场景),并已在 UI 上标注「演示数据」 * * 🔴 本文件仅在演示模式下被实例化;真实模式走 ParseApi(当前为显式抛错的占位)。 */ @Injectable({ providedIn: 'root' }) export class MockApi extends FmodeApi { // ---------- 鉴权 ---------- async sendMobileCode(mobile: string): Promise { await this.delay(); if (!/^1\d{10}$/.test(mobile)) { throw new Error('手机号格式不正确'); } // 演示:不发送短信 } async loginWithMobile(mobile: string, code: string): Promise { await this.delay(); if (!/^1\d{10}$/.test(mobile)) throw new Error('手机号格式不正确'); if (code === '000000') throw new Error('验证码已过期,请重新获取'); if (code !== '123456') throw new Error('验证码错误,请检查后重试'); return { token: 'demo-fmode', me: DEMO_ME }; } async loginWithToken(token: string): Promise { await this.delay(); if (!token) throw new Error('Token 不能为空'); if (token !== 'demo-fmode') throw new Error('Token 无效或已失效'); return { token, me: DEMO_ME }; } async fetchMe(token: string): Promise { await this.delay(); if (!token) throw new Error('未登录'); return DEMO_ME; } // ---------- 数字生命 ---------- async fetchAgents(): Promise { await this.delay(); return DEMO_AGENTS.map((a) => ({ ...a })); } async fetchAgentStatus(agentId: string): Promise { await this.delay(); const found = DEMO_AGENTS.find((a) => a.id === agentId); return found ? { ...found } : null; } /** * 重命名容器(演示态)。 * * 演示数据是静态常量,这里只**模拟成功返回**、不改 DEMO_AGENTS —— * 刷新即恢复原名,与真实环境「写回服务器」的行为明确区分, * 避免演示时误以为改了真实数据。 */ async renameAgent(agentId: string, displayName: string): Promise { await this.delay(); const name = displayName.trim(); if (!name) throw new Error('名称不能为空'); const found = DEMO_AGENTS.find((a) => a.id === agentId); if (!found) throw new Error('容器不存在'); return { ...found, name }; } // ---------- Matrix ---------- async provisionMatrix(agentId: string): Promise { await this.delay(); const agent = DEMO_AGENTS.find((a) => a.id === agentId); if (!agent || !agent.matrixReady) { throw new Error('该数字生命尚未接入 FMODE'); } return { cached: true, bindingId: `demo-binding-${agentId}`, status: 'ok', userMxId: `@demo-user:fmode.cn`, agentMxId: `@${agentId}:fmode.cn`, homeserver: 'https://matrix.fmode.cn', accessToken: 'demo-access-token', deviceId: 'DEMODEVICE', roomId: `!demo-${agentId}:fmode.cn`, }; } async validateMatrix(agentId: string): Promise { return this.provisionMatrix(agentId); } // ---------- 交付成果 ---------- async fetchDeliverables(opts?: { agentId?: string; limit?: number }): Promise { await this.delay(); let items = DEMO_DELIVERABLES.map((d) => ({ ...d })); if (opts?.agentId) items = items.filter((d) => d.agentId === opts.agentId); items.sort((a, b) => b.deliveredAt.localeCompare(a.deliveredAt)); if (opts?.limit) items = items.slice(0, opts.limit); return items; } async fetchDeliverablesByProject(limit?: number): Promise { const all = await this.fetchDeliverables(); const map = new Map(); for (const d of all) { const key = d.projectId ?? '__none__'; if (!map.has(key)) { map.set(key, { project: d.projectId, projectName: d.projectName ?? '未归类', count: 0, items: [] }); } const g = map.get(key)!; g.items.push(d); g.count += 1; } let groups = [...map.values()]; groups.sort((a, b) => b.items[0].deliveredAt.localeCompare(a.items[0].deliveredAt)); if (limit) groups = groups.slice(0, limit); return groups; } // ---------- 文件台账(演示态:内存存储,行为与云端一致) ---------- /** 演示态文件台账:会话 id → 文件列表 */ private readonly mockFiles = new Map(); private mockFileSeq = 1; async registerFile(entry: FileEntry): Promise<{ id: string; duplicated?: boolean }> { const list = this.mockFiles.get(entry.threadRoot) ?? []; const hit = list.find((f) => f.url === entry.url); if (hit) return { id: hit.id, duplicated: true }; const id = `mock-file-${this.mockFileSeq++}`; list.unshift({ id, threadRoot: entry.threadRoot, agentId: entry.agentId ?? '', roomId: entry.roomId ?? '', name: entry.name, size: entry.size ?? -1, mimeType: entry.mimeType ?? '', url: entry.url, storageKey: entry.key ?? '', uploadedBy: entry.uploadedBy, status: 'delivered', deliveredAt: Date.now(), }); this.mockFiles.set(entry.threadRoot, list); return { id }; } async fetchFilesBySession(threadRoot: string, limit = 100): Promise { return (this.mockFiles.get(threadRoot) ?? []).slice(0, limit); } /** 演示态按群拉(2026-11-11):与 threadRoot 同口径 */ async fetchFilesByGroup(groupId: string, limit = 200): Promise { return (this.mockFiles.get(groupId) ?? []).slice(0, limit); } /** 演示态不需要补归并(没有 DM thread 与群的两套归属) */ async claimFilesToGroup(): Promise<{ claimed: number }> { return { claimed: 0 }; } /** * 演示态读文件正文(2026-11-11)。 * * 演示环境的文件是本地 blob,本来就能直读;这里保持接口一致, * 避免预览组件在演示态下走「服务未部署」的降级路径。 */ async readText(url: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); } // ---------- 会话目录(演示态:内存存储,行为与云端一致) ---------- /** * 演示态的会话目录。 * * 2026-11-02:改为 **localStorage 持久化**,模拟「云端目录」。 * · 原实现是进程内存,整页刷新即清空 —— 用户建了会话一刷新就消失, * 与线上(云端持久)行为不一致,本地调试会被误判成缺陷。 * · 键名带 `mock/` 前缀,与业务缓存(`fmode_*`)区分,清理时不会误删。 */ private static readonly THREADS_KEY = 'mock/chat-threads'; private _threads: Map | null = null; private get mockThreads(): Map { if (this._threads) return this._threads; this._threads = new Map(); try { const raw = localStorage.getItem(MockApi.THREADS_KEY); const arr = raw ? (JSON.parse(raw) as ChatThreadRecord[]) : []; for (const t of arr) this._threads.set(`${t.roomId}|${t.threadRoot}`, t); } catch { /* 坏数据 / 隐私模式:从空目录开始 */ } return this._threads; } private persistThreads(): void { try { localStorage.setItem(MockApi.THREADS_KEY, JSON.stringify([...this.mockThreads.values()])); } catch { /* 隐私模式:退化为内存态 */ } } async fetchChatThreads(agentId: string, _opts?: { includeDeleted?: boolean }): Promise { await this.delay(); return [...this.mockThreads.values()] .filter((t) => t.agentId === agentId) .sort((a, b) => b.lastMessageAt - a.lastMessageAt) .map((t) => ({ ...t })); } async upsertChatThreads(items: ChatThreadUpsert[]): Promise { await this.delay(); for (const it of items) { const key = `${it.roomId}|${it.threadRoot}`; const cur = this.mockThreads.get(key); this.mockThreads.set(key, { objectId: cur?.objectId ?? `mock_${key}`, agentId: it.agentId, roomId: it.roomId, threadRoot: it.threadRoot, title: cur?.titleLocked ? cur.title : it.title || cur?.title || '', titleLocked: cur?.titleLocked ?? false, messageCount: it.messageCount ?? cur?.messageCount ?? 0, lastMessageAt: it.lastMessageAt ?? cur?.lastMessageAt ?? Date.now(), archived: cur?.archived ?? false, lastPreview: it.lastPreview ?? cur?.lastPreview ?? '', }); } this.persistThreads(); } async patchChatThread(input: { threadRoot: string; title?: string; archived?: boolean; workspaceId?: string; }): Promise { await this.delay(); for (const [key, t] of this.mockThreads) { if (t.threadRoot !== input.threadRoot) continue; this.mockThreads.set(key, { ...t, title: input.title ?? t.title, titleLocked: input.title != null ? true : t.titleLocked, archived: input.archived ?? t.archived, workspaceId: input.workspaceId ?? t.workspaceId, }); } this.persistThreads(); } async removeChatThread(input: { threadRoot: string; roomId?: string; agentId?: string }): Promise { await this.delay(); for (const [key, t] of this.mockThreads) { if (t.threadRoot === input.threadRoot) this.mockThreads.delete(key); } this.persistThreads(); } // ---------- 工作区(2026-11-01 上云) ---------- private readonly mockWorkspaces = new Map(); async fetchWorkspaces(): Promise { await this.delay(); return [...this.mockWorkspaces.values()].sort((a, b) => a.createdAt - b.createdAt); } async upsertWorkspaces(items: WorkspaceUpsert[]): Promise { await this.delay(); for (const it of items) this.mockWorkspaces.set(it.id, { ...it }); } async removeWorkspace(id: string): Promise { await this.delay(); this.mockWorkspaces.delete(id); } // ---------- 任务看板(FR-07b)---------- async fetchTasks(agentId: string): Promise { await this.delay(); return DEMO_TASKS.filter((t) => t.agentId === agentId) .map((t) => ({ ...t })) .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); } async fetchTaskDetail(taskId: string): Promise { await this.delay(); const found = DEMO_TASKS.find((t) => t.id === taskId); return found ? { ...found } : null; } // ---------- 上传 ---------- async requestUploadUrl(filename: string, mimeType: string, size: number): Promise { await this.delay(); if (!filename) throw new Error('缺少文件名'); const key = `demo/${Date.now()}-${filename}`; return { uploadUrl: `#demo-upload`, key, publicUrl: `https://s3.fmode.cn/${key}`, headers: { 'Content-Type': mimeType, 'x-obs-acl': 'public-read' }, }; } async uploadFile( file: File, onProgress?: (pct: number) => void, ): Promise<{ key: string; publicUrl: string }> { const ticket = await this.requestUploadUrl(file.name, file.type, file.size); // 演示:不真正上传,仅模拟进度(README 载明) for (let pct = 0; pct <= 100; pct += 20) { onProgress?.(pct); await new Promise((r) => setTimeout(r, 120)); } return { key: ticket.key, publicUrl: ticket.publicUrl }; } async enqueueArtifactJob(_job: { deliverableId: string; agentId: string; source: string; filename: string; index: number; }): Promise<{ id: string }> { return { id: `mock-job-${Date.now()}` }; } async fetchArtifactJobs(_agentId: string): Promise { return []; } async ackArtifactJob(_input: { id: string; ok: boolean; publicUrl?: string; error?: string; }): Promise { // 演示态无副作用 } // ---------- 工作组(演示态内存实现) ---------- private readonly mockGroups: WorkGroup[] = []; async createWorkGroup(input: { name: string; goal?: string; memberAgentIds: string[]; }): Promise<{ id: string; roomId: string }> { const id = `wg-${Date.now()}`; const roomId = `!demo-${id}:fmode.cn`; this.mockGroups.push({ id, name: input.name, goal: input.goal, roomId, members: input.memberAgentIds, status: 'active', }); return { id, roomId }; } async listWorkGroups(): Promise { return [...this.mockGroups]; } async inviteToWorkGroup(groupId: string, agentIds: string[]): Promise { const g = this.mockGroups.find((x) => x.id === groupId); if (g) g.members = [...g.members, ...agentIds]; } async removeFromWorkGroup(groupId: string, agentId: string): Promise { const g = this.mockGroups.find((x) => x.id === groupId); if (g) g.members = g.members.filter((m) => m !== agentId); } async archiveWorkGroup(groupId: string): Promise { const g = this.mockGroups.find((x) => x.id === groupId); if (g) g.status = 'archived'; } // ---------- 组织成员管理(2026-11-10,演示态内存实现) ---------- /** 演示数据:有组织成员管理的入口才看得到真实交互 */ private readonly mockOrgUsers: OrgUser[] = [ { id: 'demo-user-01', username: 'zhangsan', nickname: '张三', mobile: '13800000001', isDeleted: false, createdAt: Date.now() - 86400_000 * 30 }, { id: 'demo-user-02', username: 'lisi', nickname: '李四', mobile: '13800000002', isDeleted: false, createdAt: Date.now() - 86400_000 * 20 }, { id: 'demo-user-03', username: 'wangwu', nickname: '王五', mobile: '13800000003', isDeleted: false, createdAt: Date.now() - 86400_000 * 10 }, ]; async listOrgUsers(query: { keyword?: string; page?: number; pageSize?: number; }): Promise { const keyword = (query.keyword ?? '').trim().toLowerCase(); const page = Math.max(1, query.page ?? 1); const pageSize = Math.max(1, query.pageSize ?? 20); const all = this.mockOrgUsers.filter( (u) => !u.isDeleted && (!keyword || u.nickname.toLowerCase().includes(keyword) || u.mobile.includes(keyword) || u.id.toLowerCase().includes(keyword)), ); const start = (page - 1) * pageSize; return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize }; } async addOrgUser(userId: string): Promise { const existed = this.mockOrgUsers.find((u) => u.id === userId); if (existed) { existed.isDeleted = false; return existed; } const created: OrgUser = { id: userId, username: userId, nickname: '新成员', mobile: '', isDeleted: false, createdAt: Date.now(), }; this.mockOrgUsers.push(created); return created; } async updateOrgUser( userId: string, patch: { nickname?: string; mobile?: string; password?: string }, ): Promise { const u = this.mockOrgUsers.find((x) => x.id === userId); if (!u) throw new Error('用户不存在'); if (patch.nickname != null) u.nickname = patch.nickname; if (patch.mobile != null) u.mobile = patch.mobile; return { ...u }; } async removeOrgUser(userId: string): Promise { const u = this.mockOrgUsers.find((x) => x.id === userId); if (u) u.isDeleted = true; } /** 演示态:当前用户视为已加入组织 */ async fetchOrgMembership(): Promise { return { userId: 'demo-me', hasOrg: true, companyId: 'demo-company', companyName: '演示组织' }; } // ---------- 内部 ---------- private delay(): Promise { this.maybeFail(); const ms = environment.mockLatency; return ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve(); } private maybeFail(): void { const rate = environment.mockFailureRate; if (rate > 0 && Math.random() < rate) { throw new Error('演示模式:模拟网络请求失败'); } } }