From ecd1a95012695ddc64b6d4a2de004ab567040982 Mon Sep 17 00:00:00 2001 From: zhenghaotao Date: Fri, 24 Jul 2026 15:14:04 +0800 Subject: [PATCH 1/3] feat(api)!: align frontend contracts with latest wiki Replace legacy HTTP and WebSocket contracts with the latest TimeFlow MVP interface baseline. Add strict runtime validators, typed domain modules, mock data, and contract tests. BREAKING CHANGE: frontend API paths, DTO fields, and WebSocket event envelopes now follow the 2026-07-24 Wiki contract. --- frontend/.env.example | 1 + frontend/.gitignore | 1 + frontend/.prettierignore | 1 + frontend/eslint.config.js | 2 +- frontend/package.json | 3 +- frontend/src/api/client.ts | 23 +- frontend/src/api/contracts/auth.ts | 28 + frontend/src/api/contracts/common.ts | 24 + frontend/src/api/contracts/conversation.ts | 243 ++++++ frontend/src/api/contracts/goal.ts | 45 ++ frontend/src/api/contracts/item.ts | 32 + frontend/src/api/contracts/profile.ts | 12 + frontend/src/api/contracts/validators.ts | 757 ++++++++++++++++++ frontend/src/api/contracts/writeRequest.ts | 39 + frontend/src/api/core/ApiError.ts | 26 + frontend/src/api/core/ConversationSocket.ts | 137 ++++ frontend/src/api/core/http.ts | 204 +++++ frontend/src/api/core/validation.ts | 164 ++++ frontend/src/api/index.ts | 18 + frontend/src/api/modules/authApi.ts | 41 + frontend/src/api/modules/conversationApi.ts | 85 ++ frontend/src/api/modules/goalApi.ts | 38 + frontend/src/api/modules/itemApi.ts | 24 + frontend/src/api/modules/profileApi.ts | 24 + frontend/src/api/modules/writeRequestApi.ts | 46 ++ frontend/tests/api/conversationSocket.test.ts | 159 ++++ frontend/tests/api/http.test.ts | 115 +++ frontend/tests/api/mockData.ts | 162 ++++ frontend/tests/api/modules.test.ts | 190 +++++ frontend/tests/api/testUtils.ts | 42 + frontend/tests/api/validation.test.ts | 168 ++++ frontend/tests/tsconfig.json | 13 + frontend/tsconfig.json | 3 +- 33 files changed, 2858 insertions(+), 12 deletions(-) create mode 100644 frontend/src/api/contracts/auth.ts create mode 100644 frontend/src/api/contracts/common.ts create mode 100644 frontend/src/api/contracts/conversation.ts create mode 100644 frontend/src/api/contracts/goal.ts create mode 100644 frontend/src/api/contracts/item.ts create mode 100644 frontend/src/api/contracts/profile.ts create mode 100644 frontend/src/api/contracts/validators.ts create mode 100644 frontend/src/api/contracts/writeRequest.ts create mode 100644 frontend/src/api/core/ApiError.ts create mode 100644 frontend/src/api/core/ConversationSocket.ts create mode 100644 frontend/src/api/core/http.ts create mode 100644 frontend/src/api/core/validation.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/modules/authApi.ts create mode 100644 frontend/src/api/modules/conversationApi.ts create mode 100644 frontend/src/api/modules/goalApi.ts create mode 100644 frontend/src/api/modules/itemApi.ts create mode 100644 frontend/src/api/modules/profileApi.ts create mode 100644 frontend/src/api/modules/writeRequestApi.ts create mode 100644 frontend/tests/api/conversationSocket.test.ts create mode 100644 frontend/tests/api/http.test.ts create mode 100644 frontend/tests/api/mockData.ts create mode 100644 frontend/tests/api/modules.test.ts create mode 100644 frontend/tests/api/testUtils.ts create mode 100644 frontend/tests/api/validation.test.ts create mode 100644 frontend/tests/tsconfig.json diff --git a/frontend/.env.example b/frontend/.env.example index 877160b..448555e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,3 @@ # Android emulator: 10.0.2.2 reaches the host machine. EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1 +EXPO_PUBLIC_WS_URL=ws://10.0.2.2:8000/ws/v1/conversation diff --git a/frontend/.gitignore b/frontend/.gitignore index bd701e1..2257214 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -37,6 +37,7 @@ yarn-error.* # typescript *.tsbuildinfo +.contract-test-dist/ # generated native folders /ios diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 7a3927b..ab82450 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,4 +1,5 @@ node_modules/ +.contract-test-dist/ .expo/ dist/ web-build/ diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f041633..0d8f3a5 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -8,7 +8,7 @@ module.exports = defineConfig([ expoConfig, prettierConfig, { - ignores: ['dist/**', '.expo/**', 'web-build/**', 'node_modules/**'], + ignores: ['dist/**', '.expo/**', '.contract-test-dist/**', 'web-build/**', 'node_modules/**'], }, { rules: { diff --git a/frontend/package.json b/frontend/package.json index 1b2211c..1e1c5d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit", - "check": "npm run lint && npm run format:check && npm run typecheck" + "test:api": "tsc -p tests/tsconfig.json && node --test .contract-test-dist/tests/api/*.test.js", + "check": "npm run lint && npm run format:check && npm run typecheck && npm run test:api" }, "private": true } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1a2a935..c747a85 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,13 +1,18 @@ -const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://127.0.0.1:8000/api/v1'; +import type { HttpRequestOptions } from './core/http'; +import { API_BASE_URL, httpClient } from './core/http'; +import type { ContractParser } from './core/validation'; -export async function apiFetch(path: string, init?: RequestInit): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, init); - - if (!response.ok) { - throw new Error(`API request failed with status ${response.status}`); - } - - return (await response.json()) as T; +/** + * 兼容旧调用方式的统一请求入口;新代码应优先使用按业务划分的 API 模块。 + * + * @deprecated Prefer the domain modules exported from `src/api`. + */ +export function apiFetch( + path: string, + options?: HttpRequestOptions, + parseData?: ContractParser, +): Promise { + return httpClient.request(path, options, parseData); } export { API_BASE_URL }; diff --git a/frontend/src/api/contracts/auth.ts b/frontend/src/api/contracts/auth.ts new file mode 100644 index 0000000..5454c7f --- /dev/null +++ b/frontend/src/api/contracts/auth.ts @@ -0,0 +1,28 @@ +import type { UUID } from './common'; + +/** 创建 TimeFlow 用户账号。最新版接口不再要求前端提交 timezone。 */ +export interface RegisterRequest { + email: string; + password: string; + display_name: string; +} + +/** 使用邮箱和密码建立登录态。 */ +export interface LoginRequest { + email: string; + password: string; +} + +/** 注册或登录成功后返回的最新版认证结果。 */ +export interface AuthSession { + user_id: UUID; + business_user_id: string; + access_token: string; +} + +/** 页面初始化时使用的当前用户最小展示信息。 */ +export interface CurrentUser { + user_id: UUID; + business_user_id: string; + display_name: string; +} diff --git a/frontend/src/api/contracts/common.ts b/frontend/src/api/contracts/common.ts new file mode 100644 index 0000000..50bd1fc --- /dev/null +++ b/frontend/src/api/contracts/common.ts @@ -0,0 +1,24 @@ +export type UUID = string; +export type ISODate = string; +export type ISODateTime = string; +export type RequestId = string; + +export type JsonPrimitive = boolean | number | string | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +export type JsonObject = { [key: string]: JsonValue }; + +/** + * 前端本地统一错误结构。 + * + * 最新 Wiki 只冻结了部分 WebSocket 错误载荷,尚未定义 HTTP 错误响应信封; + * 因此该类型只用于 SDK 抛错,不表示服务端固定响应格式。 + */ +export interface ApiErrorDetail { + code: string; + message: string; + stage: string; + retryable: boolean; +} + +export type QueryValue = boolean | number | string | null | undefined; +export type QueryParams = Record; diff --git a/frontend/src/api/contracts/conversation.ts b/frontend/src/api/contracts/conversation.ts new file mode 100644 index 0000000..28ca171 --- /dev/null +++ b/frontend/src/api/contracts/conversation.ts @@ -0,0 +1,243 @@ +import type { ISODateTime, JsonObject, JsonValue, UUID } from './common'; +import type { WriteDecision, WriteRequestStatus } from './writeRequest'; + +export type InputModality = 'text' | 'image' | 'audio'; +export type ConversationRole = 'user' | 'assistant' | 'system' | 'tool'; +export type AgentName = + | 'schedule_todo_agent' + | 'feedback_agent' + | 'replan_agent' + | 'review_agent' + | 'long_task_split_agent'; +export type MessageKind = + | 'input' + | 'clarification_question' + | 'clarification_answer' + | 'decision' + | 'execution_processing' + | 'selection_required' + | 'selection_submit' + | 'selection_cancel' + | 'execution_result' + | 'confirmation_request' + | 'confirmation_decision' + | 'system_notice' + | 'error'; + +/** 母 AI 解析或推导出的时间范围。 */ +export interface TimeRange { + start_at: ISODateTime; + end_at: ISODateTime; + source: 'user_explicit' | 'agent_inferred' | 'system_default'; +} + +/** 对话消息 metadata 的最新版结构。 */ +export interface ConversationMessageMetadata { + schema_version: number; + message_kind: MessageKind; + client_message_id?: string | null; + trace_id?: string | null; + question_id?: UUID | null; + question_status?: 'open' | 'answered' | 'expired' | null; + question_type?: 'normal' | 'long_task_split' | 'disambiguation' | 'confirmation' | null; + expires_at?: ISODateTime | null; + reply_to_question_id?: UUID | null; + target_agent_name?: AgentName | null; + target_function_name?: string | null; + missing_fields?: string[]; + parsed_params?: JsonObject; + time_range?: TimeRange | null; + write_request_id?: UUID | null; + decision?: WriteDecision | null; +} + +/** 聊天历史、快照和 WS 恢复共同使用的消息 DTO。 */ +export interface ConversationMessage { + id: UUID; + user_id: UUID; + message_index: number; + role: ConversationRole; + modality: InputModality | null; + raw_content: string; + source_url: string | null; + metadata: ConversationMessageMetadata; + created_at: ISODateTime; +} + +/** 文本输入使用 JSON 提交,raw_content 必填。 */ +export interface TextConversationInputRequest { + client_message_id: string; + modality: 'text'; + raw_content: string; + reply_to_question_id?: string; +} + +/** 图片或音频输入使用 multipart/form-data 提交。 */ +export interface MediaConversationInputRequest { + client_message_id: string; + modality: 'image' | 'audio'; + file: Blob; + file_name: string; + reply_to_question_id?: string; +} + +/** 统一输入被 HTTP 接收后的结果;后续处理结果通过 WS 推送。 */ +export interface ConversationInputAccepted { + source_message_id: UUID; + client_message_id: string; + source_url?: string | null; + status: 'accepted'; +} + +/** 向前翻页查询对话历史。 */ +export interface ConversationMessagesRequest { + user_id: UUID; + before_message_id?: UUID; + limit?: number; +} + +/** 会话历史分页结果。最新版不再返回 next_cursor。 */ +export interface ConversationMessagesResult { + messages: ConversationMessage[]; + has_more: boolean; +} + +/** 页面首次打开或刷新时恢复持久化可见状态。 */ +export interface ConversationSnapshotRequest { + user_id: UUID; + last_seen_message_id?: UUID; +} + +/** 反问问题中的单个选项;Wiki 只约束为字符串键值对象。 */ +export type ClarificationOption = Record; + +/** 母 AI 请求用户补充的一个问题。 */ +export interface ClarificationQuestion { + question_id: string; + field_name: string; + question_text: string; + options: ClarificationOption[]; +} + +/** 页面或 WS 恢复时可重新展示的待回答反问。 */ +export interface ClarificationRequest { + reason: + | 'missing_required_params' + | 'invalid_answer' + | 'long_task_split_questions' + | 'ambiguous_reference'; + mode: 'single' | 'batch'; + questions: ClarificationQuestion[]; + original_agent_name: AgentName | null; + original_function_name: string | null; +} + +/** 页面初始化使用的静态会话快照。 */ +export interface ConversationSnapshot { + recent_messages: ConversationMessage[]; + pending_clarification: ClarificationRequest | null; + last_event: JsonObject | null; +} + +/** 最新版 WebSocket 统一信封:全部关联字段都必须位于 payload。 */ +export interface WsEnvelope { + type: TType; + timestamp: ISODateTime; + payload: TPayload; +} + +export interface DialogueClarificationPayload { + source_message_id: UUID; + questions: ClarificationQuestion[]; + reason: ClarificationRequest['reason']; + mode: ClarificationRequest['mode']; +} + +export interface DialogueDecisionPayload { + source_message_id: UUID; + target_agent_name: AgentName; + target_function_name: string; + parsed_params: JsonObject; + time_range: TimeRange; +} + +export interface ExecutionProcessingPayload { + source_message_id: UUID; + stage: string; +} + +export interface SelectionRequiredPayload { + interaction_id: string; + candidate_items: JsonObject[]; + question_text: string; +} + +export interface WriteConfirmationRequiredPayload { + write_request_id: UUID; + confirmation_payload: JsonObject; +} + +export interface WriteAppliedPayload { + write_request_id: UUID; + status: WriteRequestStatus; + applied_target_id?: UUID | null; +} + +export interface ExecutionResultPayload { + source_message_id: UUID; + result: JsonValue; +} + +export interface ConversationErrorPayload { + source_message_id?: UUID | null; + error_code: string; + message: string; +} + +export interface TransportSnapshotPayload { + recent_messages: ConversationMessage[]; + pending_clarification: ClarificationRequest | null; + last_event: JsonObject | null; +} + +/** 服务端可推送给前端的全部最新版 WS 事件。 */ +export type ConversationServerEvent = + | WsEnvelope<'dialogue.clarification', DialogueClarificationPayload> + | WsEnvelope<'dialogue.decision', DialogueDecisionPayload> + | WsEnvelope<'execution.processing', ExecutionProcessingPayload> + | WsEnvelope<'selection.required', SelectionRequiredPayload> + | WsEnvelope<'write.confirmation_required', WriteConfirmationRequiredPayload> + | WsEnvelope<'write.applied', WriteAppliedPayload> + | WsEnvelope<'execution.result', ExecutionResultPayload> + | WsEnvelope<'dialogue.error', ConversationErrorPayload> + | WsEnvelope<'execution.error', ConversationErrorPayload> + | WsEnvelope<'transport.ping', Record> + | WsEnvelope<'transport.pong', Record> + | WsEnvelope<'transport.snapshot', TransportSnapshotPayload>; + +/** 用户提交候选选择。 */ +export type SelectionSubmitEvent = WsEnvelope< + 'selection.submit', + { interaction_id: string; selected_candidate_ids: string[] } +>; +/** 用户取消当前候选选择。 */ +export type SelectionCancelEvent = WsEnvelope<'selection.cancel', { interaction_id: string }>; +/** 用户确认或拒绝待写入请求。 */ +export type WriteDecideEvent = WsEnvelope< + 'write.decide', + { write_request_id: UUID; decision: WriteDecision; idempotency_key: string } +>; +/** WebSocket 心跳控制事件。 */ +export type TransportPingEvent = WsEnvelope<'transport.ping', Record>; +export type TransportPongEvent = WsEnvelope<'transport.pong', Record>; +/** 断线重连后请求补发遗漏的流式事件。 */ +export type TransportResumeEvent = WsEnvelope<'transport.resume', { last_seen_message_id: UUID }>; + +/** 前端可发送给服务端的全部最新版 WS 控制事件。 */ +export type ConversationClientEvent = + | SelectionSubmitEvent + | SelectionCancelEvent + | WriteDecideEvent + | TransportPingEvent + | TransportPongEvent + | TransportResumeEvent; diff --git a/frontend/src/api/contracts/goal.ts b/frontend/src/api/contracts/goal.ts new file mode 100644 index 0000000..379a9c8 --- /dev/null +++ b/frontend/src/api/contracts/goal.ts @@ -0,0 +1,45 @@ +import type { ISODateTime, JsonObject, UUID } from './common'; +import type { TaskItem } from './item'; + +export type LongGoalStatus = 'active' | 'completed' | 'cancelled'; + +/** 长任务目标管理 Tab 使用的长目标结构。 */ +export interface LongGoal { + id: UUID; + user_id: UUID; + title: string; + description?: string | null; + plan_overview?: string | null; + start_at?: ISODateTime | null; + deadline_at?: ISODateTime | null; + status: LongGoalStatus; + version: number; +} + +/** 按用户和可选状态查询长目标列表。 */ +export interface LongGoalListRequest { + user_id: UUID; + status?: LongGoalStatus; +} + +/** `GET /long-goals` 的完整响应。 */ +export interface LongGoalListResult { + goals: LongGoal[]; +} + +/** 查询单个长目标详情时使用的身份参数。 */ +export interface LongGoalDetailRequest { + user_id: UUID; +} + +/** + * `GET /long-goals/{goal_id}` 的完整响应。 + * + * 最新 Wiki 只命名了 `task_profile`,未冻结 UserProfileDTO 的内部字段, + * 因此该字段保持 JSON 对象,不把 Mock 内容升级成正式契约。 + */ +export interface LongGoalDetailResult { + goal: LongGoal; + subtasks: TaskItem[]; + task_profile: JsonObject | null; +} diff --git a/frontend/src/api/contracts/item.ts b/frontend/src/api/contracts/item.ts new file mode 100644 index 0000000..ed11ae7 --- /dev/null +++ b/frontend/src/api/contracts/item.ts @@ -0,0 +1,32 @@ +import type { ISODateTime, UUID } from './common'; + +export type ItemType = 'schedule' | 'todo' | 'subtask'; +export type ItemStatus = 'planned' | 'in_progress' | 'completed' | 'cancelled' | 'deferred'; + +/** 时间顺序 Tab 展示的日程、待办或长期目标子任务。 */ +export interface TaskItem { + id: UUID; + user_id: UUID; + long_goal_id?: UUID | null; + item_type: ItemType; + title: string; + description?: string | null; + start_at?: ISODateTime | null; + end_at?: ISODateTime | null; + due_at?: ISODateTime | null; + status: ItemStatus; + version: number; +} + +/** 查询时间顺序事项;时间范围由前端显式传入。 */ +export interface TimelineItemsRequest { + user_id: UUID; + start_at: ISODateTime; + end_at: ISODateTime; + item_type?: ItemType; +} + +/** `GET /items/timeline` 的完整响应。 */ +export interface TimelineItemsResult { + items: TaskItem[]; +} diff --git a/frontend/src/api/contracts/profile.ts b/frontend/src/api/contracts/profile.ts new file mode 100644 index 0000000..7ef90b1 --- /dev/null +++ b/frontend/src/api/contracts/profile.ts @@ -0,0 +1,12 @@ +import type { JsonObject, UUID } from './common'; + +/** 查询当前用户全局画像时显式携带用户 ID。 */ +export interface UserProfileRequest { + user_id: UUID; +} + +/** + * 最新 Wiki 引用了 UserProfileDTO,但尚未声明字段表。 + * 当前只保证响应是 JSON 对象,避免把测试样例误当成正式结构。 + */ +export type UserProfile = JsonObject; diff --git a/frontend/src/api/contracts/validators.ts b/frontend/src/api/contracts/validators.ts new file mode 100644 index 0000000..1f39e42 --- /dev/null +++ b/frontend/src/api/contracts/validators.ts @@ -0,0 +1,757 @@ +import type { AuthSession, CurrentUser } from './auth'; +import type { + AgentName, + ClarificationQuestion, + ClarificationRequest, + ConversationClientEvent, + ConversationErrorPayload, + ConversationInputAccepted, + ConversationMessage, + ConversationMessageMetadata, + ConversationMessagesResult, + ConversationServerEvent, + ConversationSnapshot, + TimeRange, + TransportSnapshotPayload, +} from './conversation'; +import type { LongGoal, LongGoalDetailResult, LongGoalListResult } from './goal'; +import type { TaskItem, TimelineItemsResult } from './item'; +import type { UserProfile } from './profile'; +import type { CreateWriteRequestResult, WriteDecisionResult } from './writeRequest'; +import { + ContractValidationError, + parseArray, + parseBoolean, + parseEnum, + parseInteger, + parseIsoDateTime, + parseJsonObject, + parseJsonValue, + parseNonEmptyString, + parseNullable, + parseObject, + parseString, + parseUuid, +} from '../core/validation'; + +/** + * 运行时校验以 2026-07-24 Wiki 的 + * `Architecture interface design(draft)` 最新提交为唯一基线。 + * + * 文档已冻结的 DTO 使用严格字段白名单;`UserProfileDTO`、 + * `confirmation_payload` 等未展开结构只校验为 JSON 对象。 + */ +const INPUT_MODALITIES = ['text', 'image', 'audio'] as const; +const ROLES = ['user', 'assistant', 'system', 'tool'] as const; +const AGENT_NAMES = [ + 'schedule_todo_agent', + 'feedback_agent', + 'replan_agent', + 'review_agent', + 'long_task_split_agent', +] as const; +const MESSAGE_KINDS = [ + 'input', + 'clarification_question', + 'clarification_answer', + 'decision', + 'execution_processing', + 'selection_required', + 'selection_submit', + 'selection_cancel', + 'execution_result', + 'confirmation_request', + 'confirmation_decision', + 'system_notice', + 'error', +] as const; +const QUESTION_STATUSES = ['open', 'answered', 'expired'] as const; +const QUESTION_TYPES = ['normal', 'long_task_split', 'disambiguation', 'confirmation'] as const; +const TIME_RANGE_SOURCES = ['user_explicit', 'agent_inferred', 'system_default'] as const; +const ITEM_TYPES = ['schedule', 'todo', 'subtask'] as const; +const ITEM_STATUSES = ['planned', 'in_progress', 'completed', 'cancelled', 'deferred'] as const; +const LONG_GOAL_STATUSES = ['active', 'completed', 'cancelled'] as const; +const CLARIFICATION_REASONS = [ + 'missing_required_params', + 'invalid_answer', + 'long_task_split_questions', + 'ambiguous_reference', +] as const; +const CLARIFICATION_MODES = ['single', 'batch'] as const; +const WRITE_DECISIONS = ['confirmed', 'rejected'] as const; +const WRITE_REQUEST_STATUSES = ['confirmed', 'rejected', 'applied', 'expired'] as const; +const CLIENT_EVENT_TYPES = [ + 'selection.submit', + 'selection.cancel', + 'write.decide', + 'transport.ping', + 'transport.pong', + 'transport.resume', +] as const; +const SERVER_EVENT_TYPES = [ + 'dialogue.clarification', + 'dialogue.decision', + 'execution.processing', + 'selection.required', + 'write.confirmation_required', + 'write.applied', + 'execution.result', + 'dialogue.error', + 'execution.error', + 'transport.ping', + 'transport.pong', + 'transport.snapshot', +] as const; + +function parseOptional( + value: unknown, + path: string, + parser: (value: unknown, path: string) => T, +): T | undefined { + return value === undefined ? undefined : parser(value, path); +} + +function parseOptionalNullable( + value: unknown, + path: string, + parser: (value: unknown, path: string) => T, +): T | null | undefined { + return value === undefined ? undefined : parseNullable(value, path, parser); +} + +function parseStringArray(value: unknown, path: string): string[] { + return parseArray(value, path, parseString); +} + +function parseEmptyObject(value: unknown, path: string): Record { + parseObject(value, path, []); + return {}; +} + +function stripUndefined(value: T): T { + for (const key of Object.keys(value) as (keyof T)[]) { + if (value[key] === undefined) { + delete value[key]; + } + } + return value; +} + +function parseTimeRange(value: unknown, path: string): TimeRange { + const object = parseObject(value, path, ['start_at', 'end_at', 'source']); + return { + start_at: parseIsoDateTime(object.start_at, `${path}.start_at`), + end_at: parseIsoDateTime(object.end_at, `${path}.end_at`), + source: parseEnum(object.source, `${path}.source`, TIME_RANGE_SOURCES), + }; +} + +function parseClarificationOption(value: unknown, path: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ContractValidationError(path, '必须是字符串键值对象'); + } + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + result[key] = parseString(item, `${path}.${key}`); + } + return result; +} + +function parseClarificationQuestion(value: unknown, path: string): ClarificationQuestion { + const object = parseObject(value, path, [ + 'question_id', + 'field_name', + 'question_text', + 'options', + ]); + return { + question_id: parseNonEmptyString(object.question_id, `${path}.question_id`), + field_name: parseNonEmptyString(object.field_name, `${path}.field_name`), + question_text: parseNonEmptyString(object.question_text, `${path}.question_text`), + options: parseArray(object.options, `${path}.options`, parseClarificationOption), + }; +} + +function parseClarificationRequest(value: unknown, path: string): ClarificationRequest { + const object = parseObject(value, path, [ + 'reason', + 'mode', + 'questions', + 'original_agent_name', + 'original_function_name', + ]); + return { + reason: parseEnum(object.reason, `${path}.reason`, CLARIFICATION_REASONS), + mode: parseEnum(object.mode, `${path}.mode`, CLARIFICATION_MODES), + questions: parseArray(object.questions, `${path}.questions`, parseClarificationQuestion), + original_agent_name: parseNullable( + object.original_agent_name, + `${path}.original_agent_name`, + (item, itemPath) => parseEnum(item, itemPath, AGENT_NAMES), + ), + original_function_name: parseNullable( + object.original_function_name, + `${path}.original_function_name`, + parseString, + ), + }; +} + +function parseConversationMessageMetadata( + value: unknown, + path: string, +): ConversationMessageMetadata { + const optionalKeys = [ + 'client_message_id', + 'trace_id', + 'question_id', + 'question_status', + 'question_type', + 'expires_at', + 'reply_to_question_id', + 'target_agent_name', + 'target_function_name', + 'missing_fields', + 'parsed_params', + 'time_range', + 'write_request_id', + 'decision', + ] as const; + const object = parseObject(value, path, ['schema_version', 'message_kind'], optionalKeys); + const result: ConversationMessageMetadata = { + schema_version: parseInteger(object.schema_version, `${path}.schema_version`), + message_kind: parseEnum(object.message_kind, `${path}.message_kind`, MESSAGE_KINDS), + client_message_id: parseOptionalNullable( + object.client_message_id, + `${path}.client_message_id`, + parseString, + ), + trace_id: parseOptionalNullable(object.trace_id, `${path}.trace_id`, parseString), + question_id: parseOptionalNullable(object.question_id, `${path}.question_id`, parseUuid), + question_status: parseOptionalNullable( + object.question_status, + `${path}.question_status`, + (item, itemPath) => parseEnum(item, itemPath, QUESTION_STATUSES), + ), + question_type: parseOptionalNullable( + object.question_type, + `${path}.question_type`, + (item, itemPath) => parseEnum(item, itemPath, QUESTION_TYPES), + ), + expires_at: parseOptionalNullable(object.expires_at, `${path}.expires_at`, parseIsoDateTime), + reply_to_question_id: parseOptionalNullable( + object.reply_to_question_id, + `${path}.reply_to_question_id`, + parseUuid, + ), + target_agent_name: parseOptionalNullable( + object.target_agent_name, + `${path}.target_agent_name`, + (item, itemPath) => parseEnum(item, itemPath, AGENT_NAMES), + ), + target_function_name: parseOptionalNullable( + object.target_function_name, + `${path}.target_function_name`, + parseString, + ), + missing_fields: parseOptional( + object.missing_fields, + `${path}.missing_fields`, + parseStringArray, + ), + parsed_params: parseOptional(object.parsed_params, `${path}.parsed_params`, parseJsonObject), + time_range: parseOptionalNullable(object.time_range, `${path}.time_range`, parseTimeRange), + write_request_id: parseOptionalNullable( + object.write_request_id, + `${path}.write_request_id`, + parseUuid, + ), + decision: parseOptionalNullable(object.decision, `${path}.decision`, (item, itemPath) => + parseEnum(item, itemPath, WRITE_DECISIONS), + ), + }; + // 保持服务端原始字段集合:可选字段未返回时,不在解析结果中补 undefined。 + for (const key of Object.keys(result) as (keyof ConversationMessageMetadata)[]) { + if (result[key] === undefined) { + delete result[key]; + } + } + return result; +} + +export function parseAuthSession(value: unknown, path = 'response'): AuthSession { + const object = parseObject(value, path, ['user_id', 'business_user_id', 'access_token']); + return { + user_id: parseUuid(object.user_id, `${path}.user_id`), + business_user_id: parseNonEmptyString(object.business_user_id, `${path}.business_user_id`), + access_token: parseNonEmptyString(object.access_token, `${path}.access_token`), + }; +} + +export function parseCurrentUser(value: unknown, path = 'response'): CurrentUser { + const object = parseObject(value, path, ['user_id', 'business_user_id', 'display_name']); + return { + user_id: parseUuid(object.user_id, `${path}.user_id`), + business_user_id: parseNonEmptyString(object.business_user_id, `${path}.business_user_id`), + display_name: parseString(object.display_name, `${path}.display_name`), + }; +} + +export function parseConversationInputAccepted( + value: unknown, + path = 'response', +): ConversationInputAccepted { + const object = parseObject( + value, + path, + ['source_message_id', 'client_message_id', 'status'], + ['source_url'], + ); + return stripUndefined({ + source_message_id: parseUuid(object.source_message_id, `${path}.source_message_id`), + client_message_id: parseNonEmptyString(object.client_message_id, `${path}.client_message_id`), + source_url: parseOptionalNullable(object.source_url, `${path}.source_url`, parseString), + status: parseEnum(object.status, `${path}.status`, ['accepted'] as const), + }); +} + +export function parseConversationMessage( + value: unknown, + path = 'response.messages[]', +): ConversationMessage { + const object = parseObject(value, path, [ + 'id', + 'user_id', + 'message_index', + 'role', + 'modality', + 'raw_content', + 'source_url', + 'metadata', + 'created_at', + ]); + return { + id: parseUuid(object.id, `${path}.id`), + user_id: parseUuid(object.user_id, `${path}.user_id`), + message_index: parseInteger(object.message_index, `${path}.message_index`), + role: parseEnum(object.role, `${path}.role`, ROLES), + modality: parseNullable(object.modality, `${path}.modality`, (item, itemPath) => + parseEnum(item, itemPath, INPUT_MODALITIES), + ), + raw_content: parseString(object.raw_content, `${path}.raw_content`), + source_url: parseNullable(object.source_url, `${path}.source_url`, parseString), + metadata: parseConversationMessageMetadata(object.metadata, `${path}.metadata`), + created_at: parseIsoDateTime(object.created_at, `${path}.created_at`), + }; +} + +export function parseConversationMessagesResult( + value: unknown, + path = 'response', +): ConversationMessagesResult { + const object = parseObject(value, path, ['messages', 'has_more']); + return { + messages: parseArray(object.messages, `${path}.messages`, parseConversationMessage), + has_more: parseBoolean(object.has_more, `${path}.has_more`), + }; +} + +export function parseConversationSnapshot(value: unknown, path = 'response'): ConversationSnapshot { + const object = parseObject(value, path, [ + 'recent_messages', + 'pending_clarification', + 'last_event', + ]); + return { + recent_messages: parseArray( + object.recent_messages, + `${path}.recent_messages`, + parseConversationMessage, + ), + pending_clarification: parseNullable( + object.pending_clarification, + `${path}.pending_clarification`, + parseClarificationRequest, + ), + last_event: parseNullable(object.last_event, `${path}.last_event`, parseJsonObject), + }; +} + +export function parseTaskItem(value: unknown, path = 'response.items[]'): TaskItem { + const object = parseObject( + value, + path, + ['id', 'user_id', 'item_type', 'title', 'status', 'version'], + ['long_goal_id', 'description', 'start_at', 'end_at', 'due_at'], + ); + return stripUndefined({ + id: parseUuid(object.id, `${path}.id`), + user_id: parseUuid(object.user_id, `${path}.user_id`), + long_goal_id: parseOptionalNullable(object.long_goal_id, `${path}.long_goal_id`, parseUuid), + item_type: parseEnum(object.item_type, `${path}.item_type`, ITEM_TYPES), + title: parseNonEmptyString(object.title, `${path}.title`), + description: parseOptionalNullable(object.description, `${path}.description`, parseString), + start_at: parseOptionalNullable(object.start_at, `${path}.start_at`, parseIsoDateTime), + end_at: parseOptionalNullable(object.end_at, `${path}.end_at`, parseIsoDateTime), + due_at: parseOptionalNullable(object.due_at, `${path}.due_at`, parseIsoDateTime), + status: parseEnum(object.status, `${path}.status`, ITEM_STATUSES), + version: parseInteger(object.version, `${path}.version`), + }); +} + +export function parseTimelineItemsResult(value: unknown, path = 'response'): TimelineItemsResult { + const object = parseObject(value, path, ['items']); + return { + items: parseArray(object.items, `${path}.items`, parseTaskItem), + }; +} + +export function parseLongGoal(value: unknown, path = 'response.goals[]'): LongGoal { + const object = parseObject( + value, + path, + ['id', 'user_id', 'title', 'status', 'version'], + ['description', 'plan_overview', 'start_at', 'deadline_at'], + ); + return stripUndefined({ + id: parseUuid(object.id, `${path}.id`), + user_id: parseUuid(object.user_id, `${path}.user_id`), + title: parseNonEmptyString(object.title, `${path}.title`), + description: parseOptionalNullable(object.description, `${path}.description`, parseString), + plan_overview: parseOptionalNullable( + object.plan_overview, + `${path}.plan_overview`, + parseString, + ), + start_at: parseOptionalNullable(object.start_at, `${path}.start_at`, parseIsoDateTime), + deadline_at: parseOptionalNullable(object.deadline_at, `${path}.deadline_at`, parseIsoDateTime), + status: parseEnum(object.status, `${path}.status`, LONG_GOAL_STATUSES), + version: parseInteger(object.version, `${path}.version`), + }); +} + +export function parseLongGoalListResult(value: unknown, path = 'response'): LongGoalListResult { + const object = parseObject(value, path, ['goals']); + return { + goals: parseArray(object.goals, `${path}.goals`, parseLongGoal), + }; +} + +export function parseLongGoalDetailResult(value: unknown, path = 'response'): LongGoalDetailResult { + const object = parseObject(value, path, ['goal', 'subtasks', 'task_profile']); + return { + goal: parseLongGoal(object.goal, `${path}.goal`), + subtasks: parseArray(object.subtasks, `${path}.subtasks`, parseTaskItem), + task_profile: parseNullable(object.task_profile, `${path}.task_profile`, parseJsonObject), + }; +} + +export function parseUserProfile(value: unknown, path = 'response'): UserProfile { + return parseJsonObject(value, path); +} + +export function parseCreateWriteRequestResult( + value: unknown, + path = 'response', +): CreateWriteRequestResult { + const object = parseObject(value, path, [ + 'write_request_id', + 'payload_hash', + 'confirmation_payload', + ]); + return { + write_request_id: parseUuid(object.write_request_id, `${path}.write_request_id`), + payload_hash: parseNonEmptyString(object.payload_hash, `${path}.payload_hash`), + confirmation_payload: parseJsonObject( + object.confirmation_payload, + `${path}.confirmation_payload`, + ), + }; +} + +export function parseWriteDecisionResult(value: unknown, path = 'response'): WriteDecisionResult { + const object = parseObject( + value, + path, + ['write_request_id', 'status'], + ['applied_target_id', 'message'], + ); + return stripUndefined({ + write_request_id: parseUuid(object.write_request_id, `${path}.write_request_id`), + status: parseEnum(object.status, `${path}.status`, WRITE_REQUEST_STATUSES), + applied_target_id: parseOptionalNullable( + object.applied_target_id, + `${path}.applied_target_id`, + parseUuid, + ), + message: parseOptionalNullable(object.message, `${path}.message`, parseString), + }); +} + +function parseDialogueError(value: unknown, path: string): ConversationErrorPayload { + const object = parseObject(value, path, ['error_code', 'message'], ['source_message_id']); + return { + source_message_id: parseOptionalNullable( + object.source_message_id, + `${path}.source_message_id`, + parseUuid, + ), + error_code: parseNonEmptyString(object.error_code, `${path}.error_code`), + message: parseNonEmptyString(object.message, `${path}.message`), + }; +} + +function parseTransportSnapshot(value: unknown, path: string): TransportSnapshotPayload { + return parseConversationSnapshot(value, path); +} + +export function parseConversationServerEvent( + value: unknown, + path = 'event', +): ConversationServerEvent { + const envelope = parseObject(value, path, ['type', 'timestamp', 'payload']); + const type = parseEnum(envelope.type, `${path}.type`, SERVER_EVENT_TYPES); + const timestamp = parseIsoDateTime(envelope.timestamp, `${path}.timestamp`); + const payloadPath = `${path}.payload`; + + switch (type) { + case 'dialogue.clarification': { + const payload = parseObject(envelope.payload, payloadPath, [ + 'source_message_id', + 'questions', + 'reason', + 'mode', + ]); + return { + type, + timestamp, + payload: { + source_message_id: parseUuid( + payload.source_message_id, + `${payloadPath}.source_message_id`, + ), + questions: parseArray( + payload.questions, + `${payloadPath}.questions`, + parseClarificationQuestion, + ), + reason: parseEnum(payload.reason, `${payloadPath}.reason`, CLARIFICATION_REASONS), + mode: parseEnum(payload.mode, `${payloadPath}.mode`, CLARIFICATION_MODES), + }, + }; + } + case 'dialogue.decision': { + const payload = parseObject(envelope.payload, payloadPath, [ + 'source_message_id', + 'target_agent_name', + 'target_function_name', + 'parsed_params', + 'time_range', + ]); + return { + type, + timestamp, + payload: { + source_message_id: parseUuid( + payload.source_message_id, + `${payloadPath}.source_message_id`, + ), + target_agent_name: parseEnum( + payload.target_agent_name, + `${payloadPath}.target_agent_name`, + AGENT_NAMES, + ) as AgentName, + target_function_name: parseNonEmptyString( + payload.target_function_name, + `${payloadPath}.target_function_name`, + ), + parsed_params: parseJsonObject(payload.parsed_params, `${payloadPath}.parsed_params`), + time_range: parseTimeRange(payload.time_range, `${payloadPath}.time_range`), + }, + }; + } + case 'execution.processing': { + const payload = parseObject(envelope.payload, payloadPath, ['source_message_id', 'stage']); + return { + type, + timestamp, + payload: { + source_message_id: parseUuid( + payload.source_message_id, + `${payloadPath}.source_message_id`, + ), + stage: parseNonEmptyString(payload.stage, `${payloadPath}.stage`), + }, + }; + } + case 'selection.required': { + const payload = parseObject(envelope.payload, payloadPath, [ + 'interaction_id', + 'candidate_items', + 'question_text', + ]); + return { + type, + timestamp, + payload: { + interaction_id: parseNonEmptyString( + payload.interaction_id, + `${payloadPath}.interaction_id`, + ), + candidate_items: parseArray( + payload.candidate_items, + `${payloadPath}.candidate_items`, + parseJsonObject, + ), + question_text: parseNonEmptyString(payload.question_text, `${payloadPath}.question_text`), + }, + }; + } + case 'write.confirmation_required': { + const payload = parseObject(envelope.payload, payloadPath, [ + 'write_request_id', + 'confirmation_payload', + ]); + return { + type, + timestamp, + payload: { + write_request_id: parseUuid(payload.write_request_id, `${payloadPath}.write_request_id`), + confirmation_payload: parseJsonObject( + payload.confirmation_payload, + `${payloadPath}.confirmation_payload`, + ), + }, + }; + } + case 'write.applied': { + const payload = parseObject( + envelope.payload, + payloadPath, + ['write_request_id', 'status'], + ['applied_target_id'], + ); + return { + type, + timestamp, + payload: { + write_request_id: parseUuid(payload.write_request_id, `${payloadPath}.write_request_id`), + status: parseEnum(payload.status, `${payloadPath}.status`, WRITE_REQUEST_STATUSES), + applied_target_id: parseOptionalNullable( + payload.applied_target_id, + `${payloadPath}.applied_target_id`, + parseUuid, + ), + }, + }; + } + case 'execution.result': { + const payload = parseObject(envelope.payload, payloadPath, ['source_message_id', 'result']); + return { + type, + timestamp, + payload: { + source_message_id: parseUuid( + payload.source_message_id, + `${payloadPath}.source_message_id`, + ), + result: parseJsonValue(payload.result, `${payloadPath}.result`), + }, + }; + } + case 'dialogue.error': + case 'execution.error': + return { type, timestamp, payload: parseDialogueError(envelope.payload, payloadPath) }; + case 'transport.snapshot': + return { + type, + timestamp, + payload: parseTransportSnapshot(envelope.payload, payloadPath), + }; + case 'transport.ping': + case 'transport.pong': + return { type, timestamp, payload: parseEmptyObject(envelope.payload, payloadPath) }; + } +} + +export function parseConversationClientEvent( + value: unknown, + path = 'event', +): ConversationClientEvent { + const envelope = parseObject(value, path, ['type', 'timestamp', 'payload']); + const type = parseEnum(envelope.type, `${path}.type`, CLIENT_EVENT_TYPES); + const timestamp = parseIsoDateTime(envelope.timestamp, `${path}.timestamp`); + const payloadPath = `${path}.payload`; + + switch (type) { + case 'selection.submit': { + const payload = parseObject(envelope.payload, payloadPath, [ + 'interaction_id', + 'selected_candidate_ids', + ]); + return { + type, + timestamp, + payload: { + interaction_id: parseNonEmptyString( + payload.interaction_id, + `${payloadPath}.interaction_id`, + ), + selected_candidate_ids: parseArray( + payload.selected_candidate_ids, + `${payloadPath}.selected_candidate_ids`, + parseNonEmptyString, + ), + }, + }; + } + case 'selection.cancel': { + const payload = parseObject(envelope.payload, payloadPath, ['interaction_id']); + return { + type, + timestamp, + payload: { + interaction_id: parseNonEmptyString( + payload.interaction_id, + `${payloadPath}.interaction_id`, + ), + }, + }; + } + case 'write.decide': { + const payload = parseObject(envelope.payload, payloadPath, [ + 'write_request_id', + 'decision', + 'idempotency_key', + ]); + return { + type, + timestamp, + payload: { + write_request_id: parseUuid(payload.write_request_id, `${payloadPath}.write_request_id`), + decision: parseEnum(payload.decision, `${payloadPath}.decision`, WRITE_DECISIONS), + idempotency_key: parseNonEmptyString( + payload.idempotency_key, + `${payloadPath}.idempotency_key`, + ), + }, + }; + } + case 'transport.resume': { + const payload = parseObject(envelope.payload, payloadPath, ['last_seen_message_id']); + return { + type, + timestamp, + payload: { + last_seen_message_id: parseUuid( + payload.last_seen_message_id, + `${payloadPath}.last_seen_message_id`, + ), + }, + }; + } + case 'transport.ping': + case 'transport.pong': + return { type, timestamp, payload: parseEmptyObject(envelope.payload, payloadPath) }; + } +} diff --git a/frontend/src/api/contracts/writeRequest.ts b/frontend/src/api/contracts/writeRequest.ts new file mode 100644 index 0000000..c15fca7 --- /dev/null +++ b/frontend/src/api/contracts/writeRequest.ts @@ -0,0 +1,39 @@ +import type { JsonObject, UUID } from './common'; + +export type WriteAction = 'create' | 'update' | 'delete' | 'feedback' | 'replan' | 'goal_split'; +export type WriteTargetType = 'task_item' | 'long_goal' | 'feedback' | 'profile'; +export type WriteDecision = 'confirmed' | 'rejected'; +export type WriteRequestStatus = 'confirmed' | 'rejected' | 'applied' | 'expired'; + +/** 手动新增、修改或删除业务数据前创建统一确认门禁。 */ +export interface CreateWriteRequest { + action: WriteAction; + target_type: WriteTargetType; + target_id?: UUID; + payload: JsonObject; + preview_text: string; +} + +/** + * 创建写入请求后的确认信息。 + * confirmation_payload 的内部字段尚未在最新版 Wiki 中冻结。 + */ +export interface CreateWriteRequestResult { + write_request_id: UUID; + payload_hash: string; + confirmation_payload: JsonObject; +} + +/** 用户确认或拒绝写入,幂等键防止重复提交。 */ +export interface DecideWriteRequest { + decision: WriteDecision; + idempotency_key: string; +} + +/** 写入确认决定的处理结果。 */ +export interface WriteDecisionResult { + write_request_id: UUID; + status: WriteRequestStatus; + applied_target_id?: UUID | null; + message?: string | null; +} diff --git a/frontend/src/api/core/ApiError.ts b/frontend/src/api/core/ApiError.ts new file mode 100644 index 0000000..bba3e7c --- /dev/null +++ b/frontend/src/api/core/ApiError.ts @@ -0,0 +1,26 @@ +import type { ApiErrorDetail, RequestId } from '../contracts/common'; + +/** 前端统一 API 异常,保留错误码、阶段、HTTP 状态和请求标识。 */ +export class ApiError extends Error { + readonly code: string; + readonly stage: string; + readonly retryable: boolean; + readonly requestId: RequestId | null; + readonly status: number; + + constructor( + detail: ApiErrorDetail, + options: { + status: number; + requestId?: RequestId | null; + }, + ) { + super(detail.message); + this.name = 'ApiError'; + this.code = detail.code; + this.stage = detail.stage; + this.retryable = detail.retryable; + this.requestId = options.requestId ?? null; + this.status = options.status; + } +} diff --git a/frontend/src/api/core/ConversationSocket.ts b/frontend/src/api/core/ConversationSocket.ts new file mode 100644 index 0000000..baa25cc --- /dev/null +++ b/frontend/src/api/core/ConversationSocket.ts @@ -0,0 +1,137 @@ +import type { ConversationClientEvent, ConversationServerEvent } from '../contracts/conversation'; +import { + parseConversationClientEvent, + parseConversationServerEvent, +} from '../contracts/validators'; +import { ApiError } from './ApiError'; + +export const CONVERSATION_WS_URL = + process.env.EXPO_PUBLIC_WS_URL ?? + (process.env.EXPO_PUBLIC_API_URL + ? process.env.EXPO_PUBLIC_API_URL.replace(/^http/, 'ws').replace( + /\/api\/v1\/?$/, + '/ws/v1/conversation', + ) + : 'ws://127.0.0.1:8000/ws/v1/conversation'); + +type ReactNativeWebSocketConstructor = new ( + url: string, + protocols?: string | string[], + options?: { headers?: Record }, +) => WebSocket; + +/** 实时会话连接配置及页面事件回调。 */ +export interface ConversationSocketOptions { + accessToken: string; + url?: string; + onEvent: (event: ConversationServerEvent) => void; + onClose?: (event: CloseEvent) => void; + onError?: (event: Event) => void; +} + +export type ConversationCloseDisposition = 'closed' | 'reauthenticate' | 'reconnect'; + +/** + * 按最新版 Wiki 的 close code 约定判断页面下一步动作。 + * 1000/1001 正常结束,1008 重新认证,1011、1006 或非 clean 关闭需要重连。 + */ +export function classifyConversationClose(event: CloseEvent): ConversationCloseDisposition { + if (event.code === 1008) { + return 'reauthenticate'; + } + if (event.code === 1011 || event.code === 1006 || !event.wasClean) { + return 'reconnect'; + } + return 'closed'; +} + +export class ConversationSocket { + private readonly options: ConversationSocketOptions; + private socket: WebSocket | null = null; + + constructor(options: ConversationSocketOptions) { + this.options = options; + } + + /** 建立 `/ws/v1/conversation` 连接,并在握手 Header 中注入 Access Token。 */ + connect(): void { + if ( + this.socket?.readyState === WebSocket.CONNECTING || + this.socket?.readyState === WebSocket.OPEN + ) { + return; + } + + const Socket = WebSocket as unknown as ReactNativeWebSocketConstructor; + const socket = new Socket(this.options.url ?? CONVERSATION_WS_URL, undefined, { + headers: { + Authorization: `Bearer ${this.options.accessToken}`, + }, + }); + + socket.onmessage = (message) => { + if (typeof message.data !== 'string') { + return; + } + + try { + // 服务端事件只有通过运行时契约校验后才会交给页面处理。 + const event = parseConversationServerEvent(JSON.parse(message.data)); + this.options.onEvent(event); + } catch { + this.options.onEvent({ + type: 'execution.error', + timestamp: new Date().toISOString(), + payload: { + error_code: 'CONTRACT_MISMATCH', + message: 'WebSocket 返回了无法解析的事件', + }, + }); + } + }; + socket.onclose = (event) => this.options.onClose?.(event); + socket.onerror = (event) => this.options.onError?.(event); + this.socket = socket; + } + + /** 重连成功后请求续接上次已读消息之后遗漏的流式事件。 */ + resume(lastSeenMessageId: string): void { + this.send({ + type: 'transport.resume', + timestamp: new Date().toISOString(), + payload: { + last_seen_message_id: lastSeenMessageId, + }, + }); + } + + /** 校验并发送一个客户端事件;非法结构会在发送前被拒绝。 */ + send(event: ConversationClientEvent): void { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('Conversation WebSocket is not connected'); + } + + try { + // 在真正发送前校验信封和 payload,避免错误事件进入主 Agent 流程。 + this.socket.send(JSON.stringify(parseConversationClientEvent(event))); + } catch (error) { + throw new ApiError( + { + code: 'CONTRACT_MISMATCH', + message: `WebSocket 入站事件不符合统一接口契约:${ + error instanceof Error ? error.message : '未知校验错误' + }`, + retryable: false, + stage: 'request_validation', + }, + { status: 0 }, + ); + } + } + + /** 主动关闭会话连接并释放当前 WebSocket 实例。 */ + close(code?: number, reason?: string): void { + this.socket?.close(code, reason); + this.socket = null; + } +} diff --git a/frontend/src/api/core/http.ts b/frontend/src/api/core/http.ts new file mode 100644 index 0000000..05cde76 --- /dev/null +++ b/frontend/src/api/core/http.ts @@ -0,0 +1,204 @@ +import type { QueryParams } from '../contracts/common'; +import { ApiError } from './ApiError'; +import type { ContractParser } from './validation'; + +export const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://127.0.0.1:8000/api/v1'; + +/** 从登录状态读取当前 Access Token,供受保护请求自动注入 Authorization。 */ +export type AccessTokenProvider = () => Promise | string | null; + +/** 单次 HTTP 请求配置,扩展了查询参数、对象请求体和鉴权开关。 */ +export interface HttpRequestOptions extends Omit { + body?: BodyInit | object | null; + query?: QueryParams; + authenticated?: boolean; +} + +/** HttpClient 初始化配置,可替换地址、Token 来源和 fetch,便于 Mock 测试。 */ +export interface HttpClientOptions { + baseUrl?: string; + getAccessToken?: AccessTokenProvider; + fetch?: typeof globalThis.fetch; +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} + +function normalizePath(path: string): string { + return path.startsWith('/') ? path : `/${path}`; +} + +function buildQueryString(query?: QueryParams): string { + if (!query) { + return ''; + } + + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + if (key === 'limit' && (typeof value !== 'number' || !Number.isInteger(value) || value < 1)) { + throw new ApiError( + { + code: 'INVALID_REQUEST', + message: 'limit 必须是正整数', + retryable: false, + stage: 'request_validation', + }, + { status: 400 }, + ); + } + searchParams.append(key, String(value)); + } + } + + const serialized = searchParams.toString(); + return serialized ? `?${serialized}` : ''; +} + +function isBodyInit(body: BodyInit | object): body is BodyInit { + return ( + typeof body === 'string' || + body instanceof FormData || + body instanceof URLSearchParams || + body instanceof Blob || + body instanceof ArrayBuffer || + ArrayBuffer.isView(body) + ); +} + +function invalidResponseError(status: number, reason?: string): ApiError { + return new ApiError( + { + code: 'CONTRACT_MISMATCH', + message: reason + ? `服务端返回了不符合统一接口契约的响应:${reason}` + : '服务端返回了不符合统一接口契约的响应', + retryable: false, + stage: 'response_validation', + }, + { status }, + ); +} + +export class HttpClient { + private readonly baseUrl: string; + private readonly fetchImpl: typeof globalThis.fetch; + private getAccessToken?: AccessTokenProvider; + + constructor(options: HttpClientOptions = {}) { + this.baseUrl = trimTrailingSlash(options.baseUrl ?? API_BASE_URL); + this.fetchImpl = options.fetch ?? globalThis.fetch; + this.getAccessToken = options.getAccessToken; + } + + setAccessTokenProvider(provider: AccessTokenProvider): void { + this.getAccessToken = provider; + } + + /** + * 请求成功后直接校验接口自身的响应对象。 + * + * 最新 Wiki 没有定义 `success/data/request_id/timestamp` HTTP 外层信封, + * 因此 SDK 不再擅自包裹或解包响应;每个业务模块必须提供对应解析器。 + */ + async request( + path: string, + options: HttpRequestOptions = {}, + parseData?: ContractParser, + ): Promise { + const response = await this.raw(path, options); + + if (response.status === 204) { + return undefined as T; + } + + let rawResponse: unknown; + + try { + rawResponse = await response.json(); + } catch { + throw invalidResponseError(response.status); + } + + if (!response.ok) { + const object = + typeof rawResponse === 'object' && rawResponse !== null && !Array.isArray(rawResponse) + ? (rawResponse as Record) + : {}; + throw new ApiError( + { + code: typeof object.error_code === 'string' ? object.error_code : 'HTTP_ERROR', + message: + typeof object.message === 'string' + ? object.message + : `HTTP 请求失败(${response.status})`, + retryable: response.status >= 500, + stage: 'http_response', + }, + { status: response.status }, + ); + } + + if (!parseData) { + return rawResponse as T; + } + + try { + return parseData(rawResponse, 'response'); + } catch (error) { + throw invalidResponseError( + response.status, + error instanceof Error ? error.message : undefined, + ); + } + } + + /** 原样请求二进制或流式资源,不解析统一 JSON 信封。 */ + async raw(path: string, options: HttpRequestOptions = {}): Promise { + const { authenticated = true, body, headers: suppliedHeaders, query, ...requestInit } = options; + const headers = new Headers(suppliedHeaders); + + if (authenticated) { + const token = await this.getAccessToken?.(); + + if (!token) { + throw new ApiError( + { + code: 'UNAUTHORIZED', + message: '缺少 Access Token', + retryable: false, + stage: 'authentication', + }, + { status: 401 }, + ); + } + + headers.set('Authorization', `Bearer ${token}`); + } + + let requestBody: BodyInit | undefined; + if (body !== undefined && body !== null) { + if (isBodyInit(body)) { + requestBody = body; + } else { + headers.set('Content-Type', 'application/json'); + requestBody = JSON.stringify(body); + } + } + + return this.fetchImpl(`${this.baseUrl}${normalizePath(path)}${buildQueryString(query)}`, { + ...requestInit, + body: requestBody, + headers, + }); + } +} + +export const httpClient = new HttpClient(); + +/** 为默认 HTTP 单例配置登录令牌读取函数。 */ +export function configureAccessTokenProvider(provider: AccessTokenProvider): void { + httpClient.setAccessTokenProvider(provider); +} diff --git a/frontend/src/api/core/validation.ts b/frontend/src/api/core/validation.ts new file mode 100644 index 0000000..e1b3a7c --- /dev/null +++ b/frontend/src/api/core/validation.ts @@ -0,0 +1,164 @@ +import type { ISODate, ISODateTime, JsonObject, JsonValue, UUID } from '../contracts/common'; + +/** + * 业务响应解析器的统一签名。 + * + * `value` 是 HTTP `data` 或 WebSocket `payload` 的原始值;`path` 用于生成 + * 可直接定位字段的错误信息,例如 `data.items[0].version`。 + */ +export type ContractParser = (value: unknown, path: string) => T; + +/** 契约不匹配错误,只描述字段位置和原因,不携带服务端内部信息。 */ +export class ContractValidationError extends Error { + readonly path: string; + + constructor(path: string, message: string) { + super(`${path}: ${message}`); + this.name = 'ContractValidationError'; + this.path = path; + } +} + +export function parseObject( + value: unknown, + path: string, + requiredKeys: readonly string[], + optionalKeys: readonly string[] = [], +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ContractValidationError(path, '必须是对象'); + } + + const object = value as Record; + // 严格模式:缺少必填字段或出现文档未声明字段都视为契约不一致。 + const allowedKeys = new Set([...requiredKeys, ...optionalKeys]); + + for (const key of requiredKeys) { + if (!Object.prototype.hasOwnProperty.call(object, key)) { + throw new ContractValidationError(`${path}.${key}`, '缺少必填字段'); + } + } + + for (const key of Object.keys(object)) { + if (!allowedKeys.has(key)) { + throw new ContractValidationError(`${path}.${key}`, '包含文档未声明的字段'); + } + } + + return object; +} + +export function parseString(value: unknown, path: string): string { + if (typeof value !== 'string') { + throw new ContractValidationError(path, '必须是字符串'); + } + return value; +} + +export function parseNonEmptyString(value: unknown, path: string): string { + const parsed = parseString(value, path); + if (parsed.trim().length === 0) { + throw new ContractValidationError(path, '不得为空'); + } + return parsed; +} + +export function parseBoolean(value: unknown, path: string): boolean { + if (typeof value !== 'boolean') { + throw new ContractValidationError(path, '必须是布尔值'); + } + return value; +} + +export function parseInteger(value: unknown, path: string): number { + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new ContractValidationError(path, '必须是整数'); + } + return value; +} + +export function parseEnum( + value: unknown, + path: string, + allowed: readonly T[], +): T { + const parsed = parseString(value, path); + if (!allowed.includes(parsed as T)) { + throw new ContractValidationError(path, `必须是 ${allowed.join(' | ')} 之一`); + } + return parsed as T; +} + +export function parseArray(value: unknown, path: string, parser: ContractParser): T[] { + if (!Array.isArray(value)) { + throw new ContractValidationError(path, '必须是数组'); + } + return value.map((item, index) => parser(item, `${path}[${index}]`)); +} + +export function parseNullable( + value: unknown, + path: string, + parser: ContractParser, +): T | null { + return value === null ? null : parser(value, path); +} + +export function parseUuid(value: unknown, path: string): UUID { + const parsed = parseString(value, path); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(parsed)) { + throw new ContractValidationError(path, '必须是 UUID 字符串'); + } + return parsed; +} + +export function parseIsoDate(value: unknown, path: string): ISODate { + const parsed = parseString(value, path); + if (!/^\d{4}-\d{2}-\d{2}$/.test(parsed) || Number.isNaN(Date.parse(`${parsed}T00:00:00Z`))) { + throw new ContractValidationError(path, '必须是 YYYY-MM-DD 日期'); + } + return parsed; +} + +export function parseIsoDateTime(value: unknown, path: string): ISODateTime { + const parsed = parseString(value, path); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(parsed) || + Number.isNaN(Date.parse(parsed)) + ) { + throw new ContractValidationError(path, '必须是包含时区偏移的 ISO 8601 时间'); + } + return parsed; +} + +export function parseJsonValue(value: unknown, path: string): JsonValue { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'string' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item, index) => parseJsonValue(item, `${path}[${index}]`)); + } + + if (typeof value === 'object') { + const result: JsonObject = {}; + for (const [key, item] of Object.entries(value)) { + result[key] = parseJsonValue(item, `${path}.${key}`); + } + return result; + } + + throw new ContractValidationError(path, '必须是合法 JSON 值'); +} + +export function parseJsonObject(value: unknown, path = 'data'): JsonObject { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ContractValidationError(path, '必须是 JSON 对象'); + } + return parseJsonValue(value, path) as JsonObject; +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..46f2ef3 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,18 @@ +export * from './contracts/auth'; +export * from './contracts/common'; +export * from './contracts/conversation'; +export * from './contracts/goal'; +export * from './contracts/item'; +export * from './contracts/profile'; +export * from './contracts/validators'; +export * from './contracts/writeRequest'; +export * from './core/ApiError'; +export * from './core/ConversationSocket'; +export * from './core/http'; +export * from './core/validation'; +export * from './modules/authApi'; +export * from './modules/conversationApi'; +export * from './modules/goalApi'; +export * from './modules/itemApi'; +export * from './modules/profileApi'; +export * from './modules/writeRequestApi'; diff --git a/frontend/src/api/modules/authApi.ts b/frontend/src/api/modules/authApi.ts new file mode 100644 index 0000000..0ae8c3d --- /dev/null +++ b/frontend/src/api/modules/authApi.ts @@ -0,0 +1,41 @@ +import type { AuthSession, CurrentUser, LoginRequest, RegisterRequest } from '../contracts/auth'; +import { parseAuthSession, parseCurrentUser } from '../contracts/validators'; +import { HttpClient, httpClient } from '../core/http'; + +/** 负责账号注册、登录和当前用户初始化查询。 */ +export class AuthApi { + constructor(private readonly http: HttpClient = httpClient) {} + + /** 创建用户账号并返回登录令牌。对应 `POST /api/v1/auth/register`。 */ + register(request: RegisterRequest): Promise { + return this.http.request( + '/auth/register', + { + authenticated: false, + body: request, + method: 'POST', + }, + parseAuthSession, + ); + } + + /** 使用邮箱密码建立登录态。对应 `POST /api/v1/auth/login`。 */ + login(request: LoginRequest): Promise { + return this.http.request( + '/auth/login', + { + authenticated: false, + body: request, + method: 'POST', + }, + parseAuthSession, + ); + } + + /** 获取页面初始化需要的当前用户信息。对应 `GET /api/v1/auth/me`。 */ + getCurrentUser(): Promise { + return this.http.request('/auth/me', {}, parseCurrentUser); + } +} + +export const authApi = new AuthApi(); diff --git a/frontend/src/api/modules/conversationApi.ts b/frontend/src/api/modules/conversationApi.ts new file mode 100644 index 0000000..d87543b --- /dev/null +++ b/frontend/src/api/modules/conversationApi.ts @@ -0,0 +1,85 @@ +import type { + ConversationInputAccepted, + ConversationMessagesRequest, + ConversationMessagesResult, + ConversationSnapshot, + ConversationSnapshotRequest, + MediaConversationInputRequest, + TextConversationInputRequest, +} from '../contracts/conversation'; +import { + parseConversationInputAccepted, + parseConversationMessagesResult, + parseConversationSnapshot, +} from '../contracts/validators'; +import { HttpClient, httpClient } from '../core/http'; + +/** 负责统一输入提交、聊天历史加载和页面状态恢复。 */ +export class ConversationApi { + constructor(private readonly http: HttpClient = httpClient) {} + + /** + * 提交文本输入并立即返回 accepted。 + * 母 AI 的处理进度和最终结果随后通过 WebSocket 推送。 + */ + submitText(request: TextConversationInputRequest): Promise { + return this.http.request( + '/conversation/inputs', + { + body: request, + method: 'POST', + }, + parseConversationInputAccepted, + ); + } + + /** + * 上传图片或音频输入。 + * 后端负责对象存储、生成 source_url,再调用 OCR 或 ASR。 + */ + submitMedia(request: MediaConversationInputRequest): Promise { + const formData = new FormData(); + formData.append('client_message_id', request.client_message_id); + formData.append('modality', request.modality); + formData.append('file', request.file, request.file_name); + if (request.reply_to_question_id) { + formData.append('reply_to_question_id', request.reply_to_question_id); + } + + return this.http.request( + '/conversation/inputs', + { + body: formData, + method: 'POST', + }, + parseConversationInputAccepted, + ); + } + + /** 按消息 ID 向前分页加载聊天历史。对应 `GET /conversation/messages`。 */ + getMessages(request: ConversationMessagesRequest): Promise { + return this.http.request( + '/conversation/messages', + { + query: { ...request }, + }, + parseConversationMessagesResult, + ); + } + + /** + * 页面首次打开或刷新时恢复持久化界面状态。 + * 该接口不替代 WS 断线后的 transport.resume。 + */ + getSnapshot(request: ConversationSnapshotRequest): Promise { + return this.http.request( + '/conversation/snapshot', + { + query: { ...request }, + }, + parseConversationSnapshot, + ); + } +} + +export const conversationApi = new ConversationApi(); diff --git a/frontend/src/api/modules/goalApi.ts b/frontend/src/api/modules/goalApi.ts new file mode 100644 index 0000000..6db6d54 --- /dev/null +++ b/frontend/src/api/modules/goalApi.ts @@ -0,0 +1,38 @@ +import type { + LongGoalDetailRequest, + LongGoalDetailResult, + LongGoalListRequest, + LongGoalListResult, +} from '../contracts/goal'; +import type { UUID } from '../contracts/common'; +import { parseLongGoalDetailResult, parseLongGoalListResult } from '../contracts/validators'; +import { HttpClient, httpClient } from '../core/http'; + +/** 负责长任务目标管理 Tab 的列表和详情查询。 */ +export class GoalApi { + constructor(private readonly http: HttpClient = httpClient) {} + + /** 查询当前用户的长目标列表。对应 `GET /api/v1/long-goals`。 */ + getLongGoals(request: LongGoalListRequest): Promise { + return this.http.request( + '/long-goals', + { + query: { ...request }, + }, + parseLongGoalListResult, + ); + } + + /** 查询一个长目标、其子任务和任务画像。对应 `GET /long-goals/{goal_id}`。 */ + getLongGoal(goalId: UUID, request: LongGoalDetailRequest): Promise { + return this.http.request( + `/long-goals/${encodeURIComponent(goalId)}`, + { + query: { ...request }, + }, + parseLongGoalDetailResult, + ); + } +} + +export const goalApi = new GoalApi(); diff --git a/frontend/src/api/modules/itemApi.ts b/frontend/src/api/modules/itemApi.ts new file mode 100644 index 0000000..4c7b6dc --- /dev/null +++ b/frontend/src/api/modules/itemApi.ts @@ -0,0 +1,24 @@ +import type { TimelineItemsRequest, TimelineItemsResult } from '../contracts/item'; +import { parseTimelineItemsResult } from '../contracts/validators'; +import { HttpClient, httpClient } from '../core/http'; + +/** 负责时间顺序 Tab 的只读事项查询。 */ +export class ItemApi { + constructor(private readonly http: HttpClient = httpClient) {} + + /** + * 查询指定时间范围内的日程、待办和子任务。 + * 对应 `GET /api/v1/items/timeline`,不生成 AI 建议,也不修改事项。 + */ + getTimeline(request: TimelineItemsRequest): Promise { + return this.http.request( + '/items/timeline', + { + query: { ...request }, + }, + parseTimelineItemsResult, + ); + } +} + +export const itemApi = new ItemApi(); diff --git a/frontend/src/api/modules/profileApi.ts b/frontend/src/api/modules/profileApi.ts new file mode 100644 index 0000000..751efe5 --- /dev/null +++ b/frontend/src/api/modules/profileApi.ts @@ -0,0 +1,24 @@ +import type { UserProfile, UserProfileRequest } from '../contracts/profile'; +import { parseUserProfile } from '../contracts/validators'; +import { HttpClient, httpClient } from '../core/http'; + +/** 负责个人主页的全局用户画像只读查询。 */ +export class ProfileApi { + constructor(private readonly http: HttpClient = httpClient) {} + + /** + * 查询用户全局画像。对应 `GET /api/v1/user/profile`。 + * 画像写入统一通过 WriteRequestApi 的确认门禁完成。 + */ + getUserProfile(request: UserProfileRequest): Promise { + return this.http.request( + '/user/profile', + { + query: { ...request }, + }, + parseUserProfile, + ); + } +} + +export const profileApi = new ProfileApi(); diff --git a/frontend/src/api/modules/writeRequestApi.ts b/frontend/src/api/modules/writeRequestApi.ts new file mode 100644 index 0000000..313adfd --- /dev/null +++ b/frontend/src/api/modules/writeRequestApi.ts @@ -0,0 +1,46 @@ +import type { UUID } from '../contracts/common'; +import type { + CreateWriteRequest, + CreateWriteRequestResult, + DecideWriteRequest, + WriteDecisionResult, +} from '../contracts/writeRequest'; +import { parseCreateWriteRequestResult, parseWriteDecisionResult } from '../contracts/validators'; +import { HttpClient, httpClient } from '../core/http'; + +/** 负责所有手动 CRUD 的统一写入确认门禁。 */ +export class WriteRequestApi { + constructor(private readonly http: HttpClient = httpClient) {} + + /** + * 创建待确认写入请求,只保存候选变更,不直接修改业务事实。 + * 对应 `POST /api/v1/write-requests`。 + */ + create(request: CreateWriteRequest): Promise { + return this.http.request( + '/write-requests', + { + body: request, + method: 'POST', + }, + parseCreateWriteRequestResult, + ); + } + + /** + * 确认或拒绝写入;只有 confirmed 才允许后端校验后落库。 + * 对应 `POST /api/v1/write-requests/{write_request_id}/decide`。 + */ + decide(writeRequestId: UUID, request: DecideWriteRequest): Promise { + return this.http.request( + `/write-requests/${encodeURIComponent(writeRequestId)}/decide`, + { + body: request, + method: 'POST', + }, + parseWriteDecisionResult, + ); + } +} + +export const writeRequestApi = new WriteRequestApi(); diff --git a/frontend/tests/api/conversationSocket.test.ts b/frontend/tests/api/conversationSocket.test.ts new file mode 100644 index 0000000..9e482c3 --- /dev/null +++ b/frontend/tests/api/conversationSocket.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import type { ConversationServerEvent } from '../../src/api/contracts/conversation'; +import { + classifyConversationClose, + ConversationSocket, +} from '../../src/api/core/ConversationSocket'; +import { + mockConfirmationEvent, + mockMessageId, + mockProcessingEvent, + mockTimestamp, +} from './mockData'; + +interface MockSocketOptions { + headers?: Record; +} + +class MockWebSocket { + static readonly CLOSED = 3; + static readonly CLOSING = 2; + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static lastInstance: MockWebSocket | null = null; + + readonly options?: MockSocketOptions; + readonly url: string; + readyState = MockWebSocket.CONNECTING; + sent: string[] = []; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + + constructor(url: string, _protocols?: string | string[], options?: MockSocketOptions) { + this.url = url; + this.options = options; + MockWebSocket.lastInstance = this; + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = MockWebSocket.CLOSED; + } +} + +const OriginalWebSocket = globalThis.WebSocket; + +afterEach(() => { + globalThis.WebSocket = OriginalWebSocket; + MockWebSocket.lastInstance = null; +}); + +describe('ConversationSocket latest Wiki envelope', () => { + it('authenticates the handshake and sends type/timestamp/payload only', () => { + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + const conversation = new ConversationSocket({ + accessToken: 'mock.jwt.token', + url: 'ws://example.test/ws/v1/conversation', + onEvent: () => undefined, + }); + + conversation.connect(); + const socket = MockWebSocket.lastInstance; + assert.ok(socket); + assert.equal(socket.options?.headers?.Authorization, 'Bearer mock.jwt.token'); + socket.readyState = MockWebSocket.OPEN; + + const event = { + type: 'selection.submit' as const, + timestamp: mockTimestamp, + payload: { + interaction_id: 'interaction-01', + selected_candidate_ids: ['candidate-01'], + }, + }; + conversation.send(event); + + assert.deepEqual(JSON.parse(socket.sent[0]), event); + assert.deepEqual(Object.keys(JSON.parse(socket.sent[0])).sort(), [ + 'payload', + 'timestamp', + 'type', + ]); + }); + + it('dispatches documented processing and confirmation events', () => { + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + const received: ConversationServerEvent[] = []; + const conversation = new ConversationSocket({ + accessToken: 'mock.jwt.token', + onEvent: (event) => received.push(event), + }); + + conversation.connect(); + const socket = MockWebSocket.lastInstance; + assert.ok(socket); + socket.onmessage?.({ data: JSON.stringify(mockProcessingEvent) } as MessageEvent); + socket.onmessage?.({ data: JSON.stringify(mockConfirmationEvent) } as MessageEvent); + + assert.deepEqual(received, [mockProcessingEvent, mockConfirmationEvent]); + }); + + it('sends transport.resume after a reconnect', () => { + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + const conversation = new ConversationSocket({ + accessToken: 'mock.jwt.token', + onEvent: () => undefined, + }); + conversation.connect(); + const socket = MockWebSocket.lastInstance; + assert.ok(socket); + socket.readyState = MockWebSocket.OPEN; + + conversation.resume(mockMessageId); + const sent = JSON.parse(socket.sent[0]) as Record; + assert.equal(sent.type, 'transport.resume'); + assert.deepEqual(sent.payload, { last_seen_message_id: mockMessageId }); + }); + + it('maps malformed server events to a local execution.error event', () => { + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + const received: ConversationServerEvent[] = []; + const conversation = new ConversationSocket({ + accessToken: 'mock.jwt.token', + onEvent: (event) => received.push(event), + }); + conversation.connect(); + const socket = MockWebSocket.lastInstance; + assert.ok(socket); + + socket.onmessage?.({ data: '{"event":"old-envelope"}' } as MessageEvent); + + assert.equal(received.length, 1); + assert.equal(received[0].type, 'execution.error'); + if (received[0].type === 'execution.error') { + assert.equal(received[0].payload.error_code, 'CONTRACT_MISMATCH'); + } + }); + + it('classifies normal, authentication, and reconnect close cases', () => { + assert.equal(classifyConversationClose({ code: 1000, wasClean: true } as CloseEvent), 'closed'); + assert.equal( + classifyConversationClose({ code: 1008, wasClean: true } as CloseEvent), + 'reauthenticate', + ); + assert.equal( + classifyConversationClose({ code: 1011, wasClean: false } as CloseEvent), + 'reconnect', + ); + assert.equal( + classifyConversationClose({ code: 1006, wasClean: false } as CloseEvent), + 'reconnect', + ); + }); +}); diff --git a/frontend/tests/api/http.test.ts b/frontend/tests/api/http.test.ts new file mode 100644 index 0000000..e3af6f2 --- /dev/null +++ b/frontend/tests/api/http.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { parseTimelineItemsResult } from '../../src/api/contracts/validators'; +import { ApiError } from '../../src/api/core/ApiError'; +import { HttpClient } from '../../src/api/core/http'; +import { mockTimeline, mockUserId } from './mockData'; +import { createMockFetch, jsonResponse } from './testUtils'; + +describe('HttpClient latest Wiki contract', () => { + it('parses direct JSON responses and injects Bearer authentication', async () => { + const mock = createMockFetch([jsonResponse(mockTimeline)]); + const client = new HttpClient({ + baseUrl: 'http://example.test/api/v1/', + fetch: mock.fetch, + getAccessToken: () => 'mock.jwt.token', + }); + + const result = await client.request( + '/items/timeline', + { + query: { + user_id: mockUserId, + start_at: '2026-07-24T00:00:00+08:00', + end_at: '2026-07-25T00:00:00+08:00', + }, + }, + parseTimelineItemsResult, + ); + + assert.deepEqual(result, mockTimeline); + assert.match(mock.requests[0].url, /items\/timeline\?/); + assert.equal(mock.requests[0].headers.get('Authorization'), 'Bearer mock.jwt.token'); + }); + + it('serializes JSON bodies without renaming snake_case fields', async () => { + const response = { + source_message_id: '5d0e1fca-dc6e-4ff4-ab7a-a161e8245585', + client_message_id: 'client-message-01', + source_url: null, + status: 'accepted', + }; + const mock = createMockFetch([jsonResponse(response, 202)]); + const client = new HttpClient({ + baseUrl: 'http://example.test/api/v1', + fetch: mock.fetch, + getAccessToken: () => 'mock.jwt.token', + }); + + await client.request('/conversation/inputs', { + method: 'POST', + body: { + client_message_id: 'client-message-01', + modality: 'text', + raw_content: '测试', + }, + }); + + assert.equal(mock.requests[0].headers.get('Content-Type'), 'application/json'); + assert.deepEqual(JSON.parse(String(mock.requests[0].body)), { + client_message_id: 'client-message-01', + modality: 'text', + raw_content: '测试', + }); + }); + + it('rejects protected requests before fetch when Access Token is missing', async () => { + const mock = createMockFetch([]); + const client = new HttpClient({ + baseUrl: 'http://example.test/api/v1', + fetch: mock.fetch, + }); + + await assert.rejects( + () => client.request('/auth/me'), + (error: unknown) => + error instanceof ApiError && error.code === 'UNAUTHORIZED' && error.status === 401, + ); + assert.equal(mock.requests.length, 0); + }); + + it('maps non-success HTTP responses without assuming an undocumented envelope', async () => { + const mock = createMockFetch([ + jsonResponse({ error_code: 'VERSION_CONFLICT', message: '数据版本已变化' }, 409), + ]); + const client = new HttpClient({ + baseUrl: 'http://example.test/api/v1', + fetch: mock.fetch, + getAccessToken: () => 'mock.jwt.token', + }); + + await assert.rejects( + () => client.request('/write-requests/id/decide'), + (error: unknown) => + error instanceof ApiError && error.code === 'VERSION_CONFLICT' && error.status === 409, + ); + }); + + it('maps malformed successful output to CONTRACT_MISMATCH', async () => { + const mock = createMockFetch([jsonResponse({ items: [{ title: '字段不完整' }] })]); + const client = new HttpClient({ + baseUrl: 'http://example.test/api/v1', + fetch: mock.fetch, + getAccessToken: () => 'mock.jwt.token', + }); + + await assert.rejects( + () => client.request('/items/timeline', {}, parseTimelineItemsResult), + (error: unknown) => + error instanceof ApiError && + error.code === 'CONTRACT_MISMATCH' && + error.message.includes('response.items[0].id'), + ); + }); +}); diff --git a/frontend/tests/api/mockData.ts b/frontend/tests/api/mockData.ts new file mode 100644 index 0000000..5a613fa --- /dev/null +++ b/frontend/tests/api/mockData.ts @@ -0,0 +1,162 @@ +import type { AuthSession, CurrentUser } from '../../src/api/contracts/auth'; +import type { + ConversationInputAccepted, + ConversationMessagesResult, + ConversationSnapshot, + ExecutionProcessingPayload, + WsEnvelope, + WriteConfirmationRequiredPayload, +} from '../../src/api/contracts/conversation'; +import type { ISODateTime, UUID } from '../../src/api/contracts/common'; +import type { + LongGoal, + LongGoalDetailResult, + LongGoalListResult, +} from '../../src/api/contracts/goal'; +import type { TaskItem, TimelineItemsResult } from '../../src/api/contracts/item'; +import type { + CreateWriteRequestResult, + WriteDecisionResult, +} from '../../src/api/contracts/writeRequest'; + +export const mockTimestamp: ISODateTime = '2026-07-24T15:00:00+08:00'; +export const mockUserId: UUID = '20a64e74-b213-44ad-b46b-16ca0805923c'; +export const mockMessageId: UUID = '5d0e1fca-dc6e-4ff4-ab7a-a161e8245585'; +export const mockGoalId: UUID = 'c10c593b-1a06-4950-8174-fe67bbee5d51'; +export const mockWriteRequestId: UUID = '968a8659-d82c-4b43-a828-4a658cd28e93'; + +export const mockAuthSession: AuthSession = { + user_id: mockUserId, + business_user_id: 'TF000001', + access_token: 'mock.jwt.token', +}; + +export const mockCurrentUser: CurrentUser = { + user_id: mockUserId, + business_user_id: 'TF000001', + display_name: '浩涛', +}; + +export const mockInputAccepted: ConversationInputAccepted = { + source_message_id: mockMessageId, + client_message_id: 'client-message-01', + source_url: null, + status: 'accepted', +}; + +export const mockConversationMessages: ConversationMessagesResult = { + messages: [ + { + id: mockMessageId, + user_id: mockUserId, + message_index: 1, + role: 'user', + modality: 'text', + raw_content: '明天下午三点开项目会议', + source_url: null, + metadata: { + schema_version: 1, + message_kind: 'input', + client_message_id: 'client-message-01', + trace_id: 'trace-01', + missing_fields: [], + parsed_params: {}, + time_range: null, + }, + created_at: mockTimestamp, + }, + ], + has_more: false, +}; + +export const mockSnapshot: ConversationSnapshot = { + recent_messages: mockConversationMessages.messages, + pending_clarification: null, + last_event: { + type: 'execution.processing', + }, +}; + +export const mockTaskItem: TaskItem = { + id: '3dd63c85-172a-4baa-a962-9a2c423e9ac1', + user_id: mockUserId, + long_goal_id: mockGoalId, + item_type: 'schedule', + title: '项目会议', + description: '确认 MVP 接口', + start_at: '2026-07-24T15:00:00+08:00', + end_at: '2026-07-24T16:00:00+08:00', + due_at: null, + status: 'planned', + version: 3, +}; + +export const mockTimeline: TimelineItemsResult = { + items: [mockTaskItem], +}; + +export const mockLongGoal: LongGoal = { + id: mockGoalId, + user_id: mockUserId, + title: '完成 TimeFlow MVP', + description: '完成可演示闭环', + plan_overview: '先完成接口,再联调页面', + start_at: '2026-07-20T09:00:00+08:00', + deadline_at: '2026-08-01T18:00:00+08:00', + status: 'active', + version: 2, +}; + +export const mockLongGoalList: LongGoalListResult = { + goals: [mockLongGoal], +}; + +export const mockLongGoalDetail: LongGoalDetailResult = { + goal: mockLongGoal, + subtasks: [mockTaskItem], + task_profile: { + profile_summary: '偏好上午处理高专注任务', + }, +}; + +export const mockUserProfile = { + preferred_work_periods: ['morning'], + default_task_duration_minutes: 45, +}; + +export const mockCreateWriteRequestResult: CreateWriteRequestResult = { + write_request_id: mockWriteRequestId, + payload_hash: 'mock-sha256', + confirmation_payload: { + title: '创建日程', + preview_text: '明天下午三点开项目会议', + }, +}; + +export const mockWriteDecisionResult: WriteDecisionResult = { + write_request_id: mockWriteRequestId, + status: 'applied', + applied_target_id: mockTaskItem.id, + message: '日程已创建', +}; + +export const mockProcessingEvent: WsEnvelope<'execution.processing', ExecutionProcessingPayload> = { + type: 'execution.processing', + timestamp: mockTimestamp, + payload: { + source_message_id: mockMessageId, + stage: 'calling_sub_agent', + }, +}; + +export const mockConfirmationEvent: WsEnvelope< + 'write.confirmation_required', + WriteConfirmationRequiredPayload +> = { + type: 'write.confirmation_required', + timestamp: mockTimestamp, + payload: { + write_request_id: mockWriteRequestId, + confirmation_payload: mockCreateWriteRequestResult.confirmation_payload, + }, +}; diff --git a/frontend/tests/api/modules.test.ts b/frontend/tests/api/modules.test.ts new file mode 100644 index 0000000..c918c03 --- /dev/null +++ b/frontend/tests/api/modules.test.ts @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { HttpClient } from '../../src/api/core/http'; +import { AuthApi } from '../../src/api/modules/authApi'; +import { ConversationApi } from '../../src/api/modules/conversationApi'; +import { GoalApi } from '../../src/api/modules/goalApi'; +import { ItemApi } from '../../src/api/modules/itemApi'; +import { ProfileApi } from '../../src/api/modules/profileApi'; +import { WriteRequestApi } from '../../src/api/modules/writeRequestApi'; +import { + mockAuthSession, + mockConversationMessages, + mockCreateWriteRequestResult, + mockCurrentUser, + mockGoalId, + mockInputAccepted, + mockLongGoalDetail, + mockLongGoalList, + mockMessageId, + mockSnapshot, + mockTimeline, + mockUserId, + mockUserProfile, + mockWriteDecisionResult, + mockWriteRequestId, +} from './mockData'; +import { createMockFetch, jsonResponse } from './testUtils'; + +function createClient(mock: ReturnType): HttpClient { + return new HttpClient({ + baseUrl: 'http://example.test/api/v1', + fetch: mock.fetch, + getAccessToken: () => 'mock.jwt.token', + }); +} + +describe('REST modules follow the latest Wiki paths and fields', () => { + it('wraps register, login, and the renamed GET /auth/me interface', async () => { + const mock = createMockFetch([ + jsonResponse(mockAuthSession, 201), + jsonResponse(mockAuthSession), + jsonResponse(mockCurrentUser), + ]); + const api = new AuthApi(createClient(mock)); + + await api.register({ + email: 'user@example.com', + password: 'StrongPassword123!', + display_name: '浩涛', + }); + await api.login({ email: 'user@example.com', password: 'StrongPassword123!' }); + await api.getCurrentUser(); + + assert.equal(mock.requests[0].url, 'http://example.test/api/v1/auth/register'); + assert.equal(mock.requests[0].headers.get('Authorization'), null); + assert.equal(mock.requests[1].url, 'http://example.test/api/v1/auth/login'); + assert.equal(mock.requests[2].url, 'http://example.test/api/v1/auth/me'); + }); + + it('submits text and media through the single conversation input endpoint', async () => { + const mediaAccepted = { + ...mockInputAccepted, + client_message_id: 'client-message-02', + source_url: 'https://oss.example.test/audio/voice.m4a', + }; + const mock = createMockFetch([ + jsonResponse(mockInputAccepted, 202), + jsonResponse(mediaAccepted, 202), + ]); + const api = new ConversationApi(createClient(mock)); + + await api.submitText({ + client_message_id: 'client-message-01', + modality: 'text', + raw_content: '明天下午三点开会', + }); + await api.submitMedia({ + client_message_id: 'client-message-02', + modality: 'audio', + file: new Blob(['mock-audio'], { type: 'audio/mp4' }), + file_name: 'voice.m4a', + }); + + assert.equal(mock.requests[0].url, 'http://example.test/api/v1/conversation/inputs'); + assert.deepEqual(JSON.parse(String(mock.requests[0].body)), { + client_message_id: 'client-message-01', + modality: 'text', + raw_content: '明天下午三点开会', + }); + assert.equal(mock.requests[1].url, 'http://example.test/api/v1/conversation/inputs'); + assert.ok(mock.requests[1].body instanceof FormData); + const formData = mock.requests[1].body; + assert.deepEqual([...formData.keys()].sort(), ['client_message_id', 'file', 'modality']); + assert.equal(formData.get('modality'), 'audio'); + assert.equal(mock.requests[1].headers.get('Content-Type'), null); + }); + + it('loads message history and the separate initial-page snapshot', async () => { + const mock = createMockFetch([ + jsonResponse(mockConversationMessages), + jsonResponse(mockSnapshot), + ]); + const api = new ConversationApi(createClient(mock)); + + await api.getMessages({ + user_id: mockUserId, + before_message_id: mockMessageId, + limit: 20, + }); + await api.getSnapshot({ + user_id: mockUserId, + last_seen_message_id: mockMessageId, + }); + + assert.match(mock.requests[0].url, /conversation\/messages\?/); + assert.match(mock.requests[0].url, new RegExp(`before_message_id=${mockMessageId}`)); + assert.doesNotMatch(mock.requests[0].url, /cursor=/); + assert.match(mock.requests[1].url, /conversation\/snapshot\?/); + }); + + it('queries only the documented timeline endpoint for task items', async () => { + const mock = createMockFetch([jsonResponse(mockTimeline)]); + const api = new ItemApi(createClient(mock)); + + const result = await api.getTimeline({ + user_id: mockUserId, + start_at: '2026-07-24T00:00:00+08:00', + end_at: '2026-07-25T00:00:00+08:00', + item_type: 'schedule', + }); + + assert.deepEqual(result, mockTimeline); + assert.match(mock.requests[0].url, /\/items\/timeline\?/); + assert.match(mock.requests[0].url, /item_type=schedule/); + }); + + it('wraps the renamed long-goal list and detail endpoints', async () => { + const mock = createMockFetch([ + jsonResponse(mockLongGoalList), + jsonResponse(mockLongGoalDetail), + ]); + const api = new GoalApi(createClient(mock)); + + await api.getLongGoals({ user_id: mockUserId, status: 'active' }); + await api.getLongGoal(mockGoalId, { user_id: mockUserId }); + + assert.match(mock.requests[0].url, /\/long-goals\?/); + assert.equal( + mock.requests[1].url, + `http://example.test/api/v1/long-goals/${mockGoalId}?user_id=${mockUserId}`, + ); + }); + + it('queries the user profile and routes all manual writes through confirmation', async () => { + const mock = createMockFetch([ + jsonResponse(mockUserProfile), + jsonResponse(mockCreateWriteRequestResult, 201), + jsonResponse(mockWriteDecisionResult), + ]); + const profileApi = new ProfileApi(createClient(mock)); + const writeApi = new WriteRequestApi(createClient(mock)); + + await profileApi.getUserProfile({ user_id: mockUserId }); + await writeApi.create({ + action: 'create', + target_type: 'task_item', + payload: { title: '项目会议' }, + preview_text: '创建项目会议', + }); + await writeApi.decide(mockWriteRequestId, { + decision: 'confirmed', + idempotency_key: 'decision-client-01', + }); + + assert.equal( + mock.requests[0].url, + `http://example.test/api/v1/user/profile?user_id=${mockUserId}`, + ); + assert.equal(mock.requests[1].url, 'http://example.test/api/v1/write-requests'); + assert.equal( + mock.requests[2].url, + `http://example.test/api/v1/write-requests/${mockWriteRequestId}/decide`, + ); + assert.deepEqual(JSON.parse(String(mock.requests[2].body)), { + decision: 'confirmed', + idempotency_key: 'decision-client-01', + }); + }); +}); diff --git a/frontend/tests/api/testUtils.ts b/frontend/tests/api/testUtils.ts new file mode 100644 index 0000000..324d36d --- /dev/null +++ b/frontend/tests/api/testUtils.ts @@ -0,0 +1,42 @@ +export interface RecordedRequest { + url: string; + method: string; + headers: Headers; + body: BodyInit | null; +} + +export interface MockFetch { + fetch: typeof globalThis.fetch; + requests: RecordedRequest[]; +} + +/** 构造最新版接口直接返回的 JSON,不添加旧版 success/data 信封。 */ +export function jsonResponse(body: T, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** 记录请求并按顺序返回 Mock 响应。 */ +export function createMockFetch(responses: Response[]): MockFetch { + const requests: RecordedRequest[] = []; + const queue = [...responses]; + + const fetch: typeof globalThis.fetch = async (input, init) => { + requests.push({ + url: String(input), + method: init?.method ?? 'GET', + headers: new Headers(init?.headers), + body: init?.body ?? null, + }); + + const response = queue.shift(); + if (!response) { + throw new Error('Mock fetch response queue is empty'); + } + return response; + }; + + return { fetch, requests }; +} diff --git a/frontend/tests/api/validation.test.ts b/frontend/tests/api/validation.test.ts new file mode 100644 index 0000000..ba956e7 --- /dev/null +++ b/frontend/tests/api/validation.test.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + parseAuthSession, + parseConversationInputAccepted, + parseConversationMessagesResult, + parseConversationServerEvent, + parseLongGoal, + parseTaskItem, + parseWriteDecisionResult, +} from '../../src/api/contracts/validators'; +import { ContractValidationError } from '../../src/api/core/validation'; +import { + mockAuthSession, + mockConversationMessages, + mockInputAccepted, + mockLongGoal, + mockProcessingEvent, + mockTaskItem, + mockWriteDecisionResult, +} from './mockData'; + +// 这些测试同时证明正确 Mock 可通过、旧字段和未声明字段会被拒绝。 +describe('strict output structure and field validation', () => { + it('accepts the exact latest auth fields and rejects old extra fields', () => { + assert.deepEqual(parseAuthSession(mockAuthSession), mockAuthSession); + assert.throws( + () => + parseAuthSession({ + ...mockAuthSession, + expires_at: '2026-07-24T18:00:00+08:00', + }), + (error: unknown) => + error instanceof ContractValidationError && error.message.includes('response.expires_at'), + ); + }); + + it('rejects the removed HTTP success/data envelope', () => { + assert.throws( + () => + parseConversationInputAccepted({ + success: true, + data: mockInputAccepted, + }), + (error: unknown) => + error instanceof ContractValidationError && + error.message.includes('response.source_message_id'), + ); + }); + + it('validates every ConversationMessageDTO field and nested metadata', () => { + assert.deepEqual( + parseConversationMessagesResult(mockConversationMessages), + mockConversationMessages, + ); + + const invalid = structuredClone(mockConversationMessages); + invalid.messages[0].metadata = { + ...invalid.messages[0].metadata, + database_row_id: 'not-documented', + } as (typeof invalid.messages)[0]['metadata']; + assert.throws( + () => parseConversationMessagesResult(invalid), + (error: unknown) => + error instanceof ContractValidationError && + error.message.includes('metadata.database_row_id'), + ); + }); + + it('rejects old message fields and old modality enum values', () => { + const message = mockConversationMessages.messages[0]; + const { + id: _id, + metadata: _metadata, + message_index: _index, + user_id: _userId, + ...old + } = message; + + assert.throws( + () => + parseConversationMessagesResult({ + messages: [ + { + ...old, + message_id: message.id, + modality: 'voice_asr', + processing_status: 'succeeded', + }, + ], + has_more: false, + }), + (error: unknown) => + error instanceof ContractValidationError && + error.message.includes('response.messages[0].id'), + ); + }); + + it('validates all TaskItemDTO fields, enums, UUIDs, and timestamps', () => { + assert.deepEqual(parseTaskItem(mockTaskItem), mockTaskItem); + assert.throws( + () => parseTaskItem({ ...mockTaskItem, status: 'pending' }), + (error: unknown) => + error instanceof ContractValidationError && + error.message.includes('response.items[].status'), + ); + assert.throws( + () => parseTaskItem({ ...mockTaskItem, id: 'not-a-uuid' }), + (error: unknown) => + error instanceof ContractValidationError && error.message.includes('response.items[].id'), + ); + assert.throws( + () => parseTaskItem({ ...mockTaskItem, start_at: '2026-07-24T15:00:00' }), + (error: unknown) => + error instanceof ContractValidationError && + error.message.includes('response.items[].start_at'), + ); + }); + + it('validates all LongGoalDTO fields and rejects undeclared fields', () => { + assert.deepEqual(parseLongGoal(mockLongGoal), mockLongGoal); + assert.throws( + () => parseLongGoal({ ...mockLongGoal, is_deleted: false }), + (error: unknown) => + error instanceof ContractValidationError && + error.message.includes('response.goals[].is_deleted'), + ); + }); + + it('validates WriteDecisionResult status and field completeness', () => { + assert.deepEqual(parseWriteDecisionResult(mockWriteDecisionResult), mockWriteDecisionResult); + assert.throws( + () => parseWriteDecisionResult({ ...mockWriteDecisionResult, status: 'pending' }), + (error: unknown) => + error instanceof ContractValidationError && error.message.includes('response.status'), + ); + }); + + it('accepts type/timestamp/payload and rejects the old WS envelope', () => { + assert.deepEqual(parseConversationServerEvent(mockProcessingEvent), mockProcessingEvent); + assert.throws( + () => + parseConversationServerEvent({ + event: 'processing', + sent_at: mockProcessingEvent.timestamp, + payload: mockProcessingEvent.payload, + }), + (error: unknown) => + error instanceof ContractValidationError && error.message.includes('event.type'), + ); + }); + + it('rejects undeclared fields inside a documented WS payload', () => { + assert.throws( + () => + parseConversationServerEvent({ + ...mockProcessingEvent, + payload: { + ...mockProcessingEvent.payload, + message: '旧协议字段', + }, + }), + (error: unknown) => + error instanceof ContractValidationError && error.message.includes('event.payload.message'), + ); + }); +}); diff --git a/frontend/tests/tsconfig.json b/frontend/tests/tsconfig.json new file mode 100644 index 0000000..343524f --- /dev/null +++ b/frontend/tests/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "Node16", + "moduleResolution": "Node16", + "noEmit": false, + "outDir": "../.contract-test-dist", + "rootDir": "..", + "types": ["node"] + }, + "include": ["../src/api/**/*.ts", "./api/**/*.ts"], + "exclude": [] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index db2da66..374b873 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -2,5 +2,6 @@ "extends": "./node_modules/expo/tsconfig.base.json", "compilerOptions": { "strict": true - } + }, + "exclude": [".contract-test-dist", "tests"] } From b200cf4ed8db644d25ce58dc2fcd9159666ac5d1 Mon Sep 17 00:00:00 2001 From: zhenghaotao Date: Fri, 24 Jul 2026 15:19:47 +0800 Subject: [PATCH 2/3] chore(test): exclude local contract tests from repository --- frontend/.gitignore | 1 + frontend/package.json | 3 +- frontend/tests/api/conversationSocket.test.ts | 159 --------------- frontend/tests/api/http.test.ts | 115 ----------- frontend/tests/api/mockData.ts | 162 --------------- frontend/tests/api/modules.test.ts | 190 ------------------ frontend/tests/api/testUtils.ts | 42 ---- frontend/tests/api/validation.test.ts | 168 ---------------- frontend/tests/tsconfig.json | 13 -- 9 files changed, 2 insertions(+), 851 deletions(-) delete mode 100644 frontend/tests/api/conversationSocket.test.ts delete mode 100644 frontend/tests/api/http.test.ts delete mode 100644 frontend/tests/api/mockData.ts delete mode 100644 frontend/tests/api/modules.test.ts delete mode 100644 frontend/tests/api/testUtils.ts delete mode 100644 frontend/tests/api/validation.test.ts delete mode 100644 frontend/tests/tsconfig.json diff --git a/frontend/.gitignore b/frontend/.gitignore index 2257214..b9233b6 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -38,6 +38,7 @@ yarn-error.* # typescript *.tsbuildinfo .contract-test-dist/ +/tests/ # generated native folders /ios diff --git a/frontend/package.json b/frontend/package.json index 1e1c5d6..1b2211c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,8 +33,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit", - "test:api": "tsc -p tests/tsconfig.json && node --test .contract-test-dist/tests/api/*.test.js", - "check": "npm run lint && npm run format:check && npm run typecheck && npm run test:api" + "check": "npm run lint && npm run format:check && npm run typecheck" }, "private": true } diff --git a/frontend/tests/api/conversationSocket.test.ts b/frontend/tests/api/conversationSocket.test.ts deleted file mode 100644 index 9e482c3..0000000 --- a/frontend/tests/api/conversationSocket.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import assert from 'node:assert/strict'; -import { afterEach, describe, it } from 'node:test'; - -import type { ConversationServerEvent } from '../../src/api/contracts/conversation'; -import { - classifyConversationClose, - ConversationSocket, -} from '../../src/api/core/ConversationSocket'; -import { - mockConfirmationEvent, - mockMessageId, - mockProcessingEvent, - mockTimestamp, -} from './mockData'; - -interface MockSocketOptions { - headers?: Record; -} - -class MockWebSocket { - static readonly CLOSED = 3; - static readonly CLOSING = 2; - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static lastInstance: MockWebSocket | null = null; - - readonly options?: MockSocketOptions; - readonly url: string; - readyState = MockWebSocket.CONNECTING; - sent: string[] = []; - onclose: ((event: CloseEvent) => void) | null = null; - onerror: ((event: Event) => void) | null = null; - onmessage: ((event: MessageEvent) => void) | null = null; - - constructor(url: string, _protocols?: string | string[], options?: MockSocketOptions) { - this.url = url; - this.options = options; - MockWebSocket.lastInstance = this; - } - - send(data: string): void { - this.sent.push(data); - } - - close(): void { - this.readyState = MockWebSocket.CLOSED; - } -} - -const OriginalWebSocket = globalThis.WebSocket; - -afterEach(() => { - globalThis.WebSocket = OriginalWebSocket; - MockWebSocket.lastInstance = null; -}); - -describe('ConversationSocket latest Wiki envelope', () => { - it('authenticates the handshake and sends type/timestamp/payload only', () => { - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; - const conversation = new ConversationSocket({ - accessToken: 'mock.jwt.token', - url: 'ws://example.test/ws/v1/conversation', - onEvent: () => undefined, - }); - - conversation.connect(); - const socket = MockWebSocket.lastInstance; - assert.ok(socket); - assert.equal(socket.options?.headers?.Authorization, 'Bearer mock.jwt.token'); - socket.readyState = MockWebSocket.OPEN; - - const event = { - type: 'selection.submit' as const, - timestamp: mockTimestamp, - payload: { - interaction_id: 'interaction-01', - selected_candidate_ids: ['candidate-01'], - }, - }; - conversation.send(event); - - assert.deepEqual(JSON.parse(socket.sent[0]), event); - assert.deepEqual(Object.keys(JSON.parse(socket.sent[0])).sort(), [ - 'payload', - 'timestamp', - 'type', - ]); - }); - - it('dispatches documented processing and confirmation events', () => { - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; - const received: ConversationServerEvent[] = []; - const conversation = new ConversationSocket({ - accessToken: 'mock.jwt.token', - onEvent: (event) => received.push(event), - }); - - conversation.connect(); - const socket = MockWebSocket.lastInstance; - assert.ok(socket); - socket.onmessage?.({ data: JSON.stringify(mockProcessingEvent) } as MessageEvent); - socket.onmessage?.({ data: JSON.stringify(mockConfirmationEvent) } as MessageEvent); - - assert.deepEqual(received, [mockProcessingEvent, mockConfirmationEvent]); - }); - - it('sends transport.resume after a reconnect', () => { - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; - const conversation = new ConversationSocket({ - accessToken: 'mock.jwt.token', - onEvent: () => undefined, - }); - conversation.connect(); - const socket = MockWebSocket.lastInstance; - assert.ok(socket); - socket.readyState = MockWebSocket.OPEN; - - conversation.resume(mockMessageId); - const sent = JSON.parse(socket.sent[0]) as Record; - assert.equal(sent.type, 'transport.resume'); - assert.deepEqual(sent.payload, { last_seen_message_id: mockMessageId }); - }); - - it('maps malformed server events to a local execution.error event', () => { - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; - const received: ConversationServerEvent[] = []; - const conversation = new ConversationSocket({ - accessToken: 'mock.jwt.token', - onEvent: (event) => received.push(event), - }); - conversation.connect(); - const socket = MockWebSocket.lastInstance; - assert.ok(socket); - - socket.onmessage?.({ data: '{"event":"old-envelope"}' } as MessageEvent); - - assert.equal(received.length, 1); - assert.equal(received[0].type, 'execution.error'); - if (received[0].type === 'execution.error') { - assert.equal(received[0].payload.error_code, 'CONTRACT_MISMATCH'); - } - }); - - it('classifies normal, authentication, and reconnect close cases', () => { - assert.equal(classifyConversationClose({ code: 1000, wasClean: true } as CloseEvent), 'closed'); - assert.equal( - classifyConversationClose({ code: 1008, wasClean: true } as CloseEvent), - 'reauthenticate', - ); - assert.equal( - classifyConversationClose({ code: 1011, wasClean: false } as CloseEvent), - 'reconnect', - ); - assert.equal( - classifyConversationClose({ code: 1006, wasClean: false } as CloseEvent), - 'reconnect', - ); - }); -}); diff --git a/frontend/tests/api/http.test.ts b/frontend/tests/api/http.test.ts deleted file mode 100644 index e3af6f2..0000000 --- a/frontend/tests/api/http.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { parseTimelineItemsResult } from '../../src/api/contracts/validators'; -import { ApiError } from '../../src/api/core/ApiError'; -import { HttpClient } from '../../src/api/core/http'; -import { mockTimeline, mockUserId } from './mockData'; -import { createMockFetch, jsonResponse } from './testUtils'; - -describe('HttpClient latest Wiki contract', () => { - it('parses direct JSON responses and injects Bearer authentication', async () => { - const mock = createMockFetch([jsonResponse(mockTimeline)]); - const client = new HttpClient({ - baseUrl: 'http://example.test/api/v1/', - fetch: mock.fetch, - getAccessToken: () => 'mock.jwt.token', - }); - - const result = await client.request( - '/items/timeline', - { - query: { - user_id: mockUserId, - start_at: '2026-07-24T00:00:00+08:00', - end_at: '2026-07-25T00:00:00+08:00', - }, - }, - parseTimelineItemsResult, - ); - - assert.deepEqual(result, mockTimeline); - assert.match(mock.requests[0].url, /items\/timeline\?/); - assert.equal(mock.requests[0].headers.get('Authorization'), 'Bearer mock.jwt.token'); - }); - - it('serializes JSON bodies without renaming snake_case fields', async () => { - const response = { - source_message_id: '5d0e1fca-dc6e-4ff4-ab7a-a161e8245585', - client_message_id: 'client-message-01', - source_url: null, - status: 'accepted', - }; - const mock = createMockFetch([jsonResponse(response, 202)]); - const client = new HttpClient({ - baseUrl: 'http://example.test/api/v1', - fetch: mock.fetch, - getAccessToken: () => 'mock.jwt.token', - }); - - await client.request('/conversation/inputs', { - method: 'POST', - body: { - client_message_id: 'client-message-01', - modality: 'text', - raw_content: '测试', - }, - }); - - assert.equal(mock.requests[0].headers.get('Content-Type'), 'application/json'); - assert.deepEqual(JSON.parse(String(mock.requests[0].body)), { - client_message_id: 'client-message-01', - modality: 'text', - raw_content: '测试', - }); - }); - - it('rejects protected requests before fetch when Access Token is missing', async () => { - const mock = createMockFetch([]); - const client = new HttpClient({ - baseUrl: 'http://example.test/api/v1', - fetch: mock.fetch, - }); - - await assert.rejects( - () => client.request('/auth/me'), - (error: unknown) => - error instanceof ApiError && error.code === 'UNAUTHORIZED' && error.status === 401, - ); - assert.equal(mock.requests.length, 0); - }); - - it('maps non-success HTTP responses without assuming an undocumented envelope', async () => { - const mock = createMockFetch([ - jsonResponse({ error_code: 'VERSION_CONFLICT', message: '数据版本已变化' }, 409), - ]); - const client = new HttpClient({ - baseUrl: 'http://example.test/api/v1', - fetch: mock.fetch, - getAccessToken: () => 'mock.jwt.token', - }); - - await assert.rejects( - () => client.request('/write-requests/id/decide'), - (error: unknown) => - error instanceof ApiError && error.code === 'VERSION_CONFLICT' && error.status === 409, - ); - }); - - it('maps malformed successful output to CONTRACT_MISMATCH', async () => { - const mock = createMockFetch([jsonResponse({ items: [{ title: '字段不完整' }] })]); - const client = new HttpClient({ - baseUrl: 'http://example.test/api/v1', - fetch: mock.fetch, - getAccessToken: () => 'mock.jwt.token', - }); - - await assert.rejects( - () => client.request('/items/timeline', {}, parseTimelineItemsResult), - (error: unknown) => - error instanceof ApiError && - error.code === 'CONTRACT_MISMATCH' && - error.message.includes('response.items[0].id'), - ); - }); -}); diff --git a/frontend/tests/api/mockData.ts b/frontend/tests/api/mockData.ts deleted file mode 100644 index 5a613fa..0000000 --- a/frontend/tests/api/mockData.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { AuthSession, CurrentUser } from '../../src/api/contracts/auth'; -import type { - ConversationInputAccepted, - ConversationMessagesResult, - ConversationSnapshot, - ExecutionProcessingPayload, - WsEnvelope, - WriteConfirmationRequiredPayload, -} from '../../src/api/contracts/conversation'; -import type { ISODateTime, UUID } from '../../src/api/contracts/common'; -import type { - LongGoal, - LongGoalDetailResult, - LongGoalListResult, -} from '../../src/api/contracts/goal'; -import type { TaskItem, TimelineItemsResult } from '../../src/api/contracts/item'; -import type { - CreateWriteRequestResult, - WriteDecisionResult, -} from '../../src/api/contracts/writeRequest'; - -export const mockTimestamp: ISODateTime = '2026-07-24T15:00:00+08:00'; -export const mockUserId: UUID = '20a64e74-b213-44ad-b46b-16ca0805923c'; -export const mockMessageId: UUID = '5d0e1fca-dc6e-4ff4-ab7a-a161e8245585'; -export const mockGoalId: UUID = 'c10c593b-1a06-4950-8174-fe67bbee5d51'; -export const mockWriteRequestId: UUID = '968a8659-d82c-4b43-a828-4a658cd28e93'; - -export const mockAuthSession: AuthSession = { - user_id: mockUserId, - business_user_id: 'TF000001', - access_token: 'mock.jwt.token', -}; - -export const mockCurrentUser: CurrentUser = { - user_id: mockUserId, - business_user_id: 'TF000001', - display_name: '浩涛', -}; - -export const mockInputAccepted: ConversationInputAccepted = { - source_message_id: mockMessageId, - client_message_id: 'client-message-01', - source_url: null, - status: 'accepted', -}; - -export const mockConversationMessages: ConversationMessagesResult = { - messages: [ - { - id: mockMessageId, - user_id: mockUserId, - message_index: 1, - role: 'user', - modality: 'text', - raw_content: '明天下午三点开项目会议', - source_url: null, - metadata: { - schema_version: 1, - message_kind: 'input', - client_message_id: 'client-message-01', - trace_id: 'trace-01', - missing_fields: [], - parsed_params: {}, - time_range: null, - }, - created_at: mockTimestamp, - }, - ], - has_more: false, -}; - -export const mockSnapshot: ConversationSnapshot = { - recent_messages: mockConversationMessages.messages, - pending_clarification: null, - last_event: { - type: 'execution.processing', - }, -}; - -export const mockTaskItem: TaskItem = { - id: '3dd63c85-172a-4baa-a962-9a2c423e9ac1', - user_id: mockUserId, - long_goal_id: mockGoalId, - item_type: 'schedule', - title: '项目会议', - description: '确认 MVP 接口', - start_at: '2026-07-24T15:00:00+08:00', - end_at: '2026-07-24T16:00:00+08:00', - due_at: null, - status: 'planned', - version: 3, -}; - -export const mockTimeline: TimelineItemsResult = { - items: [mockTaskItem], -}; - -export const mockLongGoal: LongGoal = { - id: mockGoalId, - user_id: mockUserId, - title: '完成 TimeFlow MVP', - description: '完成可演示闭环', - plan_overview: '先完成接口,再联调页面', - start_at: '2026-07-20T09:00:00+08:00', - deadline_at: '2026-08-01T18:00:00+08:00', - status: 'active', - version: 2, -}; - -export const mockLongGoalList: LongGoalListResult = { - goals: [mockLongGoal], -}; - -export const mockLongGoalDetail: LongGoalDetailResult = { - goal: mockLongGoal, - subtasks: [mockTaskItem], - task_profile: { - profile_summary: '偏好上午处理高专注任务', - }, -}; - -export const mockUserProfile = { - preferred_work_periods: ['morning'], - default_task_duration_minutes: 45, -}; - -export const mockCreateWriteRequestResult: CreateWriteRequestResult = { - write_request_id: mockWriteRequestId, - payload_hash: 'mock-sha256', - confirmation_payload: { - title: '创建日程', - preview_text: '明天下午三点开项目会议', - }, -}; - -export const mockWriteDecisionResult: WriteDecisionResult = { - write_request_id: mockWriteRequestId, - status: 'applied', - applied_target_id: mockTaskItem.id, - message: '日程已创建', -}; - -export const mockProcessingEvent: WsEnvelope<'execution.processing', ExecutionProcessingPayload> = { - type: 'execution.processing', - timestamp: mockTimestamp, - payload: { - source_message_id: mockMessageId, - stage: 'calling_sub_agent', - }, -}; - -export const mockConfirmationEvent: WsEnvelope< - 'write.confirmation_required', - WriteConfirmationRequiredPayload -> = { - type: 'write.confirmation_required', - timestamp: mockTimestamp, - payload: { - write_request_id: mockWriteRequestId, - confirmation_payload: mockCreateWriteRequestResult.confirmation_payload, - }, -}; diff --git a/frontend/tests/api/modules.test.ts b/frontend/tests/api/modules.test.ts deleted file mode 100644 index c918c03..0000000 --- a/frontend/tests/api/modules.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { HttpClient } from '../../src/api/core/http'; -import { AuthApi } from '../../src/api/modules/authApi'; -import { ConversationApi } from '../../src/api/modules/conversationApi'; -import { GoalApi } from '../../src/api/modules/goalApi'; -import { ItemApi } from '../../src/api/modules/itemApi'; -import { ProfileApi } from '../../src/api/modules/profileApi'; -import { WriteRequestApi } from '../../src/api/modules/writeRequestApi'; -import { - mockAuthSession, - mockConversationMessages, - mockCreateWriteRequestResult, - mockCurrentUser, - mockGoalId, - mockInputAccepted, - mockLongGoalDetail, - mockLongGoalList, - mockMessageId, - mockSnapshot, - mockTimeline, - mockUserId, - mockUserProfile, - mockWriteDecisionResult, - mockWriteRequestId, -} from './mockData'; -import { createMockFetch, jsonResponse } from './testUtils'; - -function createClient(mock: ReturnType): HttpClient { - return new HttpClient({ - baseUrl: 'http://example.test/api/v1', - fetch: mock.fetch, - getAccessToken: () => 'mock.jwt.token', - }); -} - -describe('REST modules follow the latest Wiki paths and fields', () => { - it('wraps register, login, and the renamed GET /auth/me interface', async () => { - const mock = createMockFetch([ - jsonResponse(mockAuthSession, 201), - jsonResponse(mockAuthSession), - jsonResponse(mockCurrentUser), - ]); - const api = new AuthApi(createClient(mock)); - - await api.register({ - email: 'user@example.com', - password: 'StrongPassword123!', - display_name: '浩涛', - }); - await api.login({ email: 'user@example.com', password: 'StrongPassword123!' }); - await api.getCurrentUser(); - - assert.equal(mock.requests[0].url, 'http://example.test/api/v1/auth/register'); - assert.equal(mock.requests[0].headers.get('Authorization'), null); - assert.equal(mock.requests[1].url, 'http://example.test/api/v1/auth/login'); - assert.equal(mock.requests[2].url, 'http://example.test/api/v1/auth/me'); - }); - - it('submits text and media through the single conversation input endpoint', async () => { - const mediaAccepted = { - ...mockInputAccepted, - client_message_id: 'client-message-02', - source_url: 'https://oss.example.test/audio/voice.m4a', - }; - const mock = createMockFetch([ - jsonResponse(mockInputAccepted, 202), - jsonResponse(mediaAccepted, 202), - ]); - const api = new ConversationApi(createClient(mock)); - - await api.submitText({ - client_message_id: 'client-message-01', - modality: 'text', - raw_content: '明天下午三点开会', - }); - await api.submitMedia({ - client_message_id: 'client-message-02', - modality: 'audio', - file: new Blob(['mock-audio'], { type: 'audio/mp4' }), - file_name: 'voice.m4a', - }); - - assert.equal(mock.requests[0].url, 'http://example.test/api/v1/conversation/inputs'); - assert.deepEqual(JSON.parse(String(mock.requests[0].body)), { - client_message_id: 'client-message-01', - modality: 'text', - raw_content: '明天下午三点开会', - }); - assert.equal(mock.requests[1].url, 'http://example.test/api/v1/conversation/inputs'); - assert.ok(mock.requests[1].body instanceof FormData); - const formData = mock.requests[1].body; - assert.deepEqual([...formData.keys()].sort(), ['client_message_id', 'file', 'modality']); - assert.equal(formData.get('modality'), 'audio'); - assert.equal(mock.requests[1].headers.get('Content-Type'), null); - }); - - it('loads message history and the separate initial-page snapshot', async () => { - const mock = createMockFetch([ - jsonResponse(mockConversationMessages), - jsonResponse(mockSnapshot), - ]); - const api = new ConversationApi(createClient(mock)); - - await api.getMessages({ - user_id: mockUserId, - before_message_id: mockMessageId, - limit: 20, - }); - await api.getSnapshot({ - user_id: mockUserId, - last_seen_message_id: mockMessageId, - }); - - assert.match(mock.requests[0].url, /conversation\/messages\?/); - assert.match(mock.requests[0].url, new RegExp(`before_message_id=${mockMessageId}`)); - assert.doesNotMatch(mock.requests[0].url, /cursor=/); - assert.match(mock.requests[1].url, /conversation\/snapshot\?/); - }); - - it('queries only the documented timeline endpoint for task items', async () => { - const mock = createMockFetch([jsonResponse(mockTimeline)]); - const api = new ItemApi(createClient(mock)); - - const result = await api.getTimeline({ - user_id: mockUserId, - start_at: '2026-07-24T00:00:00+08:00', - end_at: '2026-07-25T00:00:00+08:00', - item_type: 'schedule', - }); - - assert.deepEqual(result, mockTimeline); - assert.match(mock.requests[0].url, /\/items\/timeline\?/); - assert.match(mock.requests[0].url, /item_type=schedule/); - }); - - it('wraps the renamed long-goal list and detail endpoints', async () => { - const mock = createMockFetch([ - jsonResponse(mockLongGoalList), - jsonResponse(mockLongGoalDetail), - ]); - const api = new GoalApi(createClient(mock)); - - await api.getLongGoals({ user_id: mockUserId, status: 'active' }); - await api.getLongGoal(mockGoalId, { user_id: mockUserId }); - - assert.match(mock.requests[0].url, /\/long-goals\?/); - assert.equal( - mock.requests[1].url, - `http://example.test/api/v1/long-goals/${mockGoalId}?user_id=${mockUserId}`, - ); - }); - - it('queries the user profile and routes all manual writes through confirmation', async () => { - const mock = createMockFetch([ - jsonResponse(mockUserProfile), - jsonResponse(mockCreateWriteRequestResult, 201), - jsonResponse(mockWriteDecisionResult), - ]); - const profileApi = new ProfileApi(createClient(mock)); - const writeApi = new WriteRequestApi(createClient(mock)); - - await profileApi.getUserProfile({ user_id: mockUserId }); - await writeApi.create({ - action: 'create', - target_type: 'task_item', - payload: { title: '项目会议' }, - preview_text: '创建项目会议', - }); - await writeApi.decide(mockWriteRequestId, { - decision: 'confirmed', - idempotency_key: 'decision-client-01', - }); - - assert.equal( - mock.requests[0].url, - `http://example.test/api/v1/user/profile?user_id=${mockUserId}`, - ); - assert.equal(mock.requests[1].url, 'http://example.test/api/v1/write-requests'); - assert.equal( - mock.requests[2].url, - `http://example.test/api/v1/write-requests/${mockWriteRequestId}/decide`, - ); - assert.deepEqual(JSON.parse(String(mock.requests[2].body)), { - decision: 'confirmed', - idempotency_key: 'decision-client-01', - }); - }); -}); diff --git a/frontend/tests/api/testUtils.ts b/frontend/tests/api/testUtils.ts deleted file mode 100644 index 324d36d..0000000 --- a/frontend/tests/api/testUtils.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface RecordedRequest { - url: string; - method: string; - headers: Headers; - body: BodyInit | null; -} - -export interface MockFetch { - fetch: typeof globalThis.fetch; - requests: RecordedRequest[]; -} - -/** 构造最新版接口直接返回的 JSON,不添加旧版 success/data 信封。 */ -export function jsonResponse(body: T, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} - -/** 记录请求并按顺序返回 Mock 响应。 */ -export function createMockFetch(responses: Response[]): MockFetch { - const requests: RecordedRequest[] = []; - const queue = [...responses]; - - const fetch: typeof globalThis.fetch = async (input, init) => { - requests.push({ - url: String(input), - method: init?.method ?? 'GET', - headers: new Headers(init?.headers), - body: init?.body ?? null, - }); - - const response = queue.shift(); - if (!response) { - throw new Error('Mock fetch response queue is empty'); - } - return response; - }; - - return { fetch, requests }; -} diff --git a/frontend/tests/api/validation.test.ts b/frontend/tests/api/validation.test.ts deleted file mode 100644 index ba956e7..0000000 --- a/frontend/tests/api/validation.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { - parseAuthSession, - parseConversationInputAccepted, - parseConversationMessagesResult, - parseConversationServerEvent, - parseLongGoal, - parseTaskItem, - parseWriteDecisionResult, -} from '../../src/api/contracts/validators'; -import { ContractValidationError } from '../../src/api/core/validation'; -import { - mockAuthSession, - mockConversationMessages, - mockInputAccepted, - mockLongGoal, - mockProcessingEvent, - mockTaskItem, - mockWriteDecisionResult, -} from './mockData'; - -// 这些测试同时证明正确 Mock 可通过、旧字段和未声明字段会被拒绝。 -describe('strict output structure and field validation', () => { - it('accepts the exact latest auth fields and rejects old extra fields', () => { - assert.deepEqual(parseAuthSession(mockAuthSession), mockAuthSession); - assert.throws( - () => - parseAuthSession({ - ...mockAuthSession, - expires_at: '2026-07-24T18:00:00+08:00', - }), - (error: unknown) => - error instanceof ContractValidationError && error.message.includes('response.expires_at'), - ); - }); - - it('rejects the removed HTTP success/data envelope', () => { - assert.throws( - () => - parseConversationInputAccepted({ - success: true, - data: mockInputAccepted, - }), - (error: unknown) => - error instanceof ContractValidationError && - error.message.includes('response.source_message_id'), - ); - }); - - it('validates every ConversationMessageDTO field and nested metadata', () => { - assert.deepEqual( - parseConversationMessagesResult(mockConversationMessages), - mockConversationMessages, - ); - - const invalid = structuredClone(mockConversationMessages); - invalid.messages[0].metadata = { - ...invalid.messages[0].metadata, - database_row_id: 'not-documented', - } as (typeof invalid.messages)[0]['metadata']; - assert.throws( - () => parseConversationMessagesResult(invalid), - (error: unknown) => - error instanceof ContractValidationError && - error.message.includes('metadata.database_row_id'), - ); - }); - - it('rejects old message fields and old modality enum values', () => { - const message = mockConversationMessages.messages[0]; - const { - id: _id, - metadata: _metadata, - message_index: _index, - user_id: _userId, - ...old - } = message; - - assert.throws( - () => - parseConversationMessagesResult({ - messages: [ - { - ...old, - message_id: message.id, - modality: 'voice_asr', - processing_status: 'succeeded', - }, - ], - has_more: false, - }), - (error: unknown) => - error instanceof ContractValidationError && - error.message.includes('response.messages[0].id'), - ); - }); - - it('validates all TaskItemDTO fields, enums, UUIDs, and timestamps', () => { - assert.deepEqual(parseTaskItem(mockTaskItem), mockTaskItem); - assert.throws( - () => parseTaskItem({ ...mockTaskItem, status: 'pending' }), - (error: unknown) => - error instanceof ContractValidationError && - error.message.includes('response.items[].status'), - ); - assert.throws( - () => parseTaskItem({ ...mockTaskItem, id: 'not-a-uuid' }), - (error: unknown) => - error instanceof ContractValidationError && error.message.includes('response.items[].id'), - ); - assert.throws( - () => parseTaskItem({ ...mockTaskItem, start_at: '2026-07-24T15:00:00' }), - (error: unknown) => - error instanceof ContractValidationError && - error.message.includes('response.items[].start_at'), - ); - }); - - it('validates all LongGoalDTO fields and rejects undeclared fields', () => { - assert.deepEqual(parseLongGoal(mockLongGoal), mockLongGoal); - assert.throws( - () => parseLongGoal({ ...mockLongGoal, is_deleted: false }), - (error: unknown) => - error instanceof ContractValidationError && - error.message.includes('response.goals[].is_deleted'), - ); - }); - - it('validates WriteDecisionResult status and field completeness', () => { - assert.deepEqual(parseWriteDecisionResult(mockWriteDecisionResult), mockWriteDecisionResult); - assert.throws( - () => parseWriteDecisionResult({ ...mockWriteDecisionResult, status: 'pending' }), - (error: unknown) => - error instanceof ContractValidationError && error.message.includes('response.status'), - ); - }); - - it('accepts type/timestamp/payload and rejects the old WS envelope', () => { - assert.deepEqual(parseConversationServerEvent(mockProcessingEvent), mockProcessingEvent); - assert.throws( - () => - parseConversationServerEvent({ - event: 'processing', - sent_at: mockProcessingEvent.timestamp, - payload: mockProcessingEvent.payload, - }), - (error: unknown) => - error instanceof ContractValidationError && error.message.includes('event.type'), - ); - }); - - it('rejects undeclared fields inside a documented WS payload', () => { - assert.throws( - () => - parseConversationServerEvent({ - ...mockProcessingEvent, - payload: { - ...mockProcessingEvent.payload, - message: '旧协议字段', - }, - }), - (error: unknown) => - error instanceof ContractValidationError && error.message.includes('event.payload.message'), - ); - }); -}); diff --git a/frontend/tests/tsconfig.json b/frontend/tests/tsconfig.json deleted file mode 100644 index 343524f..0000000 --- a/frontend/tests/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "module": "Node16", - "moduleResolution": "Node16", - "noEmit": false, - "outDir": "../.contract-test-dist", - "rootDir": "..", - "types": ["node"] - }, - "include": ["../src/api/**/*.ts", "./api/**/*.ts"], - "exclude": [] -} From c8a66f90733b81d3dd45f5ea9cb190624d8d98fd Mon Sep 17 00:00:00 2001 From: zhenghaotao Date: Fri, 24 Jul 2026 16:22:59 +0800 Subject: [PATCH 3/3] fix(api)!: enforce strict contract validation Validate every business request before transport and require response parsers. Define and validate the complete task profile snapshot returned by goal details. BREAKING CHANGE: HttpClient.request and apiFetch now require a response parser. --- frontend/src/api/client.ts | 4 +- frontend/src/api/contracts/goal.ts | 17 +- frontend/src/api/contracts/validators.ts | 240 +++++++++++++++++++- frontend/src/api/core/http.ts | 34 ++- frontend/src/api/modules/authApi.ts | 15 +- frontend/src/api/modules/conversationApi.ts | 26 ++- frontend/src/api/modules/goalApi.ts | 19 +- frontend/src/api/modules/itemApi.ts | 7 +- frontend/src/api/modules/profileApi.ts | 7 +- frontend/src/api/modules/writeRequestApi.ts | 19 +- 10 files changed, 336 insertions(+), 52 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c747a85..d10549e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -9,8 +9,8 @@ import type { ContractParser } from './core/validation'; */ export function apiFetch( path: string, - options?: HttpRequestOptions, - parseData?: ContractParser, + options: HttpRequestOptions, + parseData: ContractParser, ): Promise { return httpClient.request(path, options, parseData); } diff --git a/frontend/src/api/contracts/goal.ts b/frontend/src/api/contracts/goal.ts index 379a9c8..378cd6e 100644 --- a/frontend/src/api/contracts/goal.ts +++ b/frontend/src/api/contracts/goal.ts @@ -1,4 +1,4 @@ -import type { ISODateTime, JsonObject, UUID } from './common'; +import type { ISODateTime, UUID } from './common'; import type { TaskItem } from './item'; export type LongGoalStatus = 'active' | 'completed' | 'cancelled'; @@ -32,14 +32,21 @@ export interface LongGoalDetailRequest { user_id: UUID; } +/** 长任务创建时确认落盘的任务级画像快照。 */ +export interface TaskProfileSnapshot { + id: UUID; + user_id: UUID; + long_goal_id: UUID; + profile_summary: string; + source_message_id: UUID; + version: number; +} + /** * `GET /long-goals/{goal_id}` 的完整响应。 - * - * 最新 Wiki 只命名了 `task_profile`,未冻结 UserProfileDTO 的内部字段, - * 因此该字段保持 JSON 对象,不把 Mock 内容升级成正式契约。 */ export interface LongGoalDetailResult { goal: LongGoal; subtasks: TaskItem[]; - task_profile: JsonObject | null; + task_profile: TaskProfileSnapshot | null; } diff --git a/frontend/src/api/contracts/validators.ts b/frontend/src/api/contracts/validators.ts index 1f39e42..53424cb 100644 --- a/frontend/src/api/contracts/validators.ts +++ b/frontend/src/api/contracts/validators.ts @@ -1,4 +1,4 @@ -import type { AuthSession, CurrentUser } from './auth'; +import type { AuthSession, CurrentUser, LoginRequest, RegisterRequest } from './auth'; import type { AgentName, ClarificationQuestion, @@ -8,16 +8,32 @@ import type { ConversationInputAccepted, ConversationMessage, ConversationMessageMetadata, + ConversationMessagesRequest, ConversationMessagesResult, ConversationServerEvent, ConversationSnapshot, + ConversationSnapshotRequest, + MediaConversationInputRequest, + TextConversationInputRequest, TimeRange, TransportSnapshotPayload, } from './conversation'; -import type { LongGoal, LongGoalDetailResult, LongGoalListResult } from './goal'; -import type { TaskItem, TimelineItemsResult } from './item'; -import type { UserProfile } from './profile'; -import type { CreateWriteRequestResult, WriteDecisionResult } from './writeRequest'; +import type { + LongGoal, + LongGoalDetailRequest, + LongGoalDetailResult, + LongGoalListRequest, + LongGoalListResult, + TaskProfileSnapshot, +} from './goal'; +import type { TaskItem, TimelineItemsRequest, TimelineItemsResult } from './item'; +import type { UserProfile, UserProfileRequest } from './profile'; +import type { + CreateWriteRequest, + CreateWriteRequestResult, + DecideWriteRequest, + WriteDecisionResult, +} from './writeRequest'; import { ContractValidationError, parseArray, @@ -79,6 +95,8 @@ const CLARIFICATION_REASONS = [ ] as const; const CLARIFICATION_MODES = ['single', 'batch'] as const; const WRITE_DECISIONS = ['confirmed', 'rejected'] as const; +const WRITE_ACTIONS = ['create', 'update', 'delete', 'feedback', 'replan', 'goal_split'] as const; +const WRITE_TARGET_TYPES = ['task_item', 'long_goal', 'feedback', 'profile'] as const; const WRITE_REQUEST_STATUSES = ['confirmed', 'rejected', 'applied', 'expired'] as const; const CLIENT_EVENT_TYPES = [ 'selection.submit', @@ -123,6 +141,21 @@ function parseStringArray(value: unknown, path: string): string[] { return parseArray(value, path, parseString); } +function parsePositiveInteger(value: unknown, path: string): number { + const parsed = parseInteger(value, path); + if (parsed < 1) { + throw new ContractValidationError(path, '必须是正整数'); + } + return parsed; +} + +function parseBlob(value: unknown, path: string): Blob { + if (!(value instanceof Blob)) { + throw new ContractValidationError(path, '必须是 Blob 文件'); + } + return value; +} + function parseEmptyObject(value: unknown, path: string): Record { parseObject(value, path, []); return {}; @@ -279,6 +312,174 @@ function parseConversationMessageMetadata( return result; } +/** 注册请求发送前校验,拒绝缺字段和 Wiki 未声明字段。 */ +export function parseRegisterRequest(value: unknown, path = 'request'): RegisterRequest { + const object = parseObject(value, path, ['email', 'password', 'display_name']); + return { + email: parseNonEmptyString(object.email, `${path}.email`), + password: parseNonEmptyString(object.password, `${path}.password`), + display_name: parseString(object.display_name, `${path}.display_name`), + }; +} + +/** 登录请求发送前校验。 */ +export function parseLoginRequest(value: unknown, path = 'request'): LoginRequest { + const object = parseObject(value, path, ['email', 'password']); + return { + email: parseNonEmptyString(object.email, `${path}.email`), + password: parseNonEmptyString(object.password, `${path}.password`), + }; +} + +/** 文本输入请求发送前校验,确保 raw_content 与 text 模态同时存在。 */ +export function parseTextConversationInputRequest( + value: unknown, + path = 'request', +): TextConversationInputRequest { + const object = parseObject( + value, + path, + ['client_message_id', 'modality', 'raw_content'], + ['reply_to_question_id'], + ); + return stripUndefined({ + client_message_id: parseNonEmptyString(object.client_message_id, `${path}.client_message_id`), + modality: parseEnum(object.modality, `${path}.modality`, ['text'] as const), + raw_content: parseNonEmptyString(object.raw_content, `${path}.raw_content`), + reply_to_question_id: parseOptional( + object.reply_to_question_id, + `${path}.reply_to_question_id`, + parseNonEmptyString, + ), + }); +} + +/** 图片或音频请求发送前校验;file_name 仅供 FormData 文件名使用。 */ +export function parseMediaConversationInputRequest( + value: unknown, + path = 'request', +): MediaConversationInputRequest { + const object = parseObject( + value, + path, + ['client_message_id', 'modality', 'file', 'file_name'], + ['reply_to_question_id'], + ); + return stripUndefined({ + client_message_id: parseNonEmptyString(object.client_message_id, `${path}.client_message_id`), + modality: parseEnum(object.modality, `${path}.modality`, ['image', 'audio'] as const), + file: parseBlob(object.file, `${path}.file`), + file_name: parseNonEmptyString(object.file_name, `${path}.file_name`), + reply_to_question_id: parseOptional( + object.reply_to_question_id, + `${path}.reply_to_question_id`, + parseNonEmptyString, + ), + }); +} + +/** 会话历史查询参数发送前校验。 */ +export function parseConversationMessagesRequest( + value: unknown, + path = 'request', +): ConversationMessagesRequest { + const object = parseObject(value, path, ['user_id'], ['before_message_id', 'limit']); + return stripUndefined({ + user_id: parseUuid(object.user_id, `${path}.user_id`), + before_message_id: parseOptional( + object.before_message_id, + `${path}.before_message_id`, + parseUuid, + ), + limit: parseOptional(object.limit, `${path}.limit`, parsePositiveInteger), + }); +} + +/** 页面静态快照查询参数发送前校验。 */ +export function parseConversationSnapshotRequest( + value: unknown, + path = 'request', +): ConversationSnapshotRequest { + const object = parseObject(value, path, ['user_id'], ['last_seen_message_id']); + return stripUndefined({ + user_id: parseUuid(object.user_id, `${path}.user_id`), + last_seen_message_id: parseOptional( + object.last_seen_message_id, + `${path}.last_seen_message_id`, + parseUuid, + ), + }); +} + +/** 时间线查询参数发送前校验。 */ +export function parseTimelineItemsRequest(value: unknown, path = 'request'): TimelineItemsRequest { + const object = parseObject(value, path, ['user_id', 'start_at', 'end_at'], ['item_type']); + return stripUndefined({ + user_id: parseUuid(object.user_id, `${path}.user_id`), + start_at: parseIsoDateTime(object.start_at, `${path}.start_at`), + end_at: parseIsoDateTime(object.end_at, `${path}.end_at`), + item_type: parseOptional(object.item_type, `${path}.item_type`, (item, itemPath) => + parseEnum(item, itemPath, ITEM_TYPES), + ), + }); +} + +/** 长目标列表查询参数发送前校验。 */ +export function parseLongGoalListRequest(value: unknown, path = 'request'): LongGoalListRequest { + const object = parseObject(value, path, ['user_id'], ['status']); + return stripUndefined({ + user_id: parseUuid(object.user_id, `${path}.user_id`), + status: parseOptional(object.status, `${path}.status`, (item, itemPath) => + parseEnum(item, itemPath, LONG_GOAL_STATUSES), + ), + }); +} + +/** 长目标详情查询参数发送前校验。 */ +export function parseLongGoalDetailRequest( + value: unknown, + path = 'request', +): LongGoalDetailRequest { + const object = parseObject(value, path, ['user_id']); + return { + user_id: parseUuid(object.user_id, `${path}.user_id`), + }; +} + +/** 用户画像查询参数发送前校验。 */ +export function parseUserProfileRequest(value: unknown, path = 'request'): UserProfileRequest { + const object = parseObject(value, path, ['user_id']); + return { + user_id: parseUuid(object.user_id, `${path}.user_id`), + }; +} + +/** 创建写入确认门禁的请求发送前校验。 */ +export function parseCreateWriteRequest(value: unknown, path = 'request'): CreateWriteRequest { + const object = parseObject( + value, + path, + ['action', 'target_type', 'payload', 'preview_text'], + ['target_id'], + ); + return stripUndefined({ + action: parseEnum(object.action, `${path}.action`, WRITE_ACTIONS), + target_type: parseEnum(object.target_type, `${path}.target_type`, WRITE_TARGET_TYPES), + target_id: parseOptional(object.target_id, `${path}.target_id`, parseUuid), + payload: parseJsonObject(object.payload, `${path}.payload`), + preview_text: parseNonEmptyString(object.preview_text, `${path}.preview_text`), + }); +} + +/** 确认或拒绝写入的请求发送前校验。 */ +export function parseDecideWriteRequest(value: unknown, path = 'request'): DecideWriteRequest { + const object = parseObject(value, path, ['decision', 'idempotency_key']); + return { + decision: parseEnum(object.decision, `${path}.decision`, WRITE_DECISIONS), + idempotency_key: parseNonEmptyString(object.idempotency_key, `${path}.idempotency_key`), + }; +} + export function parseAuthSession(value: unknown, path = 'response'): AuthSession { const object = parseObject(value, path, ['user_id', 'business_user_id', 'access_token']); return { @@ -437,12 +638,39 @@ export function parseLongGoalListResult(value: unknown, path = 'response'): Long }; } +/** 校验长任务创建时固化的任务画像快照。 */ +export function parseTaskProfileSnapshot( + value: unknown, + path = 'response.task_profile', +): TaskProfileSnapshot { + const object = parseObject(value, path, [ + 'id', + 'user_id', + 'long_goal_id', + 'profile_summary', + 'source_message_id', + 'version', + ]); + return { + id: parseUuid(object.id, `${path}.id`), + user_id: parseUuid(object.user_id, `${path}.user_id`), + long_goal_id: parseUuid(object.long_goal_id, `${path}.long_goal_id`), + profile_summary: parseNonEmptyString(object.profile_summary, `${path}.profile_summary`), + source_message_id: parseUuid(object.source_message_id, `${path}.source_message_id`), + version: parseInteger(object.version, `${path}.version`), + }; +} + export function parseLongGoalDetailResult(value: unknown, path = 'response'): LongGoalDetailResult { const object = parseObject(value, path, ['goal', 'subtasks', 'task_profile']); return { goal: parseLongGoal(object.goal, `${path}.goal`), subtasks: parseArray(object.subtasks, `${path}.subtasks`, parseTaskItem), - task_profile: parseNullable(object.task_profile, `${path}.task_profile`, parseJsonObject), + task_profile: parseNullable( + object.task_profile, + `${path}.task_profile`, + parseTaskProfileSnapshot, + ), }; } diff --git a/frontend/src/api/core/http.ts b/frontend/src/api/core/http.ts index 05cde76..f93f1a7 100644 --- a/frontend/src/api/core/http.ts +++ b/frontend/src/api/core/http.ts @@ -82,6 +82,28 @@ function invalidResponseError(status: number, reason?: string): ApiError { ); } +/** + * 在请求离开 SDK 前执行严格契约校验,并统一转换为可识别的客户端错误。 + */ +export function validateRequest(value: unknown, parser: ContractParser): T { + try { + return parser(value, 'request'); + } catch (error) { + throw new ApiError( + { + code: 'INVALID_REQUEST', + message: + error instanceof Error + ? `请求不符合统一接口契约:${error.message}` + : '请求不符合统一接口契约', + retryable: false, + stage: 'request_validation', + }, + { status: 400 }, + ); + } +} + export class HttpClient { private readonly baseUrl: string; private readonly fetchImpl: typeof globalThis.fetch; @@ -105,15 +127,11 @@ export class HttpClient { */ async request( path: string, - options: HttpRequestOptions = {}, - parseData?: ContractParser, + options: HttpRequestOptions, + parseData: ContractParser, ): Promise { const response = await this.raw(path, options); - if (response.status === 204) { - return undefined as T; - } - let rawResponse: unknown; try { @@ -141,10 +159,6 @@ export class HttpClient { ); } - if (!parseData) { - return rawResponse as T; - } - try { return parseData(rawResponse, 'response'); } catch (error) { diff --git a/frontend/src/api/modules/authApi.ts b/frontend/src/api/modules/authApi.ts index 0ae8c3d..f6a86cf 100644 --- a/frontend/src/api/modules/authApi.ts +++ b/frontend/src/api/modules/authApi.ts @@ -1,6 +1,11 @@ import type { AuthSession, CurrentUser, LoginRequest, RegisterRequest } from '../contracts/auth'; -import { parseAuthSession, parseCurrentUser } from '../contracts/validators'; -import { HttpClient, httpClient } from '../core/http'; +import { + parseAuthSession, + parseCurrentUser, + parseLoginRequest, + parseRegisterRequest, +} from '../contracts/validators'; +import { HttpClient, httpClient, validateRequest } from '../core/http'; /** 负责账号注册、登录和当前用户初始化查询。 */ export class AuthApi { @@ -8,11 +13,12 @@ export class AuthApi { /** 创建用户账号并返回登录令牌。对应 `POST /api/v1/auth/register`。 */ register(request: RegisterRequest): Promise { + const validatedRequest = validateRequest(request, parseRegisterRequest); return this.http.request( '/auth/register', { authenticated: false, - body: request, + body: validatedRequest, method: 'POST', }, parseAuthSession, @@ -21,11 +27,12 @@ export class AuthApi { /** 使用邮箱密码建立登录态。对应 `POST /api/v1/auth/login`。 */ login(request: LoginRequest): Promise { + const validatedRequest = validateRequest(request, parseLoginRequest); return this.http.request( '/auth/login', { authenticated: false, - body: request, + body: validatedRequest, method: 'POST', }, parseAuthSession, diff --git a/frontend/src/api/modules/conversationApi.ts b/frontend/src/api/modules/conversationApi.ts index d87543b..24da3e8 100644 --- a/frontend/src/api/modules/conversationApi.ts +++ b/frontend/src/api/modules/conversationApi.ts @@ -9,10 +9,14 @@ import type { } from '../contracts/conversation'; import { parseConversationInputAccepted, + parseConversationMessagesRequest, parseConversationMessagesResult, parseConversationSnapshot, + parseConversationSnapshotRequest, + parseMediaConversationInputRequest, + parseTextConversationInputRequest, } from '../contracts/validators'; -import { HttpClient, httpClient } from '../core/http'; +import { HttpClient, httpClient, validateRequest } from '../core/http'; /** 负责统一输入提交、聊天历史加载和页面状态恢复。 */ export class ConversationApi { @@ -23,10 +27,11 @@ export class ConversationApi { * 母 AI 的处理进度和最终结果随后通过 WebSocket 推送。 */ submitText(request: TextConversationInputRequest): Promise { + const validatedRequest = validateRequest(request, parseTextConversationInputRequest); return this.http.request( '/conversation/inputs', { - body: request, + body: validatedRequest, method: 'POST', }, parseConversationInputAccepted, @@ -38,12 +43,13 @@ export class ConversationApi { * 后端负责对象存储、生成 source_url,再调用 OCR 或 ASR。 */ submitMedia(request: MediaConversationInputRequest): Promise { + const validatedRequest = validateRequest(request, parseMediaConversationInputRequest); const formData = new FormData(); - formData.append('client_message_id', request.client_message_id); - formData.append('modality', request.modality); - formData.append('file', request.file, request.file_name); - if (request.reply_to_question_id) { - formData.append('reply_to_question_id', request.reply_to_question_id); + formData.append('client_message_id', validatedRequest.client_message_id); + formData.append('modality', validatedRequest.modality); + formData.append('file', validatedRequest.file, validatedRequest.file_name); + if (validatedRequest.reply_to_question_id) { + formData.append('reply_to_question_id', validatedRequest.reply_to_question_id); } return this.http.request( @@ -58,10 +64,11 @@ export class ConversationApi { /** 按消息 ID 向前分页加载聊天历史。对应 `GET /conversation/messages`。 */ getMessages(request: ConversationMessagesRequest): Promise { + const validatedRequest = validateRequest(request, parseConversationMessagesRequest); return this.http.request( '/conversation/messages', { - query: { ...request }, + query: { ...validatedRequest }, }, parseConversationMessagesResult, ); @@ -72,10 +79,11 @@ export class ConversationApi { * 该接口不替代 WS 断线后的 transport.resume。 */ getSnapshot(request: ConversationSnapshotRequest): Promise { + const validatedRequest = validateRequest(request, parseConversationSnapshotRequest); return this.http.request( '/conversation/snapshot', { - query: { ...request }, + query: { ...validatedRequest }, }, parseConversationSnapshot, ); diff --git a/frontend/src/api/modules/goalApi.ts b/frontend/src/api/modules/goalApi.ts index 6db6d54..d9d7225 100644 --- a/frontend/src/api/modules/goalApi.ts +++ b/frontend/src/api/modules/goalApi.ts @@ -5,8 +5,14 @@ import type { LongGoalListResult, } from '../contracts/goal'; import type { UUID } from '../contracts/common'; -import { parseLongGoalDetailResult, parseLongGoalListResult } from '../contracts/validators'; -import { HttpClient, httpClient } from '../core/http'; +import { + parseLongGoalDetailRequest, + parseLongGoalDetailResult, + parseLongGoalListRequest, + parseLongGoalListResult, +} from '../contracts/validators'; +import { HttpClient, httpClient, validateRequest } from '../core/http'; +import { parseUuid } from '../core/validation'; /** 负责长任务目标管理 Tab 的列表和详情查询。 */ export class GoalApi { @@ -14,10 +20,11 @@ export class GoalApi { /** 查询当前用户的长目标列表。对应 `GET /api/v1/long-goals`。 */ getLongGoals(request: LongGoalListRequest): Promise { + const validatedRequest = validateRequest(request, parseLongGoalListRequest); return this.http.request( '/long-goals', { - query: { ...request }, + query: { ...validatedRequest }, }, parseLongGoalListResult, ); @@ -25,10 +32,12 @@ export class GoalApi { /** 查询一个长目标、其子任务和任务画像。对应 `GET /long-goals/{goal_id}`。 */ getLongGoal(goalId: UUID, request: LongGoalDetailRequest): Promise { + const validatedGoalId = validateRequest(goalId, parseUuid); + const validatedRequest = validateRequest(request, parseLongGoalDetailRequest); return this.http.request( - `/long-goals/${encodeURIComponent(goalId)}`, + `/long-goals/${encodeURIComponent(validatedGoalId)}`, { - query: { ...request }, + query: { ...validatedRequest }, }, parseLongGoalDetailResult, ); diff --git a/frontend/src/api/modules/itemApi.ts b/frontend/src/api/modules/itemApi.ts index 4c7b6dc..9c172ea 100644 --- a/frontend/src/api/modules/itemApi.ts +++ b/frontend/src/api/modules/itemApi.ts @@ -1,6 +1,6 @@ import type { TimelineItemsRequest, TimelineItemsResult } from '../contracts/item'; -import { parseTimelineItemsResult } from '../contracts/validators'; -import { HttpClient, httpClient } from '../core/http'; +import { parseTimelineItemsRequest, parseTimelineItemsResult } from '../contracts/validators'; +import { HttpClient, httpClient, validateRequest } from '../core/http'; /** 负责时间顺序 Tab 的只读事项查询。 */ export class ItemApi { @@ -11,10 +11,11 @@ export class ItemApi { * 对应 `GET /api/v1/items/timeline`,不生成 AI 建议,也不修改事项。 */ getTimeline(request: TimelineItemsRequest): Promise { + const validatedRequest = validateRequest(request, parseTimelineItemsRequest); return this.http.request( '/items/timeline', { - query: { ...request }, + query: { ...validatedRequest }, }, parseTimelineItemsResult, ); diff --git a/frontend/src/api/modules/profileApi.ts b/frontend/src/api/modules/profileApi.ts index 751efe5..b005593 100644 --- a/frontend/src/api/modules/profileApi.ts +++ b/frontend/src/api/modules/profileApi.ts @@ -1,6 +1,6 @@ import type { UserProfile, UserProfileRequest } from '../contracts/profile'; -import { parseUserProfile } from '../contracts/validators'; -import { HttpClient, httpClient } from '../core/http'; +import { parseUserProfile, parseUserProfileRequest } from '../contracts/validators'; +import { HttpClient, httpClient, validateRequest } from '../core/http'; /** 负责个人主页的全局用户画像只读查询。 */ export class ProfileApi { @@ -11,10 +11,11 @@ export class ProfileApi { * 画像写入统一通过 WriteRequestApi 的确认门禁完成。 */ getUserProfile(request: UserProfileRequest): Promise { + const validatedRequest = validateRequest(request, parseUserProfileRequest); return this.http.request( '/user/profile', { - query: { ...request }, + query: { ...validatedRequest }, }, parseUserProfile, ); diff --git a/frontend/src/api/modules/writeRequestApi.ts b/frontend/src/api/modules/writeRequestApi.ts index 313adfd..42c05cc 100644 --- a/frontend/src/api/modules/writeRequestApi.ts +++ b/frontend/src/api/modules/writeRequestApi.ts @@ -5,8 +5,14 @@ import type { DecideWriteRequest, WriteDecisionResult, } from '../contracts/writeRequest'; -import { parseCreateWriteRequestResult, parseWriteDecisionResult } from '../contracts/validators'; -import { HttpClient, httpClient } from '../core/http'; +import { + parseCreateWriteRequest, + parseCreateWriteRequestResult, + parseDecideWriteRequest, + parseWriteDecisionResult, +} from '../contracts/validators'; +import { HttpClient, httpClient, validateRequest } from '../core/http'; +import { parseUuid } from '../core/validation'; /** 负责所有手动 CRUD 的统一写入确认门禁。 */ export class WriteRequestApi { @@ -17,10 +23,11 @@ export class WriteRequestApi { * 对应 `POST /api/v1/write-requests`。 */ create(request: CreateWriteRequest): Promise { + const validatedRequest = validateRequest(request, parseCreateWriteRequest); return this.http.request( '/write-requests', { - body: request, + body: validatedRequest, method: 'POST', }, parseCreateWriteRequestResult, @@ -32,10 +39,12 @@ export class WriteRequestApi { * 对应 `POST /api/v1/write-requests/{write_request_id}/decide`。 */ decide(writeRequestId: UUID, request: DecideWriteRequest): Promise { + const validatedWriteRequestId = validateRequest(writeRequestId, parseUuid); + const validatedRequest = validateRequest(request, parseDecideWriteRequest); return this.http.request( - `/write-requests/${encodeURIComponent(writeRequestId)}/decide`, + `/write-requests/${encodeURIComponent(validatedWriteRequestId)}/decide`, { - body: request, + body: validatedRequest, method: 'POST', }, parseWriteDecisionResult,