/** * 领域模型 —— 前端唯一权威类型定义 * * 依据《数据访问层设计.md》§三。云函数返回的原始形状由 `ParseApi` 的映射函数 * 转成本文件定义的模型,页面只见模型,不见原始字段。 * * 🔴 禁止在页面/组件内另建同义类型;禁止把云函数的原始 JSON 形状泄漏到视图层。 */ // ---------- 通用 ---------- /** 加载状态三态 + 空闲 */ export type LoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'error'; // ---------- 数字生命 ---------- /** 数字生命运行状态(与云函数 xlosi3m46q 的 status 取值一致) */ export type AgentStatus = 'running' | 'idle' | 'error' | 'offline' | 'stopped'; export interface HostMetrics { cpuPercent?: number; memPercent?: number; diskPercent?: number; } export interface Balance { /** 服务端换算后的余额(元)。映射层负责归一:兼容旧键 remainRmb 后统一写入此处 */ remainCny?: number; /** 业务再算页面外链(FR-07 AC-07.4) */ pageUrl?: string; } export interface ChannelConfig { matrix?: boolean; homeserver?: string; matrixConfig?: { userId?: string; homeserver?: string; serverName?: string; /** * 容器侧上报的通道状态(实测数据里有,此前类型未声明)。 * `'connected'` = 容器 Matrix 通道在跑;缺失 = 未上报(可能离线)。 */ channelState?: string; /** listener 健康检查通过(旧字段,与 channelState 二选一为真即在线) */ listenerOk?: boolean; /** 更细的状态明细(gateway / matrix / wecom 各自状态) */ channelDetail?: { matrixState?: string; gatewayState?: string; wecomState?: string; codeVersion?: string; source?: string; }; }; /** 企业微信通道是否点亮(数组形态归一后得出) */ wecom?: boolean; /** 企微机器人 BotID(数组形态下仅知通道名、无此值,故可选) */ wecomBotId?: string; } /** * Token 用量。 * 🔴 映射口径:云函数返回的 `usage` / `usageSessions` 是**字符串形态的 JSON**, * 且字段名未与服务端逐字段核对(《API-契约》§六-4)。映射函数负责 * ① 兼容「已是对象 / 是 JSON 字符串」;② 归一字段名;③ 未识别字段保留在 raw。 */ export interface Usage { promptTokens?: number; completionTokens?: number; totalTokens?: number; updatedAt?: string; /** 原始未归一字段(排查用,UI 不直接消费) */ raw?: Record; } /** 单次会话的 token 用量(云函数 `usageSessions` 元素,契约见 reporter 字段规范 §3.4) */ export interface UsageSession { sessionId?: string; title?: string; model?: string; messageCount?: number; toolCallCount?: number; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; reasoningTokens?: number; estimatedCostUsd?: number; costStatus?: string; /** epoch 数字(服务端不转换) */ startedAt?: number; } /** * 容器上报的**单个会话**运行状态(2026-11-09,方案 A)。 * * 由容器在 `pre_llm_call` / `post_llm_call` 钩子里维护,写进 `extra.currentSessions`。 * `sessionKey` 是容器的 `build_session_key()` 产物,形如: * `agent:main:matrix:dm:[:]` * * 🔴 App 的会话 id 是 Matrix 的 **thread 根 event_id**,与这里的 `sessionKey` * 是否对得上**必须实测验证** —— 对不上就是「报了但匹配不到会话」。 */ export interface AgentSessionState { /** 容器的 session_key 原文 */ sessionKey: string; /** 该会话当前是否在跑 */ status: 'running' | 'idle'; /** 本轮开始的 epoch 毫秒(idle 时可缺省) */ turnStartAt?: number; } export interface Person { id: string; name: string; avatar?: string; mobile?: string; /** 同事行展开时用 */ agents?: Agent[]; /** * 所属公司 id(`Company.objectId`)。 * 通讯录按公司分组时使用;缺失时归入「未归属」。 */ companyId?: string; /** * 所属部门。 * * 🔴 **预留字段**(2026-11-04):服务端目前没有部门维度, * 该值恒为 undefined,通讯录会归入「未分组」。 * 待组织架构落地后由 `_User` / `Company` 表补充,前端不用改结构。 */ department?: string; } export interface Agent { id: string; name: string; avatar?: string; /** 专长描述(原型 specialty) */ specialty?: string; gender?: 'male' | 'female' | 'unknown'; status: AgentStatus; /** 状态中文文案(由 core/format 统一给出,不在组件里拼) */ statusText?: string; currentTask?: string; currentAction?: string; progress?: number; heartbeatAt?: string; /** * 心跳距今**秒数**(2026-11-07 新增)。 * * 云函数 `xlosi3m46q` 直接返回这个字段(实测它有值,而 `heartbeatAt` 为 undefined)。 * 容器掉线后**不会**把 `status` 改成 offline,只会停止心跳 —— * 实测两个容器 `heartbeatAgeSec = 3914610`(45 天)却仍报 `running`。 * 状态展示(`core/agent-state.ts`)用它判定「其实已离线」。 */ heartbeatAgeSec?: number; updatedAt?: string; host?: HostMetrics; balance?: Balance; channels?: ChannelConfig; usage?: Usage; /** * 会话级用量明细(云函数 `usageSessions`,JSON 字符串形态)。 * 用于「Token 用量」下钻,缺失时不影响总量展示。 */ usageSessions?: UsageSession[]; owner?: Person; companyId?: string; /** * 会话级运行状态(2026-11-09,方案 A)。 * * 🔴 为什么需要:容器上报的 `status` 是**整个容器一个值** —— * 同一容器的多个会话共用它,于是「会话 A 在跑」时,会话 B 的界面也显示 * 「正在执行」(用户实测)。 * * 容器把每个会话的状态塞进 `extra.currentSessions`(`extra` 是平台 * 云函数字段白名单里的合法字段,无需改云函数),这里解析出来供按会话展示。 * * 容器尚未上报时为 `undefined` —— 调用方必须回落到全局 `status`, * 不能因为缺这个字段就显示「未知」。 */ currentSessions?: AgentSessionState[]; /** 所属部门 —— **预留字段**(2026-11-04)。 * 服务端暂无此维度,恒为 undefined;通讯录归入「未分组」。 */ department?: string; /** 容器标识(云函数 `containerRef`,Docker 短 ID / 主机名,排障时用来反查来源) */ containerRef?: string; /** * Agent 自身 token 健康灯(云函数 `sessionStatus`)。 * `ok` / `expired` / `unknown` —— expired 时余额等依赖 token 的字段会降级。 */ sessionStatus?: 'ok' | 'expired' | 'unknown'; /** 是否已接入 FMODE(决定「进会话 vs 进详情」,见 AgentRouteService) */ matrixReady: boolean; /** 会话未读数 */ unread: number; } // ---------- 会话与消息 ---------- /** * 会话。 * * ## 两种形态(2026-10-21 新增群聊) * * | kind | 说明 | 用到哪些字段 | * |---|---|---| * | `dm` | 用户 ↔ 单个数字生命 | `agentId` / `peer` | * | `group` | 一个房间里有多个数字生命 | `members` / `name` | * * 🔴 群聊里**同一个房间里会有多个 agent 发言**,所以消息必须能标出「是谁说的」。 * 此前消息只分 `me` / `agent`,无法区分多个 agent(见 `Message.senderId`)。 */ export interface Conversation { /** Matrix roomId(演示态为合成 id) */ id: string; /** 会话形态;缺省视为 dm(兼容旧数据) */ kind?: 'dm' | 'group'; /** dm:对方的 agentId。群聊时为空 */ agentId?: string; /** dm:对方 */ peer?: Agent; /** group:群名称 */ name?: string; /** group:房间内的 agent 成员(不含自己) */ members?: Agent[]; lastMessagePreview?: string; lastMessageAt?: string; unread: number; } export type MessageKind = 'text' | 'notice' | 'emote'; export type MessageState = 'sending' | 'sent' | 'failed'; export interface FileCard { kind: 'file'; name: string; size: number; mimeType: string; url: string; thumb?: string; /** * 云盘(OBS/S3)公网地址(2026-11-09)。 * * 用户要求「之后查看都走云盘」:有这个地址就优先用它, * 没有则回落到 Matrix 的 mxc 转换地址。缺失说明当时 S3 上传失败。 */ cloudUrl?: string; } export interface Message { id: string; conversationId: string; /** * 发送方类别。 * 🔴 `agent` 不区分具体是哪个 agent —— 群聊里要结合 `senderId` / `senderName` 才能区分。 */ sender: 'me' | 'agent'; /** 群聊:具体发送者的 Matrix ID(dm 下可省略) */ senderId?: string; /** 群聊:展示用名字(agent 的 botName,或人类昵称) */ senderName?: string; /** 这条消息 @ 了哪些人(Matrix ID)。群聊 UI 用来高亮「在叫我 / 叫我同事」 */ mentions?: string[]; kind: MessageKind; text?: string; file?: FileCard; artifactId?: string; taskId?: string; ts: string; state: MessageState; /** 失败原因(AC-04.8:失败消息不丢失,可重试) */ error?: string; /** * 发送时写入 Matrix 事件的本地 id(`com.fmode.local`)。 * * 服务端 echo 会原样带回它 —— 回读时据此**认领**本地那条乐观消息, * 避免同一句话同时以「本地一条 + 回读一条」出现两遍(2026-11-09)。 * 容器 / 历史事件没有这个字段。 */ localId?: string; } // ---------- 成果与项目 ---------- export interface Artifact { name: string; url: string; type: string; /** * 容器内的原始路径(非 URL 时才有)。 * 该 artifact **尚未上传到云盘**,由 `ArtifactSyncService` 触发同步; * `url` 为空时 UI 不得提供下载(避免给一个打不开的链接)。 */ source?: string; } export type DeliverableState = 'delivered' | 'failed'; /** * 文件台账条目(云函数 fmode-file-hub,2026-11-10) * * 用户裁决:消息不入库,只有**文件与成果**入库,让右侧栏能按会话直接加载。 * 只存访问链接与元信息,不存文件内容。 */ export interface FileEntry { /** 会话归属(App 的会话 id = Matrix thread 根),核心字段 */ threadRoot: string; /** 产出的容器;用户上传的文件也要带,便于按容器聚合 */ agentId?: string; roomId?: string; name: string; /** 云盘访问链接(https,必须是 s3.fmode.cn / file-cloud.fmode.cn) */ url: string; /** 云盘对象键,url 只是它的投影 */ key?: string; mimeType?: string; size?: number; /** 谁放进来的:用户自己传的,还是容器产出的 */ uploadedBy: 'user' | 'agent'; /** 容器内的原始路径(仅容器产出时有) */ source?: string; } /** 从文件台账拉回的一条记录(服务端已归一化) */ export interface SessionFile { id: string; threadRoot: string; agentId: string; roomId: string; name: string; /** 未知大小用 -1(与云函数口径一致) */ size: number; mimeType: string; url: string; storageKey: string; uploadedBy: 'user' | 'agent'; status: string; /** epoch 毫秒 */ deliveredAt: number; } export interface Deliverable { id: string; agentId: string; title: string; subtitle?: string; summary?: string; projectId?: string; projectName?: string; artifacts: Artifact[]; tags: string[]; deliveredAt: string; state: DeliverableState; /** 成果正文(演示态用;真实态由 artifacts 外链承载) */ content?: string; /** 文件类型标签(DOC / CSV …) */ fileType?: string; filename?: string; size?: string; version?: string; } export interface DeliverableGroup { project?: string; projectName: string; count: number; items: Deliverable[]; } export interface Project { id: string; name: string; code?: string; description?: string; brief?: string; status?: string; owner?: string; due?: string; updated?: string; agentIds: string[]; deliverableIds: string[]; hasBlocked: boolean; } // ---------- 事件(派生,非独立表 —— SPEC FR-06 口径) ---------- export type EventCategory = 'delivery' | 'execution' | 'alert'; export interface EventItem { id: string; category: EventCategory; title: string; description?: string; ts: string; agentId: string; artifactId?: string; taskId?: string; } // ---------- 任务看板(FR-07b) ---------- export type TaskStatus = | 'triage' | 'todo' | 'scheduled' | 'ready' | 'running' | 'blocked' | 'done'; export interface TaskRun { id: string; status: string; outcome?: string; stepKey?: string; title?: string; detail?: string; summary?: string; error?: string; startedAt?: string; endedAt?: string; } export interface TaskComment { id: string; author?: string; body: string; createdAt: string; } export interface TaskAttachment { id: string; filename: string; size?: number; contentType?: string; /** 可选下载地址;真实接口未提供时保持空值,避免伪造下载能力。 */ url?: string; } export interface Task { id: string; agentId: string; projectId?: string; title: string; status: TaskStatus; assignee?: string; owner?: string; branchName?: string; body?: string; blockKind?: string; consecutiveFailures?: number; /** 已阻塞时长(小时,用于「已阻塞 436h」表达) */ blockedHours?: number; createdAt?: string; updatedAt?: string; startedAt?: string; completedAt?: string; runs: TaskRun[]; comments: TaskComment[]; attachments: TaskAttachment[]; } // ---------- 工作组(多容器协作) ---------- /** * 工作组 —— 一次任务的临时组合(员工团队)。 * * 与 `Conversation` 的区别:会话是「和谁聊」,工作组是「谁在一起干活」。 * 数据源是云函数 `fmode-workgroup`(服务端建房),不是 Matrix 直读。 */ export interface WorkGroup { id: string; name: string; goal?: string; roomId: string; /** 成员 agentId 列表 */ members: string[]; status?: string; } // ---------- 登录 ---------- export interface Me { id: string; name: string; mobile?: string; avatar?: string; companyId?: string; /** * 所属组织名(Company.name)。 * * 🔴 此前「我的」页显示硬编码的「企业空间」——是**假文案**。 * 实测 `Company` 表可拿到 `name`(如 `E4KpGvTEto → 飞码AI`),故改为真实数据。 * 取不到时为 `undefined`,UI 显示「—」而非编造。 */ companyName?: string; /** 仅用于决定请求哪个 scope 与界面提示;**不得**作为授权依据(FR-20 AC-20.2) */ roles?: string[]; } export interface AuthSession { token: string; me: Me; } // ---------- Matrix 开通与上传(字段与《API-契约》§2.3/§2.5 对齐) ---------- /** 对应云函数 RP5Ht55NeX 的返回 */ export interface MatrixProvision { cached?: boolean; bindingId?: string; status?: string; userMxId: string | null; agentMxId: string | null; homeserver: string; accessToken: string; deviceId: string; roomId: string | null; userCreated?: boolean; } /** * 成果文件搬运任务(表 `FmodeArtifactJob`)。 * * 由 **App(前端)** 创建、由 **容器** 领取执行: * * ``` * App: 发现成果附件只有容器路径 → 签好 PUT 地址 → 建任务(pending) * 容器: 轮询 pending → 读 source → 按 headers 原样 PUT 到 uploadUrl → 回执 done * App: 下一次刷新时,成果的 artifacts[i].url 已变成云盘地址 * ``` * * 🔴 `uploadUrl` 是**限时(5 分钟)单对象写入授权**,不是长期凭据; * 过期后由 App 重新签发,容器不需要也不应该保存它。 */ export interface ArtifactJob { id: string; deliverableId: string; agentId: string; /** 容器内文件路径 */ source: string; filename: string; mimeType: string; /** 🔴 以下三项由**云函数**签名后写入;客户端只读,不得自行构造 */ uploadUrl: string; publicUrl: string; headers: Record; /** epoch 秒(预签名有效期,默认 5 分钟) */ expiresAt: number; status?: 'pending' | 'done' | 'failed'; error?: string; } /** 对应云函数 AlP56LCKFm 的返回(取签名阶段) */ export interface UploadTicket { uploadUrl: string; key: string; publicUrl: string; headers?: Record; } // ---------- 整体配色(ADR-006 v2.0) ---------- export type PaletteId = 'platinum' | 'ice' | 'clay' | 'slate'; export interface PaletteOption { id: PaletteId; name: string; description: string; } // ---------- 数字生命 / 任务的业务文案与语义色映射(唯一实现) ---------- export const AGENT_STATUS_TEXT: Record = { running: '正在执行', idle: '空闲', error: '异常', offline: '离线', stopped: '已停止', }; /** * 任务状态中文文案(对应源库 tasks.status 七态)。 * 依据《设计令牌.md》v3.0 §5.1 与 statuses 表。 */ export const TASK_STATUS_TEXT: Record = { triage: '待分诊', todo: '待办', scheduled: '已排期', ready: '就绪', running: '执行中', blocked: '已阻塞', done: '已完成', }; /** 任务状态筛选顺序(UI 用) */ export const TASK_STATUS_ORDER: TaskStatus[] = [ 'triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'done', ]; /** * 语义色断言(《设计令牌.md》v3.0 §5.1)。 * 🔴 阻塞 = danger(用户裁决 2026-10-14);运行中 = info(不用品牌色,避免与 accent 撞色)。 */ export type SemanticTone = 'success' | 'danger' | 'warning' | 'info' | 'neutral'; export function agentStatusTone(status: AgentStatus): SemanticTone { switch (status) { case 'running': return 'success'; case 'idle': return 'warning'; case 'error': case 'offline': case 'stopped': return 'danger'; default: return 'neutral'; } } export function taskStatusTone(status: TaskStatus): SemanticTone { switch (status) { case 'running': return 'info'; case 'blocked': return 'danger'; case 'done': return 'success'; default: return 'neutral'; } } // ---------- 数字生命「在线可对话」判定(唯一实现) ---------- /** * 数字生命的**可对话在线状态**。 * * ## 🔴 为什么需要这一层(实测教训) * * 项目里存在**两套语义不同的状态**,此前被混用,导致同一容器在 * 「通讯录」与「会话列表」显示不一致(用户实测反馈): * * | 维度 | 字段 | 含义 | 实测问题 | * |---|---|---|---| * | 运行态 | `Agent.status` | reporter 上报的容器运行状态 | **是历史值**:芯葶 `heartbeatAt` 停在 2026-09-23(近一月前)却仍为 `online` | * | 接入态 | `channels.matrixConfig.channelState` / `listenerOk` | 容器 Matrix 通道是否在跑 | 决定**能否真的对话** | * * 用户能感知的是「**能不能对话**」→ 属**接入态**。 * 故本判定**以接入态为准**,运行态只作辅助(在工作台/详情展示)。 * * ## 口径 * * ``` * online 接入态 connected / listenerOk 为真 → 可真对话 * unreported 有绑定但通道状态未上报 → 能连 Matrix,对方可能不在 * offline 无绑定(matrixReady 为假) → 不可对话 * ``` */ export type AgentPresence = 'online' | 'unreported' | 'offline'; /** 判定可对话状态(**全项目唯一实现**,禁止各页另算) */ export function agentPresence(a: Agent | undefined | null): AgentPresence { if (!a || !a.matrixReady) return 'offline'; const cfg = a.channels?.matrixConfig; const state = cfg?.channelState ?? cfg?.channelDetail?.matrixState; if (state === 'connected' || cfg?.listenerOk) return 'online'; // 有绑定但容器未上报通道状态 return 'unreported'; } /** 可对话状态 → 中文文案(UI 展示,配颜色使用) */ export const AGENT_PRESENCE_TEXT: Record = { online: '在线', unreported: '未上报', offline: '未接入', }; /** 可对话状态 → 语义色(复用单轨映射) */ export function agentPresenceTone(p: AgentPresence): SemanticTone { switch (p) { case 'online': return 'success'; case 'unreported': return 'warning'; default: return 'neutral'; } } /** * 云端工作区记录(表 `FmodeWorkspace`,云函数 `fmodeagent-chatthreads` 的 wsList)。 * * 2026-11-01 上云:工作区此前只存 localStorage,换设备后文件夹全丢, * 而会话的 workspaceId 还在 —— 归属无处安放,只能落进「孤儿」区。 */ export interface WorkspaceRecord { /** 客户端生成的工作区 id(`ws__`),跨设备稳定 */ id: string; name: string; /** 毫秒时间戳;用于稳定排序 */ createdAt: number; } /** 工作区 upsert 载荷 */ export interface WorkspaceUpsert { id: string; name: string; createdAt: number; } /** * 云端会话目录记录(表 `FmodeChatThread`,云函数 `fmodeagent-chatthreads`)。 * * 2026-10-31 引入:会话索引此前只存在本地 localStorage,换浏览器/清缓存即丢失。 * 云端记录是**唯一真相源**,本地只作缓存。 * * 🔴 归属由服务端按 sessionToken 强制确定,客户端不传也不信任任何 user 字段。 */ export interface ChatThreadRecord { /** 云端记录 id */ objectId: string; agentId: string; roomId: string; /** 线程根 event_id(App 内部称 sessionId) */ threadRoot: string; title: string; /** 用户手动改过标题 → upsert 不覆盖 */ titleLocked: boolean; messageCount: number; /** 毫秒时间戳 */ lastMessageAt: number; archived: boolean; lastPreview: string; /** 归属工作区 id(`''` / 缺省 = 日常对话);换设备后仍能还原层级 */ workspaceId?: string; /** 会话形态(2026-11-05):缺省 = dm */ kind?: 'dm' | 'group'; /** 群会话成员容器 id 列表 */ memberIds?: string[]; /** * 本会话关联的成果链接(云盘可访问地址)。 * * 🔴 2026-11-09 用户裁决:成果按会话存放 —— 每条会话在库里有一条记录, * 记录里放该会话成果的在线链接数组,查询时读出来即可。 * 这样不依赖容器上报 threadRoot,「这条对话的成果」由数据本身决定。 */ deliverableUrls?: string[]; } /** upsert 提交项(不含服务端生成字段) */ export interface ChatThreadUpsert { agentId: string; roomId: string; threadRoot: string; title?: string; messageCount?: number; /** 毫秒时间戳 */ lastMessageAt?: number; lastPreview?: string; /** * 归属的工作区 id(`null` / 缺省 = 日常对话)。 * * 🔴 必须上云(2026-11-01):此前只存本地 localStorage,导致两个问题: * ① 每次云端重建列表时归属靠本地缓存「捡」,捡不到就掉回日常对话; * ② 换浏览器/清缓存后归属永久丢失,用户以为「工作区里的对话没了」。 */ workspaceId?: string | null; /** 会话形态(2026-11-05):缺省 = dm,兼容存量 */ kind?: 'dm' | 'group'; /** 群会话成员容器 id 列表;单聊为空 */ memberIds?: string[]; /** * 本会话关联的成果链接(云盘可访问地址)。 * * 🔴 2026-11-09 用户裁决:成果按会话存放 —— 每条会话在库里有一条记录, * 记录里放该会话成果的在线链接数组,查询时读出来即可。 * 这样不依赖容器上报 threadRoot,「这条对话的成果」由数据本身决定。 */ deliverableUrls?: string[]; /** 重新出现时撤销软删 */ revive?: boolean; }