diff --git a/.gitignore b/.gitignore index 2afc0c9..8cae8af 100644 --- a/.gitignore +++ b/.gitignore @@ -285,6 +285,7 @@ infra/cloud/volcano/env/*.env .env.*.local .env.cloud .env.*.cloud +deploy/.env.prod # Misc *.class @@ -294,6 +295,8 @@ infra/cloud/volcano/env/*.env .history /logs/ backend/logs/ +*.tar.gz +*.tar # Docker volumes (local data) docker/supabase/volumes/db/data/ infra/docker/volumes/db/data/ diff --git a/AGENTS.md b/AGENTS.md index 5b25b17..459e6fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,53 +1,48 @@ -# Project Development Guide +# Project AGENTS Router -This file serves as the entry point for project development, directing to appropriate constraint files based on development context. +Root `AGENTS.md` is a navigation and global-constraint layer only. +Do not place backend/frontend implementation details here. -## Project Structure +## Rule Order -``` -social-app/ -├── apps/ # Flutter mobile app -├── backend/ # FastAPI backend service -├── infra/ # Infrastructure (Docker, deployment scripts) -└── docs/ # Documentation and design/planning artifacts -``` +Apply rules in this order: -## Rules Hierarchy +1. System/developer/platform safety instructions +2. Workspace global runtime rules (`AGENTS.md` and `rules/*` in workspace runtime config) +3. This file (routing + project-wide constraints) +4. Domain sub-rules: + - `backend/AGENTS.md` + - `apps/AGENTS.md` -Follow this hierarchy when developing: +If two rules conflict, use the stricter one. -``` -├── This file (root AGENTS.md) # Project-level entry -│ ├── backend/AGENTS.md # Backend-specific rules -│ └── apps/AGENTS.md # Frontend-specific rules -``` +## Mandatory Routing -## Development Guidance +- Any change under `backend/**` MUST follow `backend/AGENTS.md`. +- Any change under `apps/**` MUST follow `apps/AGENTS.md`. +- Cross-domain changes MUST satisfy all relevant sub-AGENTS together. +- Infrastructure-only changes under `infra/**` follow this file plus `infra/` conventions. -| Development Context | Follow Rules | -|--------------------|--------------| -| Backend Python dev | [backend/AGENTS.md](backend/AGENTS.md) | -| Flutter mobile dev | [apps/AGENTS.md](apps/AGENTS.md) | -| Infrastructure/ops | This file + infra/ directory conventions | -| API doc changes | Sync to `docs/runtime/runtime-route.md` | +## Development Context Mapping -## Backend Startup +| Context | Required Rule Set | +|---|---| +| Backend Python/FastAPI | `backend/AGENTS.md` | +| Flutter mobile app | `apps/AGENTS.md` | +| Backend + Flutter in one task | `backend/AGENTS.md` + `apps/AGENTS.md` | +| Infra/ops scripts | This file + `infra/` conventions | +| API contract/doc updates | Also sync `docs/runtime/runtime-route.md` | -**Always use `./infra/scripts/app.sh` to start/stop the backend.** Do not start uvicorn directly. -**Always use `./logs/*.log` to check the backend log output.** +## Project-Wide Constraints -## Git Workflow +- Default branch is `dev`; never develop directly on `main`. +- Preferred feature workflow: `git worktree add -b feature/xxx ../feature-xxx dev`. +- Never push remote changes unless the user explicitly requests it. +- Keep AGENTS chain lean: put domain details in sub-AGENTS, avoid duplicate rules across layers. -- Default branch: `dev` -- Feature development: use worktree `git worktree add -b feature/xxx ../feature-xxx dev` -- Never develop directly on `main` -- **Never push to remote unless explicitly requested by user** +## Skills Index -## Skills (Domain Knowledge) +- `ag-ui`: AG-UI protocol implementation guidance. +- `agentscope-skill`: AgentScope framework guidance. -| Skill | Purpose | When to Use | -|-------|---------|-------------| -| **ag-ui** | AG-UI protocol for agent-user interaction | Agent chat, streaming events, tool calls, state sync | -| **agentscope-skill** | AgentScope framework reference and examples | AgentScope multi-agent orchestration, API usage, implementation patterns | - -**Usage**: Reference skills by name (e.g., "use the `ag-ui` skill") in development rules. Skills provide complete documentation, examples, and best practices. +Skill invocation and process routing are governed by workspace runtime rules. diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 2f39e4c..98a19d4 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -2,6 +2,13 @@ This document defines **hard constraints** for Flutter mobile development. Treat all items as **non-negotiable** unless explicitly overridden. +## 0) Scope and Precedence (MUST) + +- This file applies to all changes under `apps/**`. +- It extends root routing rules in `AGENTS.md` and workspace global runtime rules. +- If rules conflict, apply the stricter requirement. +- Keep Flutter-specific constraints in this file; avoid duplicating them in root `AGENTS.md`. + ## 1) Design Tokens (MUST) - **MUST** use design tokens from `apps/lib/core/theme/design_tokens.dart`: @@ -32,13 +39,17 @@ This document defines **hard constraints** for Flutter mobile development. Treat - If persistent header/footer regions exist, **MUST** center primary content within the remaining usable region. - **MUST** prioritize *visual centering* over purely geometric centering when they differ. -## 5) Quality Gate for Important Screens (MUST) +## 5) Testing Strategy (MUST) -For important screens: +Follow lightweight testing strategy - prioritize value over coverage: -- **MUST** add widget tests to reduce layout regression risk: - - Verify primary content stays centered relative to the usable viewport. - - Include at least one constrained scenario (e.g., small height **or** large text scale). +**Write tests for:** +- Model / DTO parsing (json → model) +- Service layer logic (business rules, API call handling) +- Complex custom widgets with rich interactions + +**Skip for:** +- Simple UI pages, regular buttons, basic layouts ## 6) UI Feedback System (MUST) @@ -62,9 +73,3 @@ Agent chat functionality **MUST** follow the AG-UI protocol. **Use the `ag-ui` s - **MUST NOT** return non-streaming responses for agent chat. - **MUST NOT** omit required lifecycle events. - **MUST NOT** use non-AG-UI event formats (except where the spec explicitly allows). - -## 8) Debugging Behavior (MUST) - -- **MUST NOT** automatically start Flutter app debugging or running. -- After code changes, **MUST** instruct the user to run manually (user-controlled): - - `flutter run --dart-define=MOCK_API=true -d emulator-5554` diff --git a/apps/lib/core/api/mock_api_client.dart b/apps/lib/core/api/mock_api_client.dart deleted file mode 100644 index 5179fdf..0000000 --- a/apps/lib/core/api/mock_api_client.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:dio/dio.dart'; -import 'i_api_client.dart'; - -class MockRequest { - final String path; - final String method; - final dynamic data; - final Options? options; - final Map? headers; - - MockRequest({ - required this.path, - required this.method, - this.data, - this.options, - this.headers, - }); -} - -typedef MockHandler = dynamic Function(MockRequest request); - -class _PatternRoute { - final RegExp pattern; - final String method; - final MockHandler handler; - - _PatternRoute({ - required this.pattern, - required this.method, - required this.handler, - }); -} - -class MockApiClient implements IApiClient { - final Map _handlers = {}; - final List<_PatternRoute> _patternHandlers = []; - - void registerHandler(String path, String method, MockHandler handler) { - final key = '$path:$method'; - _handlers[key] = handler; - } - - void registerPatternHandler( - RegExp pattern, - String method, - MockHandler handler, - ) { - _patternHandlers.add( - _PatternRoute( - pattern: pattern, - method: method.toUpperCase(), - handler: handler, - ), - ); - } - - void clearMocks() { - _handlers.clear(); - _patternHandlers.clear(); - } - - @override - Future> get(String path, {Options? options}) async { - return _handleRequest('GET', path, options: options); - } - - @override - Future> post( - String path, { - dynamic data, - Options? options, - }) async { - return _handleRequest('POST', path, data: data, options: options); - } - - @override - Future> patch( - String path, { - dynamic data, - Options? options, - }) async { - return _handleRequest('PATCH', path, data: data, options: options); - } - - @override - Future> delete( - String path, { - dynamic data, - Options? options, - }) async { - return _handleRequest('DELETE', path, data: data, options: options); - } - - @override - Future> getSseLines( - String path, { - Map? headers, - }) async { - final key = '$path:SSE'; - final direct = _handlers[key]; - if (direct != null) { - final response = direct( - MockRequest(path: path, method: 'SSE', headers: headers), - ); - if (response is Stream) { - return response; - } - if (response is Iterable) { - return Stream.fromIterable(response); - } - return const Stream.empty(); - } - for (final route in _patternHandlers) { - if (route.method != 'SSE') { - continue; - } - if (!route.pattern.hasMatch(path)) { - continue; - } - final response = route.handler( - MockRequest(path: path, method: 'SSE', headers: headers), - ); - if (response is Stream) { - return response; - } - if (response is Iterable) { - return Stream.fromIterable(response); - } - return const Stream.empty(); - } - return const Stream.empty(); - } - - Future> _handleRequest( - String method, - String path, { - dynamic data, - Options? options, - }) async { - await Future.delayed(const Duration(milliseconds: 200)); - - final handler = _resolveHandler(path: path, method: method); - - if (handler != null) { - final response = handler( - MockRequest(path: path, method: method, data: data, options: options), - ); - if (response is Response) { - return response as Response; - } - return Response( - data: response as T?, - statusCode: 200, - requestOptions: RequestOptions(path: path), - ); - } - - return Response( - data: null, - statusCode: 404, - requestOptions: RequestOptions(path: path), - ); - } - - MockHandler? _resolveHandler({required String path, required String method}) { - final key = '$path:$method'; - final direct = _handlers[key]; - if (direct != null) { - return direct; - } - for (final route in _patternHandlers) { - if (route.method != method.toUpperCase()) { - continue; - } - if (route.pattern.hasMatch(path)) { - return route.handler; - } - } - return null; - } -} diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index f300f64..54d6a58 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -2,8 +2,10 @@ import 'dart:io'; class Env { static String get apiUrl { - const url = String.fromEnvironment('API_URL'); - if (url.isNotEmpty) return url; + final backendUrl = const String.fromEnvironment('BACKEND_URL'); + if (backendUrl.isNotEmpty && backendUrl != 'false') { + return backendUrl; + } if (Platform.isAndroid) { return 'http://192.168.1.25:5775'; } diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index e9d326b..ec8babf 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -3,7 +3,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get_it/get_it.dart'; import '../api/api_client.dart'; import '../api/i_api_client.dart'; -import '../api/mock_api_client.dart'; import '../storage/token_storage.dart'; import '../config/env.dart'; import '../notifications/local_notification_service.dart'; @@ -12,7 +11,7 @@ import '../../features/auth/data/auth_repository.dart'; import '../../features/auth/data/auth_repository_impl.dart'; import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../../features/calendar/data/calendar_api.dart'; -import '../../features/calendar/data/services/mock_calendar_service.dart'; +import '../../features/calendar/data/services/calendar_service.dart'; import '../../features/calendar/ui/calendar_state_manager.dart'; import '../../features/friends/data/friends_api.dart'; import '../../features/messages/data/inbox_api.dart'; @@ -29,18 +28,13 @@ Future configureDependencies() async { final IApiClient apiClient; final SecureTokenStorage tokenStorage; - if (Env.isMockApi) { - apiClient = MockApiClient(); - tokenStorage = SecureTokenStorage(const FlutterSecureStorage()); - } else { - final dio = Dio(BaseOptions(baseUrl: Env.apiUrl)); - tokenStorage = SecureTokenStorage(const FlutterSecureStorage()); - apiClient = ApiClient( - baseUrl: Env.apiUrl, - tokenStorage: tokenStorage, - dio: dio, - ); - } + final dio = Dio(BaseOptions(baseUrl: Env.apiUrl)); + tokenStorage = SecureTokenStorage(const FlutterSecureStorage()); + apiClient = ApiClient( + baseUrl: Env.apiUrl, + tokenStorage: tokenStorage, + dio: dio, + ); sl.registerSingleton(apiClient); @@ -53,9 +47,7 @@ Future configureDependencies() async { final calendarApi = CalendarApi(apiClient); sl.registerSingleton(calendarApi); - final calendarService = CalendarService( - apiClient: Env.isMockApi ? null : apiClient, - ); + final calendarService = CalendarService(apiClient: apiClient); sl.registerSingleton(calendarService); sl.registerSingleton(LocalNotificationService()); @@ -72,24 +64,20 @@ Future configureDependencies() async { final authRepository = AuthRepositoryImpl( api: authApi, tokenStorage: tokenStorage, - onLogout: Env.isMockApi - ? null - : () async { - (apiClient as ApiClient).resetInterceptor(); - }, + onLogout: () async { + (apiClient as ApiClient).resetInterceptor(); + }, ); sl.registerSingleton(authRepository); - if (!Env.isMockApi) { - (apiClient as ApiClient).setRefreshCallback((token) async { - try { - await authRepository.refreshSession(token); - return true; - } catch (_) { - return false; - } - }); - } + (apiClient as ApiClient).setRefreshCallback((token) async { + try { + await authRepository.refreshSession(token); + return true; + } catch (_) { + return false; + } + }); sl.registerSingleton(AuthBloc(authRepository)); sl.registerSingleton(CalendarStateManager()); diff --git a/apps/lib/core/schemas/ui_schema.dart b/apps/lib/core/schemas/ui_schema.dart new file mode 100644 index 0000000..2391674 --- /dev/null +++ b/apps/lib/core/schemas/ui_schema.dart @@ -0,0 +1,1367 @@ +// UI Schema Protocol Implementation. +/// +/// This file is the single source of truth for UI Schema. +/// All implementations must follow docs/protocols/ui-schema.md. +/// +/// Version: 1.0 +library; + +// ========== Enums ========== + +enum SchemaType { + toolResult('tool_result'), + agentResponse('agent_response'), + notification('notification'); + + final String value; + const SchemaType(this.value); +} + +enum UiStatus { + info('info'), + success('success'), + warning('warning'), + error('error'), + pending('pending'); + + final String value; + const UiStatus(this.value); +} + +enum IconSource { + icon('icon'), + emoji('emoji'), + url('url'); + + final String value; + const IconSource(this.value); +} + +enum OperationType { + create('create'), + update('update'), + delete('delete'), + execute('execute'); + + final String value; + const OperationType(this.value); +} + +enum OperationResult { + success('success'), + failure('failure'), + partial('partial'); + + final String value; + const OperationResult(this.value); +} + +enum ContainerDirection { + vertical('vertical'), + horizontal('horizontal'); + + final String value; + const ContainerDirection(this.value); +} + +enum TextFormat { + plain('plain'), + markdown('markdown'); + + final String value; + const TextFormat(this.value); +} + +enum KvLayout { + vertical('vertical'), + horizontal('horizontal'), + grid('grid'); + + final String value; + const KvLayout(this.value); +} + +enum BadgeVariant { + def('default'), + success('success'), + warning('warning'), + error('error'), + info('info'); + + final String value; + const BadgeVariant(this.value); +} + +enum ActionStyle { + primary('primary'), + secondary('secondary'), + ghost('ghost'), + danger('danger'); + + final String value; + const ActionStyle(this.value); +} + +enum RendererTheme { + def('default'), + dark('dark'), + light('light'); + + final String value; + const RendererTheme(this.value); +} + +// ========== Common Types ========== + +class UiIcon { + final IconSource source; + final String value; + final String? color; + final int? size; + + const UiIcon({ + required this.source, + required this.value, + this.color, + this.size, + }); + + factory UiIcon.fromJson(Map json) { + return UiIcon( + source: IconSource.values.firstWhere( + (e) => e.value == json['source'], + orElse: () => IconSource.icon, + ), + value: json['value'] as String? ?? '', + color: json['color'] as String?, + size: json['size'] as int?, + ); + } + + Map toJson() { + return { + 'source': source.value, + 'value': value, + if (color != null) 'color': color, + if (size != null) 'size': size, + }; + } +} + +class UiBadge { + final String label; + final BadgeVariant variant; + + const UiBadge({required this.label, this.variant = BadgeVariant.def}); + + factory UiBadge.fromJson(Map json) { + return UiBadge( + label: json['label'] as String? ?? '', + variant: BadgeVariant.values.firstWhere( + (e) => e.value == json['variant'], + orElse: () => BadgeVariant.def, + ), + ); + } + + Map toJson() { + return {'label': label, 'variant': variant.value}; + } +} + +class Pagination { + final int page; + final int pageSize; + final int total; + final bool hasMore; + + const Pagination({ + required this.page, + required this.pageSize, + required this.total, + required this.hasMore, + }); + + factory Pagination.fromJson(Map json) { + return Pagination( + page: json['page'] as int? ?? 1, + pageSize: json['pageSize'] as int? ?? 20, + total: json['total'] as int? ?? 0, + hasMore: json['hasMore'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'page': page, + 'pageSize': pageSize, + 'total': total, + 'hasMore': hasMore, + }; + } +} + +class ActionConfirm { + final String? title; + final String? message; + final String? confirmLabel; + final String? cancelLabel; + + const ActionConfirm({ + this.title, + this.message, + this.confirmLabel, + this.cancelLabel, + }); + + factory ActionConfirm.fromJson(Map json) { + return ActionConfirm( + title: json['title'] as String?, + message: json['message'] as String?, + confirmLabel: json['confirmLabel'] as String?, + cancelLabel: json['cancelLabel'] as String?, + ); + } + + Map toJson() { + return { + if (title != null) 'title': title, + if (message != null) 'message': message, + if (confirmLabel != null) 'confirmLabel': confirmLabel, + if (cancelLabel != null) 'cancelLabel': cancelLabel, + }; + } +} + +class KeyValuePair { + final String key; + final String? label; + final dynamic value; + final bool? copyable; + + const KeyValuePair({ + required this.key, + this.label, + required this.value, + this.copyable, + }); + + factory KeyValuePair.fromJson(Map json) { + return KeyValuePair( + key: json['key'] as String? ?? '', + label: json['label'] as String?, + value: json['value'], + copyable: json['copyable'] as bool?, + ); + } + + Map toJson() { + return { + 'key': key, + if (label != null) 'label': label, + 'value': value, + if (copyable != null) 'copyable': copyable, + }; + } +} + +class TableColumn { + final String key; + final String label; + final String? width; + final String? align; + + const TableColumn({ + required this.key, + required this.label, + this.width, + this.align, + }); + + factory TableColumn.fromJson(Map json) { + return TableColumn( + key: json['key'] as String? ?? '', + label: json['label'] as String? ?? '', + width: json['width'] as String?, + align: json['align'] as String?, + ); + } + + Map toJson() { + return { + 'key': key, + 'label': label, + if (width != null) 'width': width, + if (align != null) 'align': align, + }; + } +} + +class TableRow { + final String id; + final Map cells; + final Map? metadata; + final List? actions; + + const TableRow({ + required this.id, + required this.cells, + this.metadata, + this.actions, + }); + + factory TableRow.fromJson(Map json) { + return TableRow( + id: json['id'] as String? ?? '', + cells: json['cells'] as Map? ?? {}, + metadata: json['metadata'] as Map?, + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'cells': cells, + if (metadata != null) 'metadata': metadata, + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +class ListItem { + final String id; + final String title; + final String? subtitle; + final String? description; + final UiIcon? icon; + final UiBadge? badge; + final Map? metadata; + final List? actions; + + const ListItem({ + required this.id, + required this.title, + this.subtitle, + this.description, + this.icon, + this.badge, + this.metadata, + this.actions, + }); + + factory ListItem.fromJson(Map json) { + return ListItem( + id: json['id'] as String? ?? '', + title: json['title'] as String? ?? '', + subtitle: json['subtitle'] as String?, + description: json['description'] as String?, + icon: json['icon'] != null + ? UiIcon.fromJson(json['icon'] as Map) + : null, + badge: json['badge'] != null + ? UiBadge.fromJson(json['badge'] as Map) + : null, + metadata: json['metadata'] as Map?, + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + if (subtitle != null) 'subtitle': subtitle, + if (description != null) 'description': description, + if (icon != null) 'icon': icon!.toJson(), + if (badge != null) 'badge': badge!.toJson(), + if (metadata != null) 'metadata': metadata, + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +// ========== Action Types ========== + +abstract class ActionSpec { + String get type; + Map toJson(); +} + +class NavigateAction implements ActionSpec { + final String path; + final Map? params; + + const NavigateAction({required this.path, this.params}); + + @override + String get type => 'navigation'; + + @override + Map toJson() { + return {'type': type, 'path': path, if (params != null) 'params': params}; + } +} + +class LinkAction implements ActionSpec { + final String url; + final String? target; + + const LinkAction({required this.url, this.target}); + + @override + String get type => 'url'; + + @override + Map toJson() { + return {'type': type, 'url': url, if (target != null) 'target': target}; + } +} + +class EventAction implements ActionSpec { + final String event; + final Map? payload; + + const EventAction({required this.event, this.payload}); + + @override + String get type => 'event'; + + @override + Map toJson() { + return { + 'type': type, + 'event': event, + if (payload != null) 'payload': payload, + }; + } +} + +class ToolAction implements ActionSpec { + final String toolId; + final Map? params; + + const ToolAction({required this.toolId, this.params}); + + @override + String get type => 'tool'; + + @override + Map toJson() { + return { + 'type': type, + 'toolId': toolId, + if (params != null) 'params': params, + }; + } +} + +class CopyAction implements ActionSpec { + final String content; + final String? successMessage; + + const CopyAction({required this.content, this.successMessage}); + + @override + String get type => 'copy'; + + @override + Map toJson() { + return { + 'type': type, + 'content': content, + if (successMessage != null) 'successMessage': successMessage, + }; + } +} + +class PayloadAction implements ActionSpec { + final Map payload; + final String? submitTo; + + const PayloadAction({required this.payload, this.submitTo}); + + @override + String get type => 'payload'; + + @override + Map toJson() { + return { + 'type': type, + 'payload': payload, + if (submitTo != null) 'submitTo': submitTo, + }; + } +} + +ActionSpec actionSpecFromJson(Map json) { + final type = json['type'] as String? ?? ''; + switch (type) { + case 'navigation': + return NavigateAction( + path: json['path'] as String? ?? '', + params: json['params'] as Map?, + ); + case 'url': + return LinkAction( + url: json['url'] as String? ?? '', + target: json['target'] as String?, + ); + case 'event': + return EventAction( + event: json['event'] as String? ?? '', + payload: json['payload'] as Map?, + ); + case 'tool': + return ToolAction( + toolId: json['toolId'] as String? ?? '', + params: json['params'] as Map?, + ); + case 'copy': + return CopyAction( + content: json['content'] as String? ?? '', + successMessage: json['successMessage'] as String?, + ); + case 'payload': + return PayloadAction( + payload: json['payload'] as Map? ?? {}, + submitTo: json['submitTo'] as String?, + ); + default: + return EventAction(event: 'unknown'); + } +} + +// ========== Action ========== + +class UiAction { + final String id; + final String label; + final UiIcon? icon; + final ActionStyle? style; + final bool disabled; + final ActionSpec action; + final ActionConfirm? confirm; + + const UiAction({ + required this.id, + required this.label, + this.icon, + this.style, + this.disabled = false, + required this.action, + this.confirm, + }); + + factory UiAction.fromJson(Map json) { + return UiAction( + id: json['id'] as String? ?? '', + label: json['label'] as String? ?? '', + icon: json['icon'] != null + ? UiIcon.fromJson(json['icon'] as Map) + : null, + style: json['style'] != null + ? ActionStyle.values.firstWhere( + (e) => e.value == json['style'], + orElse: () => ActionStyle.primary, + ) + : null, + disabled: json['disabled'] as bool? ?? false, + action: actionSpecFromJson( + json['action'] as Map? ?? {'type': 'event'}, + ), + confirm: json['confirm'] != null + ? ActionConfirm.fromJson(json['confirm'] as Map) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'label': label, + if (icon != null) 'icon': icon!.toJson(), + if (style != null) 'style': style!.value, + 'disabled': disabled, + 'action': action.toJson(), + if (confirm != null) 'confirm': confirm!.toJson(), + }; + } +} + +// ========== Node Types ========== + +abstract class UiNode { + String get type; + String? get id; + + factory UiNode.fromJson(Map json) { + final type = json['type'] as String? ?? ''; + switch (type) { + case 'text': + return UiTextNode.fromJson(json); + case 'card': + return UiCardNode.fromJson(json); + case 'list': + return UiListNode.fromJson(json); + case 'table': + return UiTableNode.fromJson(json); + case 'kv': + return UiKvNode.fromJson(json); + case 'operation': + return UiOperationNode.fromJson(json); + case 'error': + return UiErrorNode.fromJson(json); + case 'container': + return UiContainerNode.fromJson(json); + default: + return UiTextNode(content: 'Unknown node type: $type'); + } + } +} + +class UiTextNode implements UiNode { + @override + final String? id; + @override + String get type => 'text'; + final String content; + final TextFormat format; + final UiIcon? icon; + final List? actions; + + const UiTextNode({ + this.id, + required this.content, + this.format = TextFormat.plain, + this.icon, + this.actions, + }); + + factory UiTextNode.fromJson(Map json) { + return UiTextNode( + id: json['id'] as String?, + content: json['content'] as String? ?? '', + format: TextFormat.values.firstWhere( + (e) => e.value == json['format'], + orElse: () => TextFormat.plain, + ), + icon: json['icon'] != null + ? UiIcon.fromJson(json['icon'] as Map) + : null, + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + 'content': content, + 'format': format.value, + if (icon != null) 'icon': icon!.toJson(), + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +class UiCardNode implements UiNode { + @override + final String? id; + @override + String get type => 'card'; + final String? title; + final String? description; + final UiIcon? icon; + final UiStatus? status; + final String? timestamp; + final List? children; + final UiTextNode? footer; + final Map? extensions; + final List? actions; + + const UiCardNode({ + this.id, + this.title, + this.description, + this.icon, + this.status, + this.timestamp, + this.children, + this.footer, + this.extensions, + this.actions, + }); + + factory UiCardNode.fromJson(Map json) { + return UiCardNode( + id: json['id'] as String?, + title: json['title'] as String?, + description: json['description'] as String?, + icon: json['icon'] != null + ? UiIcon.fromJson(json['icon'] as Map) + : null, + status: json['status'] != null + ? UiStatus.values.firstWhere( + (e) => e.value == json['status'], + orElse: () => UiStatus.info, + ) + : null, + timestamp: json['timestamp'] as String?, + children: (json['children'] as List?) + ?.map((e) => UiNode.fromJson(e as Map)) + .toList(), + footer: json['footer'] != null + ? UiTextNode.fromJson(json['footer'] as Map) + : null, + extensions: json['extensions'] as Map?, + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (icon != null) 'icon': icon!.toJson(), + if (status != null) 'status': status!.value, + if (timestamp != null) 'timestamp': timestamp, + if (children != null) + 'children': children!.map((e) => (e as dynamic).toJson()).toList(), + if (footer != null) 'footer': footer!.toJson(), + if (extensions != null) 'extensions': extensions, + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +class UiListNode implements UiNode { + @override + final String? id; + @override + String get type => 'list'; + final String? title; + final String? description; + final UiIcon? icon; + final UiStatus? status; + final List items; + final Pagination? pagination; + final String? emptyText; + final Map? extensions; + final List? actions; + + const UiListNode({ + this.id, + this.title, + this.description, + this.icon, + this.status, + required this.items, + this.pagination, + this.emptyText, + this.extensions, + this.actions, + }); + + factory UiListNode.fromJson(Map json) { + return UiListNode( + id: json['id'] as String?, + title: json['title'] as String?, + description: json['description'] as String?, + icon: json['icon'] != null + ? UiIcon.fromJson(json['icon'] as Map) + : null, + status: json['status'] != null + ? UiStatus.values.firstWhere( + (e) => e.value == json['status'], + orElse: () => UiStatus.info, + ) + : null, + items: + (json['items'] as List?) + ?.map((e) => ListItem.fromJson(e as Map)) + .toList() ?? + [], + pagination: json['pagination'] != null + ? Pagination.fromJson(json['pagination'] as Map) + : null, + emptyText: json['emptyText'] as String?, + extensions: json['extensions'] as Map?, + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (icon != null) 'icon': icon!.toJson(), + if (status != null) 'status': status!.value, + 'items': items.map((e) => e.toJson()).toList(), + if (pagination != null) 'pagination': pagination!.toJson(), + if (emptyText != null) 'emptyText': emptyText, + if (extensions != null) 'extensions': extensions, + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +class UiTableNode implements UiNode { + @override + final String? id; + @override + String get type => 'table'; + final String? title; + final String? description; + final UiIcon? icon; + final UiStatus? status; + final List columns; + final List rows; + final Pagination? pagination; + final Map? extensions; + final List? actions; + + const UiTableNode({ + this.id, + this.title, + this.description, + this.icon, + this.status, + required this.columns, + required this.rows, + this.pagination, + this.extensions, + this.actions, + }); + + factory UiTableNode.fromJson(Map json) { + return UiTableNode( + id: json['id'] as String?, + title: json['title'] as String?, + description: json['description'] as String?, + icon: json['icon'] != null + ? UiIcon.fromJson(json['icon'] as Map) + : null, + status: json['status'] != null + ? UiStatus.values.firstWhere( + (e) => e.value == json['status'], + orElse: () => UiStatus.info, + ) + : null, + columns: + (json['columns'] as List?) + ?.map((e) => TableColumn.fromJson(e as Map)) + .toList() ?? + [], + rows: + (json['rows'] as List?) + ?.map((e) => TableRow.fromJson(e as Map)) + .toList() ?? + [], + pagination: json['pagination'] != null + ? Pagination.fromJson(json['pagination'] as Map) + : null, + extensions: json['extensions'] as Map?, + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (icon != null) 'icon': icon!.toJson(), + if (status != null) 'status': status!.value, + 'columns': columns.map((e) => e.toJson()).toList(), + 'rows': rows.map((e) => e.toJson()).toList(), + if (pagination != null) 'pagination': pagination!.toJson(), + if (extensions != null) 'extensions': extensions, + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +class UiKvNode implements UiNode { + @override + final String? id; + @override + String get type => 'kv'; + final String? title; + final String? description; + final UiIcon? icon; + final UiStatus? status; + final List pairs; + final KvLayout layout; + final Map? extensions; + final List? actions; + + const UiKvNode({ + this.id, + this.title, + this.description, + this.icon, + this.status, + required this.pairs, + this.layout = KvLayout.vertical, + this.extensions, + this.actions, + }); + + factory UiKvNode.fromJson(Map json) { + return UiKvNode( + id: json['id'] as String?, + title: json['title'] as String?, + description: json['description'] as String?, + icon: json['icon'] != null + ? UiIcon.fromJson(json['icon'] as Map) + : null, + status: json['status'] != null + ? UiStatus.values.firstWhere( + (e) => e.value == json['status'], + orElse: () => UiStatus.info, + ) + : null, + pairs: + (json['pairs'] as List?) + ?.map((e) => KeyValuePair.fromJson(e as Map)) + .toList() ?? + [], + layout: KvLayout.values.firstWhere( + (e) => e.value == json['layout'], + orElse: () => KvLayout.vertical, + ), + extensions: json['extensions'] as Map?, + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (icon != null) 'icon': icon!.toJson(), + if (status != null) 'status': status!.value, + 'pairs': pairs.map((e) => e.toJson()).toList(), + 'layout': layout.value, + if (extensions != null) 'extensions': extensions, + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +class UiOperationNode implements UiNode { + @override + final String? id; + @override + String get type => 'operation'; + final String? title; + final String? description; + final UiIcon? icon; + final UiStatus? status; + final OperationType operation; + final OperationResult result; + final String? message; + final int? affectedCount; + final UiNode? details; + final UiAction? rollback; + final Map? extensions; + final List? actions; + + const UiOperationNode({ + this.id, + this.title, + this.description, + this.icon, + this.status, + required this.operation, + required this.result, + this.message, + this.affectedCount, + this.details, + this.rollback, + this.extensions, + this.actions, + }); + + factory UiOperationNode.fromJson(Map json) { + return UiOperationNode( + id: json['id'] as String?, + title: json['title'] as String?, + description: json['description'] as String?, + icon: json['icon'] != null + ? UiIcon.fromJson(json['icon'] as Map) + : null, + status: json['status'] != null + ? UiStatus.values.firstWhere( + (e) => e.value == json['status'], + orElse: () => UiStatus.info, + ) + : null, + operation: OperationType.values.firstWhere( + (e) => e.value == json['operation'], + orElse: () => OperationType.execute, + ), + result: OperationResult.values.firstWhere( + (e) => e.value == json['result'], + orElse: () => OperationResult.failure, + ), + message: json['message'] as String?, + affectedCount: json['affectedCount'] as int?, + details: json['details'] != null + ? UiNode.fromJson(json['details'] as Map) + : null, + rollback: json['rollback'] != null + ? UiAction.fromJson(json['rollback'] as Map) + : null, + extensions: json['extensions'] as Map?, + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (icon != null) 'icon': icon!.toJson(), + if (status != null) 'status': status!.value, + 'operation': operation.value, + 'result': result.value, + if (message != null) 'message': message, + if (affectedCount != null) 'affectedCount': affectedCount, + if (details != null) 'details': (details as dynamic).toJson(), + if (rollback != null) 'rollback': rollback!.toJson(), + if (extensions != null) 'extensions': extensions, + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +class UiErrorNode implements UiNode { + @override + final String? id; + @override + String get type => 'error'; + final String? title; + final UiIcon? icon; + final String errorCode; + final String message; + final String? details; + final String? stack; + final bool retryable; + final List? suggestions; + final UiAction? retry; + final UiAction? support; + final List? actions; + + const UiErrorNode({ + this.id, + this.title, + this.icon, + required this.errorCode, + required this.message, + this.details, + this.stack, + this.retryable = false, + this.suggestions, + this.retry, + this.support, + this.actions, + }); + + factory UiErrorNode.fromJson(Map json) { + return UiErrorNode( + id: json['id'] as String?, + title: json['title'] as String?, + icon: json['icon'] != null + ? UiIcon.fromJson(json['icon'] as Map) + : null, + errorCode: json['errorCode'] as String? ?? 'UNKNOWN', + message: json['message'] as String? ?? 'An error occurred', + details: json['details'] as String?, + stack: json['stack'] as String?, + retryable: json['retryable'] as bool? ?? false, + suggestions: (json['suggestions'] as List?) + ?.map((e) => e as String) + .toList(), + retry: json['retry'] != null + ? UiAction.fromJson(json['retry'] as Map) + : null, + support: json['support'] != null + ? UiAction.fromJson(json['support'] as Map) + : null, + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + if (title != null) 'title': title, + if (icon != null) 'icon': icon!.toJson(), + 'errorCode': errorCode, + 'message': message, + if (details != null) 'details': details, + if (stack != null) 'stack': stack, + 'retryable': retryable, + if (suggestions != null) 'suggestions': suggestions, + if (retry != null) 'retry': retry!.toJson(), + if (support != null) 'support': support!.toJson(), + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +class UiContainerNode implements UiNode { + @override + final String? id; + @override + String get type => 'container'; + final ContainerDirection direction; + final int? gap; + final List children; + final List? actions; + + const UiContainerNode({ + this.id, + this.direction = ContainerDirection.vertical, + this.gap, + required this.children, + this.actions, + }); + + factory UiContainerNode.fromJson(Map json) { + return UiContainerNode( + id: json['id'] as String?, + direction: ContainerDirection.values.firstWhere( + (e) => e.value == json['direction'], + orElse: () => ContainerDirection.vertical, + ), + gap: json['gap'] as int?, + children: + (json['children'] as List?) + ?.map((e) => UiNode.fromJson(e as Map)) + .toList() ?? + [], + actions: (json['actions'] as List?) + ?.map((e) => UiAction.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + 'direction': direction.value, + if (gap != null) 'gap': gap, + 'children': children.map((e) => (e as dynamic).toJson()).toList(), + if (actions != null) 'actions': actions!.map((e) => e.toJson()).toList(), + }; + } +} + +// ========== Document Types ========== + +class RendererConfig { + final String? renderer; + final RendererTheme? theme; + + const RendererConfig({this.renderer, this.theme}); + + factory RendererConfig.fromJson(Map json) { + return RendererConfig( + renderer: json['renderer'] as String?, + theme: json['theme'] != null + ? RendererTheme.values.firstWhere( + (e) => e.value == json['theme'], + orElse: () => RendererTheme.def, + ) + : null, + ); + } + + Map toJson() { + return { + if (renderer != null) 'renderer': renderer, + if (theme != null) 'theme': theme!.value, + }; + } +} + +class DocumentMeta { + final String? requestId; + final String? toolId; + final String? traceId; + final String? userId; + final Map? extra; + + const DocumentMeta({ + this.requestId, + this.toolId, + this.traceId, + this.userId, + this.extra, + }); + + factory DocumentMeta.fromJson(Map json) { + return DocumentMeta( + requestId: json['requestId'] as String?, + toolId: json['toolId'] as String?, + traceId: json['traceId'] as String?, + userId: json['userId'] as String?, + extra: json, + ); + } + + Map toJson() { + return { + if (requestId != null) 'requestId': requestId, + if (toolId != null) 'toolId': toolId, + if (traceId != null) 'traceId': traceId, + if (userId != null) 'userId': userId, + }; + } +} + +class UiSchemaDocument { + final String version; + final SchemaType schemaType; + final String? docId; + final String? timestamp; + final String? locale; + final UiStatus status; + final RendererConfig? renderer; + final DocumentMeta? meta; + final List nodes; + + const UiSchemaDocument({ + required this.version, + required this.schemaType, + this.docId, + this.timestamp, + this.locale, + required this.status, + this.renderer, + this.meta, + required this.nodes, + }); + + factory UiSchemaDocument.fromJson(Map json) { + return UiSchemaDocument( + version: json['version'] as String? ?? '1.0', + schemaType: SchemaType.values.firstWhere( + (e) => e.value == json['schemaType'], + orElse: () => SchemaType.toolResult, + ), + docId: json['docId'] as String?, + timestamp: json['timestamp'] as String?, + locale: json['locale'] as String?, + status: UiStatus.values.firstWhere( + (e) => e.value == json['status'], + orElse: () => UiStatus.info, + ), + renderer: json['renderer'] != null + ? RendererConfig.fromJson(json['renderer'] as Map) + : null, + meta: json['meta'] != null + ? DocumentMeta.fromJson(json['meta'] as Map) + : null, + nodes: + (json['nodes'] as List?) + ?.map((e) => UiNode.fromJson(e as Map)) + .toList() ?? + [], + ); + } + + Map toJson() { + return { + 'version': version, + 'schemaType': schemaType.value, + if (docId != null) 'docId': docId, + if (timestamp != null) 'timestamp': timestamp, + if (locale != null) 'locale': locale, + 'status': status.value, + if (renderer != null) 'renderer': renderer!.toJson(), + if (meta != null) 'meta': meta!.toJson(), + 'nodes': nodes.map((e) => (e as dynamic).toJson()).toList(), + }; + } +} + +// ========== Builder Functions ========== + +UiSchemaDocument buildSuccessDocument( + List nodes, { + String version = '1.0', + SchemaType schemaType = SchemaType.toolResult, + String? docId, + String? timestamp, + String locale = 'zh-CN', + RendererConfig? renderer, + DocumentMeta? meta, +}) { + return UiSchemaDocument( + version: version, + schemaType: schemaType, + docId: docId, + timestamp: timestamp, + locale: locale, + status: UiStatus.success, + renderer: renderer, + meta: meta, + nodes: nodes, + ); +} + +UiSchemaDocument buildErrorDocument( + List nodes, { + String version = '1.0', + SchemaType schemaType = SchemaType.toolResult, + String? docId, + String? timestamp, + String locale = 'zh-CN', + RendererConfig? renderer, + DocumentMeta? meta, +}) { + return UiSchemaDocument( + version: version, + schemaType: schemaType, + docId: docId, + timestamp: timestamp, + locale: locale, + status: UiStatus.error, + renderer: renderer, + meta: meta, + nodes: nodes, + ); +} diff --git a/apps/lib/features/auth/ui/screens/login_screen.dart b/apps/lib/features/auth/ui/screens/login_screen.dart index ff36aff..dd72f5e 100644 --- a/apps/lib/features/auth/ui/screens/login_screen.dart +++ b/apps/lib/features/auth/ui/screens/login_screen.dart @@ -6,6 +6,7 @@ import '../../../../core/theme/design_tokens.dart'; import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/banner/app_banner.dart'; +import '../../../../shared/widgets/link_button.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../widgets/auth_page_scaffold.dart'; import '../../presentation/cubits/login_cubit.dart'; @@ -225,30 +226,16 @@ class _LoginViewState extends State { } Widget _buildForgotPassword() { - return GestureDetector( + return LinkButton( + text: '忘记密码?', onTap: () => context.push('/reset-password'), - child: const Text( - '忘记密码?', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), ); } Widget _buildFooter() { - return GestureDetector( + return LinkButton( + text: '还没有账号?去注册', onTap: () => context.push('/register'), - child: const Text( - '还没有账号?去注册', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), ); } } diff --git a/apps/lib/features/auth/ui/screens/register_screen.dart b/apps/lib/features/auth/ui/screens/register_screen.dart index b54615e..61fed6e 100644 --- a/apps/lib/features/auth/ui/screens/register_screen.dart +++ b/apps/lib/features/auth/ui/screens/register_screen.dart @@ -8,6 +8,8 @@ import '../../../../core/theme/design_tokens.dart'; import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/banner/app_banner.dart'; +import '../../../../shared/widgets/fixed_length_code_input.dart'; +import '../../../../shared/widgets/link_button.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../presentation/cubits/register_cubit.dart'; @@ -34,6 +36,41 @@ class RegisterView extends StatefulWidget { } class _RegisterViewState extends State { + static const _inviteCodeLength = 4; + static const _inviteAllowedChars = { + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'J', + 'K', + 'M', + 'N', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + }; + final _nicknameController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); @@ -51,10 +88,15 @@ class _RegisterViewState extends State { Future _handleNext() async { final cubit = context.read(); + final inviteCode = _inviteCodeController.text.trim().toUpperCase(); + final normalizedInviteCode = inviteCode.length == _inviteCodeLength + ? inviteCode + : ''; + cubit.usernameChanged(_nicknameController.text); cubit.emailChanged(_emailController.text); cubit.passwordChanged(_passwordController.text); - cubit.inviteCodeChanged(_inviteCodeController.text); + cubit.inviteCodeChanged(normalizedInviteCode); if (!cubit.state.isStep1Valid || cubit.state.isSending) { String? errorMsg; @@ -71,6 +113,14 @@ class _RegisterViewState extends State { return; } + if (inviteCode.isNotEmpty && normalizedInviteCode.isEmpty && mounted) { + Toast.show( + context, + '邀请码需为 4 位,且仅支持 A-H/J-N/P-Z 与 2-9;已按无邀请码继续注册', + type: ToastType.warning, + ); + } + if (mounted) { context.push('/register/verification', extra: cubit); } @@ -147,7 +197,7 @@ class _RegisterViewState extends State { const SizedBox(height: 12), _buildPasswordInput(), const SizedBox(height: 12), - _buildInput('邀请码(选填)', '请输入邀请码', _inviteCodeController), + _buildInviteCodeInput(), const SizedBox(height: 12), _buildStepIndicator(), if (state.errorMessage != null) @@ -174,6 +224,42 @@ class _RegisterViewState extends State { ); } + Widget _buildInviteCodeInput() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '邀请码(选填)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate600, + ), + ), + const SizedBox(height: 6), + FixedLengthCodeInput( + controller: _inviteCodeController, + length: _inviteCodeLength, + semanticLabel: '邀请码输入框', + uppercase: true, + allowedCharacters: _inviteAllowedChars, + onChanged: (value) { + context.read().inviteCodeChanged(value); + }, + ), + const SizedBox(height: 6), + const Text( + '4 位邀请码,支持 A-H/J-N/P-Z 与 2-9', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: AppColors.slate500, + ), + ), + ], + ); + } + Widget _buildInput( String label, String hint, @@ -262,16 +348,6 @@ class _RegisterViewState extends State { } Widget _buildFooter() { - return GestureDetector( - onTap: () => context.pop(), - child: const Text( - '已有账号?去登录', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), - ); + return LinkButton(text: '已有账号?去登录', onTap: () => context.pop()); } } diff --git a/apps/lib/features/auth/ui/screens/register_verification_screen.dart b/apps/lib/features/auth/ui/screens/register_verification_screen.dart index 6c64833..b5d3d63 100644 --- a/apps/lib/features/auth/ui/screens/register_verification_screen.dart +++ b/apps/lib/features/auth/ui/screens/register_verification_screen.dart @@ -6,6 +6,8 @@ import 'package:go_router/go_router.dart'; import 'package:formz/formz.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/fixed_length_code_input.dart'; +import '../../../../shared/widgets/link_button.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../presentation/cubits/register_cubit.dart'; @@ -49,22 +51,10 @@ class _RegisterVerificationViewState extends State { Timer? _countdownTimer; int _countdown = 0; bool _firstSendCompleted = false; - bool _hintShown = false; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_hintShown) { - _hintShown = true; - Toast.show( - context, - '验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册', - type: ToastType.info, - duration: const Duration(seconds: 5), - ); - } - }); } @override @@ -200,6 +190,12 @@ class _RegisterVerificationViewState extends State { !_firstSendCompleted) { _firstSendCompleted = true; _startCountdown(); + Toast.show( + context, + '验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册', + type: ToastType.info, + duration: const Duration(seconds: 5), + ); } }, builder: (context, state) { @@ -246,16 +242,28 @@ class _RegisterVerificationViewState extends State { Expanded( child: SizedBox( height: 40, - child: TextField( + child: FixedLengthCodeInput( controller: _codeController, + length: 6, + semanticLabel: '邮箱验证码输入框', keyboardType: TextInputType.number, - decoration: const InputDecoration( - hintText: '输入验证码', - contentPadding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), + allowedCharacters: const { + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + }, + onChanged: (value) { + context.read().verificationCodeChanged( + value, + ); + }, ), ), ), @@ -268,36 +276,38 @@ class _RegisterVerificationViewState extends State { } Widget _buildResendButton(bool canResend, RegisterState state) { - final bgColor = canResend ? AppColors.primary : const Color(0xFFF1F5F9); - final textColor = canResend ? AppColors.white : AppColors.slate400; + final canPress = + canResend && state.status != FormzSubmissionStatus.inProgress; - String text; - if (state.status == FormzSubmissionStatus.inProgress) { - text = '发送中'; - } else if (canResend) { - text = '重发'; - } else { - text = '${_countdown}s'; - } - - return GestureDetector( - onTap: canResend ? _handleResendCode : null, - child: Container( - width: 70, - height: 40, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(6), - ), - alignment: Alignment.center, - child: Text( - text, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: textColor, + return SizedBox( + width: 70, + height: 44, + child: TextButton( + onPressed: canPress ? _handleResendCode : null, + style: TextButton.styleFrom( + backgroundColor: canResend ? AppColors.primary : AppColors.slate100, + foregroundColor: canResend ? AppColors.white : AppColors.slate400, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), ), + padding: EdgeInsets.zero, ), + child: state.status == FormzSubmissionStatus.inProgress + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.slate400), + ), + ) + : Text( + canResend ? '重发' : '${_countdown}s', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), ), ); } @@ -329,16 +339,6 @@ class _RegisterVerificationViewState extends State { } Widget _buildFooter() { - return GestureDetector( - onTap: () => context.go('/'), - child: const Text( - '已有账号?去登录', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), - ); + return LinkButton(text: '已有账号?去登录', onTap: () => context.go('/')); } } diff --git a/apps/lib/features/auth/ui/screens/reset_password_screen.dart b/apps/lib/features/auth/ui/screens/reset_password_screen.dart index d78d1ca..d151c1b 100644 --- a/apps/lib/features/auth/ui/screens/reset_password_screen.dart +++ b/apps/lib/features/auth/ui/screens/reset_password_screen.dart @@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/fixed_length_code_input.dart'; +import '../../../../shared/widgets/link_button.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../presentation/cubits/reset_password_cubit.dart'; @@ -114,7 +116,7 @@ class _ResetPasswordViewState extends State { children: [ _buildEmailInput(state.email.displayError != null), const SizedBox(height: 12), - _buildCodeInput(state.code.displayError != null, state), + _buildCodeInput(state), const SizedBox(height: 12), _buildPasswordInput(state.newPassword.displayError != null), const SizedBox(height: 12), @@ -160,7 +162,7 @@ class _ResetPasswordViewState extends State { ); } - Widget _buildCodeInput(bool hasError, ResetPasswordState state) { + Widget _buildCodeInput(ResetPasswordState state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -176,16 +178,26 @@ class _ResetPasswordViewState extends State { Row( children: [ Expanded( - child: TextField( + child: FixedLengthCodeInput( controller: _codeController, + length: 6, + semanticLabel: '重置密码验证码输入框', keyboardType: TextInputType.number, + allowedCharacters: const { + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + }, onChanged: (value) { context.read().codeChanged(value); }, - decoration: InputDecoration( - hintText: '请输入 6 位验证码', - errorText: hasError ? ' ' : null, - ), ), ), const SizedBox(width: 12), @@ -325,17 +337,6 @@ class _ResetPasswordViewState extends State { } Widget _buildBackToLogin() { - return GestureDetector( - onTap: () => context.go('/'), - child: const Text( - '返回登录', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - textAlign: TextAlign.center, - ), - ); + return LinkButton(text: '返回登录', onTap: () => context.go('/')); } } diff --git a/apps/lib/features/calendar/data/services/calendar_service.dart b/apps/lib/features/calendar/data/services/calendar_service.dart new file mode 100644 index 0000000..69be549 --- /dev/null +++ b/apps/lib/features/calendar/data/services/calendar_service.dart @@ -0,0 +1,50 @@ +import 'package:social_app/core/api/i_api_client.dart'; + +import '../calendar_api.dart'; +import '../models/schedule_item_model.dart'; + +class CalendarService { + final IApiClient _apiClient; + CalendarApi? _calendarApi; + + CalendarService({required IApiClient apiClient}) : _apiClient = apiClient; + + CalendarApi get _api { + final api = _calendarApi; + if (api != null) { + return api; + } + final created = CalendarApi(_apiClient); + _calendarApi = created; + return created; + } + + Future> getEventsForDay(DateTime date) async { + final start = DateTime(date.year, date.month, date.day); + final end = DateTime(date.year, date.month, date.day, 23, 59, 59); + return getEventsForRange(start, end); + } + + Future> getEventsForRange( + DateTime start, + DateTime end, + ) async { + return _api.listByRange(startAt: start, endAt: end); + } + + Future getEventById(String id) async { + return _api.getById(id); + } + + Future addEvent(ScheduleItemModel event) async { + return _api.create(event); + } + + Future updateEvent(ScheduleItemModel event) async { + return _api.update(event); + } + + Future deleteEvent(String id) async { + await _api.delete(id); + } +} diff --git a/apps/lib/features/calendar/data/services/mock_calendar_service.dart b/apps/lib/features/calendar/data/services/mock_calendar_service.dart deleted file mode 100644 index 9797406..0000000 --- a/apps/lib/features/calendar/data/services/mock_calendar_service.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:social_app/core/api/i_api_client.dart'; - -import '../calendar_api.dart'; -import '../models/schedule_item_model.dart'; - -class MockCalendarService { - static final MockCalendarService _instance = MockCalendarService._internal(); - factory MockCalendarService() => _instance; - - final List _events = []; - - MockCalendarService._internal(); - - List get events => List.unmodifiable(_events); - - List getEventsForDay(DateTime date) { - final dateOnly = DateTime(date.year, date.month, date.day); - return _events.where((event) { - final eventDate = DateTime( - event.startAt.year, - event.startAt.month, - event.startAt.day, - ); - return eventDate == dateOnly && event.status == ScheduleStatus.active; - }).toList()..sort((a, b) => a.startAt.compareTo(b.startAt)); - } - - List getEventsForRange(DateTime start, DateTime end) { - return _events.where((event) { - return event.startAt.isAfter(start.subtract(const Duration(days: 1))) && - event.startAt.isBefore(end.add(const Duration(days: 1))) && - event.status == ScheduleStatus.active; - }).toList()..sort((a, b) => a.startAt.compareTo(b.startAt)); - } - - ScheduleItemModel? getEventById(String id) { - try { - return _events.firstWhere((e) => e.id == id); - } catch (_) { - return null; - } - } - - void addEvent(ScheduleItemModel event) { - _events.add(event); - } - - void updateEvent(ScheduleItemModel event) { - final index = _events.indexWhere((e) => e.id == event.id); - if (index >= 0) { - _events[index] = event; - } - } - - void deleteEvent(String id) { - _events.removeWhere((e) => e.id == id); - } -} - -class CalendarService { - final IApiClient? _apiClient; - final MockCalendarService _mock = MockCalendarService(); - CalendarApi? _calendarApi; - - CalendarService({IApiClient? apiClient}) : _apiClient = apiClient; - - CalendarApi get _api { - final api = _calendarApi; - if (api != null) { - return api; - } - final client = _apiClient; - if (client == null) { - throw StateError('Real API client not configured'); - } - final created = CalendarApi(client); - _calendarApi = created; - return created; - } - - Future> getEventsForDay(DateTime date) async { - if (_apiClient == null) { - return _mock.getEventsForDay(date); - } - final start = DateTime(date.year, date.month, date.day); - final end = DateTime(date.year, date.month, date.day, 23, 59, 59); - return getEventsForRange(start, end); - } - - Future> getEventsForRange( - DateTime start, - DateTime end, - ) async { - if (_apiClient != null) { - return _api.listByRange(startAt: start, endAt: end); - } - return _mock.getEventsForRange(start, end); - } - - Future getEventById(String id) async { - if (_apiClient != null) { - return _api.getById(id); - } - return _mock.getEventById(id); - } - - Future addEvent(ScheduleItemModel event) async { - if (_apiClient != null) { - return _api.create(event); - } - _mock.addEvent(event); - return event; - } - - Future updateEvent(ScheduleItemModel event) async { - if (_apiClient != null) { - return _api.update(event); - } - _mock.updateEvent(event); - return event; - } - - Future deleteEvent(String id) async { - if (_apiClient != null) { - await _api.delete(id); - return; - } - _mock.deleteEvent(id); - } -} diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 737fa9b..060f0ac 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -7,7 +7,7 @@ import '../calendar_state_manager.dart'; import '../calendar_time_utils.dart'; import '../widgets/bottom_dock.dart'; import '../widgets/create_event_sheet.dart'; -import '../../data/services/mock_calendar_service.dart'; +import '../../data/services/calendar_service.dart'; import '../../data/models/schedule_item_model.dart'; class CalendarDayWeekScreen extends StatefulWidget { diff --git a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index d9da80d..348d37d 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/notifications/local_notification_service.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../data/services/mock_calendar_service.dart'; +import '../../data/services/calendar_service.dart'; import '../../data/models/schedule_item_model.dart'; import '../widgets/create_event_sheet.dart'; import '../widgets/calendar_share_dialog.dart'; diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index 1fb74e2..9a11452 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -9,7 +9,7 @@ import '../calendar_time_utils.dart'; import '../widgets/bottom_dock.dart'; import '../widgets/create_event_sheet.dart'; import '../../data/models/schedule_item_model.dart'; -import '../../data/services/mock_calendar_service.dart'; +import '../../data/services/calendar_service.dart'; class CalendarMonthScreen extends StatefulWidget { final bool resetToToday; diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index bd8a227..191e220 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -5,7 +5,7 @@ import '../../../../core/di/injection.dart'; import '../../../../core/notifications/local_notification_service.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../data/models/schedule_item_model.dart'; -import '../../data/services/mock_calendar_service.dart'; +import '../../data/services/calendar_service.dart'; class CreateEventSheet extends StatefulWidget { final DateTime? initialDate; diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index cf37a18..c2b9184 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -6,16 +6,13 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:image_picker/image_picker.dart'; import 'package:social_app/core/api/i_api_client.dart'; -import 'package:social_app/core/api/mock_api_client.dart'; import '../ai/ai_decision_engine.dart'; import '../models/ag_ui_event.dart'; import '../tools/tool_registry.dart'; -import 'mock_history_service.dart'; typedef EventCallback = void Function(AgUiEvent event); -/// ID 前缀常量 const _runIdPrefix = 'run_'; const _messageIdPrefix = 'msg_'; const _toolCallIdPrefix = 'tc_'; @@ -24,24 +21,16 @@ class AgUiService { final IApiClient _apiClient; EventCallback onEvent; final AiDecisionEngine _decisionEngine; - final MockHistoryService _historyService; - final Map> _mockSseLinesByThread = {}; final Map _lastEventIdByThread = {}; int _activeStreamToken = 0; String? _threadId; bool _hasMoreHistory = false; - bool _mockApiConfigured = false; - AgUiService({EventCallback? onEvent, IApiClient? apiClient}) + AgUiService({EventCallback? onEvent, required IApiClient apiClient}) : onEvent = onEvent ?? ((_) {}), - _apiClient = apiClient ?? MockApiClient(), - _decisionEngine = AiDecisionEngine(), - _historyService = MockHistoryService() { - if (_apiClient is MockApiClient) { - _configureMockAgentApi(_apiClient); - } - } + _apiClient = apiClient, + _decisionEngine = AiDecisionEngine(); Future sendMessage(String content, {List? images}) async { final streamToken = ++_activeStreamToken; @@ -409,368 +398,4 @@ class AgUiService { const variant = ['8', '9', 'a', 'b']; return '${hex(8)}-${hex(4)}-4${hex(3)}-${variant[random.nextInt(4)]}${hex(3)}-${hex(12)}'; } - - void _configureMockAgentApi(MockApiClient client) { - if (_mockApiConfigured) { - return; - } - _mockApiConfigured = true; - - client.registerHandler('/api/v1/agent/runs', 'POST', _handleMockRun); - client.registerPatternHandler( - RegExp(r'^/api/v1/agent/runs/[^/]+/resume$'), - 'POST', - _handleMockResume, - ); - client.registerPatternHandler( - RegExp(r'^/api/v1/agent/history(?:\?.*)?$'), - 'GET', - _handleMockHistory, - ); - client.registerPatternHandler( - RegExp(r'^/api/v1/agent/runs/[^/]+/events$'), - 'SSE', - _handleMockSse, - ); - client.registerHandler( - '/api/v1/agent/attachments', - 'POST', - _handleMockUploadAttachment, - ); - client.registerHandler( - '/api/v1/agent/transcribe', - 'POST', - _handleMockTranscribe, - ); - } - - Map _handleMockTranscribe(MockRequest request) { - return {'transcript': '这是模拟语音转写'}; - } - - Map _handleMockUploadAttachment(MockRequest request) { - final payload = request.data; - final threadId = payload is Map - ? (payload['threadId'] as String?) - : null; - final resolvedThreadId = (threadId != null && threadId.isNotEmpty) - ? threadId - : (_threadId ?? _newUuid()); - final path = - 'agent-inputs/mock/$resolvedThreadId/${_nextId('upload_')}.png'; - return { - 'attachment': { - 'bucket': 'mock-bucket', - 'path': path, - 'mimeType': 'image/png', - 'url': 'https://mock.local/$path', - }, - }; - } - - Map _handleMockRun(MockRequest request) { - final payload = request.data; - final runInput = payload is Map - ? payload - : {}; - final threadId = (runInput['threadId'] as String?) ?? _newUuid(); - final runId = (runInput['runId'] as String?) ?? _nextId(_runIdPrefix); - _threadId = threadId; - - final content = _extractLatestUserContent(runInput); - final events = _buildMockRunEvents( - threadId: threadId, - runId: runId, - userInput: content, - ); - _mockSseLinesByThread[threadId] = _toSseLines(events); - return { - 'taskId': _nextId('task_'), - 'threadId': threadId, - 'runId': runId, - 'created': false, - }; - } - - Map _handleMockResume(MockRequest request) { - final match = RegExp( - r'^/api/v1/agent/runs/([^/]+)/resume$', - ).firstMatch(request.path); - final threadId = match?.group(1) ?? (_threadId ?? _newUuid()); - final payload = request.data; - final runInput = payload is Map - ? payload - : {}; - final runId = (runInput['runId'] as String?) ?? _nextId(_runIdPrefix); - _threadId = threadId; - - final toolMessage = _extractLatestToolMessage(runInput); - final events = >[ - { - 'type': AgUiEventTypeWire.runStarted, - 'threadId': threadId, - 'runId': runId, - }, - { - 'type': AgUiEventTypeWire.toolCallResult, - 'messageId': _nextId(_messageIdPrefix), - 'toolCallId': toolMessage.$1, - 'content': toolMessage.$2, - }, - { - 'type': AgUiEventTypeWire.textMessageStart, - 'messageId': _nextId(_messageIdPrefix), - 'role': 'assistant', - }, - { - 'type': AgUiEventTypeWire.textMessageContent, - 'messageId': _nextId(_messageIdPrefix), - 'delta': '已收到你的审批,继续执行完成。', - }, - { - 'type': AgUiEventTypeWire.textMessageEnd, - 'messageId': _nextId(_messageIdPrefix), - }, - { - 'type': AgUiEventTypeWire.runFinished, - 'threadId': threadId, - 'runId': runId, - }, - ]; - _mockSseLinesByThread[threadId] = _toSseLines(events); - return { - 'taskId': _nextId('task_'), - 'threadId': threadId, - 'runId': runId, - 'created': false, - }; - } - - Map _handleMockHistory(MockRequest request) { - final uri = Uri.parse(request.path); - final query = uri.queryParameters; - final providedThreadId = query['threadId']; - final threadId = providedThreadId ?? _threadId ?? _newUuid(); - _threadId = threadId; - - final beforeRaw = query['before']; - DateTime? beforeDate; - if (beforeRaw != null && beforeRaw.isNotEmpty) { - beforeDate = DateTime.tryParse(beforeRaw); - } - - DateTime? targetDate; - if (beforeDate == null) { - targetDate = _historyService.getLatestHistoryDate(); - } else { - targetDate = _historyService.getPreviousDay(beforeDate); - } - final messages = targetDate == null - ? [] - : _historyService.getHistoryForDay(targetDate); - final hasMore = - targetDate != null && _historyService.hasEarlierHistory(targetDate); - _hasMoreHistory = hasMore; - - return { - 'type': AgUiEventTypeWire.stateSnapshot, - 'threadId': threadId, - 'snapshot': { - 'scope': 'history_day', - 'threadId': threadId, - 'day': targetDate == null - ? null - : DateTime( - targetDate.year, - targetDate.month, - targetDate.day, - ).toIso8601String().substring(0, 10), - 'hasMore': hasMore, - 'messages': messages.map((item) => item.toJson()).toList(), - }, - }; - } - - Stream _handleMockSse(MockRequest request) { - final match = RegExp( - r'^/api/v1/agent/runs/([^/]+)/events$', - ).firstMatch(request.path); - final threadId = match?.group(1); - if (threadId == null) { - return const Stream.empty(); - } - final lines = _mockSseLinesByThread[threadId]; - if (lines == null) { - return const Stream.empty(); - } - return Stream.fromIterable(lines); - } - - List> _buildMockRunEvents({ - required String threadId, - required String runId, - required String userInput, - }) { - final events = >[ - { - 'type': AgUiEventTypeWire.runStarted, - 'threadId': threadId, - 'runId': runId, - }, - ]; - - final forceTrigger = _decisionEngine.tryForceTrigger(userInput); - Map? args; - String? toolName; - if (forceTrigger != null) { - toolName = forceTrigger.toolName; - args = forceTrigger.args; - } else if (_looksLikeNavigationIntent(userInput)) { - toolName = 'front.navigate_to_route'; - args = {'target': _inferNavigationRoute(userInput), 'replace': false}; - } - - if (toolName != null && args != null) { - if (toolName == 'front.navigate_to_route') { - args = {...args, '__nonce': _nextId('nonce_')}; - } - final toolCallId = _nextId(_toolCallIdPrefix); - events.add({ - 'type': AgUiEventTypeWire.toolCallStart, - 'toolCallId': toolCallId, - 'toolCallName': toolName, - }); - events.add({ - 'type': AgUiEventTypeWire.toolCallArgs, - 'toolCallId': toolCallId, - 'delta': jsonEncode(args), - }); - events.add({ - 'type': AgUiEventTypeWire.toolCallEnd, - 'toolCallId': toolCallId, - }); - - if (toolName == 'front.navigate_to_route') { - // 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。 - } else { - events.add({ - 'type': AgUiEventTypeWire.toolCallError, - 'toolCallId': toolCallId, - 'error': 'Unsupported frontend tool in mock mode', - 'code': 'UNSUPPORTED_TOOL', - }); - } - } - - final replies = _generateReplies(userInput); - for (final reply in replies) { - final messageId = _nextId(_messageIdPrefix); - events.add({ - 'type': AgUiEventTypeWire.textMessageStart, - 'messageId': messageId, - 'role': 'assistant', - }); - events.add({ - 'type': AgUiEventTypeWire.textMessageContent, - 'messageId': messageId, - 'delta': reply, - }); - events.add({ - 'type': AgUiEventTypeWire.textMessageEnd, - 'messageId': messageId, - }); - } - - events.add({ - 'type': AgUiEventTypeWire.runFinished, - 'threadId': threadId, - 'runId': runId, - }); - return events; - } - - List _toSseLines(List> events) { - final lines = []; - for (var i = 0; i < events.length; i++) { - final event = events[i]; - final eventType = event['type'] as String? ?? 'MESSAGE'; - final eventId = '${i + 1}-0'; - lines.add('id: $eventId'); - lines.add('event: $eventType'); - lines.add('data: ${jsonEncode(event)}'); - lines.add(''); - } - return lines; - } - - String _extractLatestUserContent(Map runInput) { - final messages = runInput['messages']; - if (messages is! List) { - return ''; - } - for (var i = messages.length - 1; i >= 0; i--) { - final raw = messages[i]; - if (raw is! Map) { - continue; - } - if (raw['role'] != 'user') { - continue; - } - final content = raw['content']; - if (content is String) { - return content; - } - } - return ''; - } - - (String, String) _extractLatestToolMessage(Map runInput) { - final messages = runInput['messages']; - if (messages is! List) { - return (_nextId(_toolCallIdPrefix), '{}'); - } - for (var i = messages.length - 1; i >= 0; i--) { - final raw = messages[i]; - if (raw is! Map) { - continue; - } - if (raw['role'] != 'tool') { - continue; - } - final toolCallId = - raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix); - final content = raw['content'] as String? ?? '{}'; - return (toolCallId, content); - } - return (_nextId(_toolCallIdPrefix), '{}'); - } - - List _generateReplies(String content) { - final intent = _decisionEngine.matchIntent(content); - switch (intent) { - case Intent.createEvent: - return ['好的,我已经为您创建了日程安排。']; - case Intent.searchEvent: - return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审']; - case Intent.unknown: - return ['我理解了您的问题,让我来帮您处理。']; - } - } - - bool _looksLikeNavigationIntent(String input) { - return input.contains('打开') || - input.contains('跳转') || - input.toLowerCase().contains('navigate') || - input.toLowerCase().contains('open'); - } - - String _inferNavigationRoute(String input) { - if (input.contains('设置')) { - return '/settings'; - } - if (input.contains('待办')) { - return '/todo'; - } - return '/calendar/dayweek'; - } } diff --git a/apps/lib/features/chat/data/services/mock_history_service.dart b/apps/lib/features/chat/data/services/mock_history_service.dart deleted file mode 100644 index 80f5a8c..0000000 --- a/apps/lib/features/chat/data/services/mock_history_service.dart +++ /dev/null @@ -1,157 +0,0 @@ -import '../models/ag_ui_event.dart'; -import '../models/tool_result.dart'; - -class MockHistoryService { - static final MockHistoryService _instance = MockHistoryService._internal(); - factory MockHistoryService() => _instance; - MockHistoryService._internal(); - - /// Normalize DateTime to date-only (midnight) - DateTime _toDateOnly(DateTime date) => - DateTime(date.year, date.month, date.day); - - List getHistoryForDay(DateTime date) { - final dayStart = _toDateOnly(date); - final allHistory = _generateAllHistory(); - - return allHistory.where((msg) { - if (msg.timestamp == null) return false; - final msgDate = _toDateOnly(msg.timestamp!); - return msgDate == dayStart; - }).toList(); - } - - DateTime? getLatestHistoryDate() { - final allHistory = _generateAllHistory(); - if (allHistory.isEmpty) return null; - - return allHistory - .where((msg) => msg.timestamp != null) - .map((msg) => _toDateOnly(msg.timestamp!)) - .reduce((a, b) => a.isAfter(b) ? a : b); - } - - DateTime? getPreviousDay(DateTime currentDate) { - final allDates = _getAllHistoryDates(); - final sortedDates = allDates.toList()..sort((a, b) => b.compareTo(a)); - final currentDateOnly = _toDateOnly(currentDate); - - for (final date in sortedDates) { - if (date.isBefore(currentDateOnly)) { - return date; - } - } - return null; - } - - bool hasEarlierHistory(DateTime fromDate) { - final allDates = _getAllHistoryDates(); - final fromDateOnly = _toDateOnly(fromDate); - - return allDates.any((date) => date.isBefore(fromDateOnly)); - } - - Set _getAllHistoryDates() { - final now = DateTime.now(); - final today = _toDateOnly(now); - final yesterday = today.subtract(const Duration(days: 1)); - return {today, yesterday}; - } - - List _generateAllHistory() { - final now = DateTime.now(); - final today = _toDateOnly(now); - final yesterday = today.subtract(const Duration(days: 1)); - - return [ - SnapshotMessage( - id: 'hist-m1', - role: 'user', - content: '明天提醒我开会', - timestamp: today.add(const Duration(hours: 10)), - ), - SnapshotMessage( - id: 'hist-t1', - role: 'tool', - toolCallId: 'hist-tc1', - timestamp: today.add(const Duration(hours: 10)), - ui: UiCard( - cardType: 'calendar_card.v1', - data: CalendarCardData( - id: 'hist-s1', - title: '产品评审会议', - description: '讨论Q2产品路线图', - startAt: today - .add(const Duration(days: 1, hours: 10)) - .toIso8601String(), - endAt: today - .add(const Duration(days: 1, hours: 11)) - .toIso8601String(), - timezone: 'Asia/Shanghai', - location: '会议室A / 在线', - color: '#4F46E5', - sourceType: 'ai_generated', - ).toJson(), - actions: [ - CardAction( - type: 'link', - label: '查看详情', - target: '/calendar/hist-s1', - ), - ], - ), - ), - SnapshotMessage( - id: 'hist-m2', - role: 'assistant', - content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。', - timestamp: today.add(const Duration(hours: 10)), - ), - SnapshotMessage( - id: 'hist-m3', - role: 'user', - content: '下周一之前提交项目报告', - timestamp: yesterday.add(const Duration(hours: 14)), - ), - SnapshotMessage( - id: 'hist-t2', - role: 'tool', - toolCallId: 'hist-tc2', - timestamp: yesterday.add(const Duration(hours: 14)), - ui: UiCard( - cardType: 'calendar_card.v1', - data: CalendarCardData( - id: 'hist-s2', - title: '提交项目报告', - description: '完成并提交Q2项目报告', - startAt: yesterday.add(const Duration(days: 5)).toIso8601String(), - endAt: null, - timezone: 'Asia/Shanghai', - location: null, - color: '#F59E0B', - sourceType: 'ai_generated', - ).toJson(), - actions: [ - CardAction( - type: 'link', - label: '查看详情', - target: '/calendar/hist-s2', - ), - ], - ), - ), - SnapshotMessage( - id: 'hist-m4', - role: 'assistant', - content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。', - timestamp: yesterday.add(const Duration(hours: 14)), - ), - SnapshotMessage( - id: 'hist-m5', - role: 'assistant', - content: '你好,我有什么可以帮你的?', - timestamp: yesterday.add(const Duration(hours: 9)), - ), - ]; - } -} diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 43c2c33..9d88f25 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:social_app/core/api/i_api_client.dart'; -import 'package:social_app/core/api/mock_api_client.dart'; import 'package:social_app/core/di/injection.dart'; import '../../data/models/ag_ui_event.dart'; @@ -91,16 +90,8 @@ class ChatBloc extends Cubit { final Map> _attachmentPreviewInflight = >{}; - ChatBloc({AgUiService? service, IApiClient? apiClient}) - : _service = - service ?? - AgUiService( - apiClient: - apiClient ?? - (sl.isRegistered() - ? sl() - : MockApiClient()), - ), + ChatBloc({AgUiService? service, required IApiClient apiClient}) + : _service = service ?? AgUiService(apiClient: apiClient), super(const ChatState()) { _service.onEvent = _handleEvent; } diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index cca2148..f4d1ef6 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -15,6 +15,7 @@ import '../../../chat/data/tools/route_navigation_tool.dart'; import '../../../messages/data/inbox_api.dart'; import '../../data/voice_recorder.dart'; import '../../../chat/ui/widgets/ui_schema_renderer.dart'; +import '../../../../shared/widgets/message_composer.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import 'home_sheet.dart'; @@ -28,19 +29,15 @@ const _iconSize = 24.0; const _messagePaddingH = 13.0; const _messagePaddingV = 9.0; const _cornerRadius = 12.0; -const _inputMinHeight = 48.0; -const _inputRadius = 24.0; +const _inputMinHeight = AppSpacing.xxl + AppSpacing.lg; +const _cancelThreshold = -(AppSpacing.xxl + AppSpacing.xxl); const _scrollDurationMs = 300; const _rippleDurationMs = 1200; -const _recordingDotSize = 10.0; const _transcribingSpinnerSize = 18.0; const _transcribingStrokeWidth = 2.0; const _attachmentPreviewSize = 88.0; const _attachmentPreviewRadius = 10.0; const _attachmentPreviewGap = 8.0; -const _inputActionButtonKey = ValueKey('home_input_action_button'); -const _inputActionIconKey = ValueKey('home_input_action_icon'); -const _holdToSpeakKey = ValueKey('home_hold_to_speak_button'); /// 颜色常量 const _chatBgColor = AppColors.slate50; @@ -79,6 +76,7 @@ class _HomeScreenState extends State bool _isRecording = false; bool _isHoldToSpeakMode = false; bool _isTranscribing = false; + bool _isCancelGestureActive = false; int _unreadCount = 0; final List _selectedImages = []; @@ -158,12 +156,17 @@ class _HomeScreenState extends State return Scaffold( backgroundColor: _chatBgColor, body: SafeArea( - child: Column( + child: Stack( children: [ - _buildHeader(context), - Expanded(child: _buildChatArea(context, state)), - _buildImagePreview(), - _buildInputContainer(context, state), + Column( + children: [ + _buildHeader(context), + Expanded(child: _buildChatArea(context, state)), + _buildImagePreview(), + _buildInputContainer(context, state), + ], + ), + if (_isRecording) _buildRecordingGestureOverlay(), ], ), ), @@ -712,191 +715,147 @@ class _HomeScreenState extends State } Widget _buildInputContainer(BuildContext context, ChatState state) { - return Container( - padding: const EdgeInsets.all(_inputPadding), - color: _chatBgColor, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - GestureDetector( - onTap: _isRecording - ? _stopRecording - : () => _showBottomSheet(context), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.white, - shape: BoxShape.circle, - border: Border.all(color: AppColors.slate300), - ), - child: Icon( - _isRecording ? LucideIcons.square : LucideIcons.plus, - size: 20, - color: _isRecording ? AppColors.red600 : AppColors.slate500, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _isHoldToSpeakMode - ? _buildHoldToSpeakButton() - : _buildNormalInputField(state), - ), - const SizedBox(width: 8), - _buildRightActionButton(state), - ], - ), - if (_isHoldToSpeakMode) ...[ - const SizedBox(height: 8), - _buildHoldToSpeakHint(), - ], - ], - ), - ); - } - - Widget _buildHoldToSpeakButton() { - return GestureDetector( - key: _holdToSpeakKey, - onLongPressStart: (_) => _onHoldToSpeakStart(), - onLongPressEnd: (_) => _onHoldToSpeakEnd(), - onLongPressMoveUpdate: (details) => _onHoldToSpeakMoveUpdate(details), - child: Container( - constraints: const BoxConstraints(minHeight: _inputMinHeight), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(_inputRadius), - border: Border.all(color: AppColors.slate300), - ), - child: const Center( - child: Text( - '按住说话', - style: TextStyle(fontSize: 14, color: AppColors.slate500), - ), - ), - ), - ); - } - - Widget _buildNormalInputField(ChatState state) { - return Container( - constraints: const BoxConstraints(minHeight: _inputMinHeight), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(_inputRadius), - border: Border.all(color: AppColors.slate300), - ), - child: _isRecording - ? _buildListeningIndicator() - : _isTranscribing - ? _buildTranscribingIndicator() - : TextField( - controller: _messageController, - minLines: 1, - maxLines: 3, - decoration: const InputDecoration( - hintText: '输入消息...', - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - filled: false, - ), - onSubmitted: (_) => _sendMessage(context), - ), - ); - } - - Widget _buildRightActionButton(ChatState state) { final isWaitingAgent = state.isWaitingFirstToken || state.isStreaming || state.isCancelling; - return GestureDetector( - key: _inputActionButtonKey, - onTap: _isTranscribing - ? null - : isWaitingAgent - ? () => _onStopGenerating(context) - : _hasMessage - ? () => _sendMessage(context) - : _toggleHoldToSpeakMode, - child: _isTranscribing - ? const SizedBox( - width: _transcribingSpinnerSize, - height: _transcribingSpinnerSize, - child: CircularProgressIndicator( - strokeWidth: _transcribingStrokeWidth, - color: AppColors.blue600, - ), - ) - : Icon( - key: _inputActionIconKey, - isWaitingAgent - ? LucideIcons.square - : _hasMessage - ? LucideIcons.send - : _isHoldToSpeakMode - ? LucideIcons.keyboard - : LucideIcons.activity, - size: _iconSize, - color: isWaitingAgent || _hasMessage - ? AppColors.blue600 - : AppColors.slate500, - ), + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), + color: _chatBgColor, + child: MessageComposer( + mode: _isHoldToSpeakMode + ? MessageComposerMode.holdToSpeak + : MessageComposerMode.text, + process: _composerProcess, + hasMessage: _hasMessage, + isWaitingAgent: isWaitingAgent, + iconSize: _iconSize, + composerMinHeight: _inputMinHeight, + onTapPlus: _isRecording + ? () => _stopRecording(autoSendAfterTranscribe: false) + : () => _showBottomSheet(context), + onTapRightAction: () => _onRightActionTap(context, state), + onHoldToSpeakStart: _onHoldToSpeakStart, + onHoldToSpeakEnd: _onHoldToSpeakEnd, + onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate, + onHoldToSpeakCancel: _onHoldToSpeakCancel, + textInputChild: _buildTextInputContent(context), + recordingAnimation: const SizedBox.shrink(), + recordingText: _isCancelGestureActive ? '松手取消' : '松手发送', + recordingHintText: _isCancelGestureActive ? '松开取消' : '松开发送,上滑取消', + showRecordingInlineFeedback: false, + ), ); } - Widget _buildHoldToSpeakHint() { - return Column( - children: [ - if (_isRecording) ...[ - _buildRecordingAnimation(), - const SizedBox(height: 4), - const Text( - '松开发送,上滑取消', - style: TextStyle(fontSize: 12, color: AppColors.slate500), + MessageComposerProcess get _composerProcess { + if (_isRecording) { + return MessageComposerProcess.recording; + } + if (_isTranscribing) { + return MessageComposerProcess.transcribing; + } + return MessageComposerProcess.idle; + } + + Widget _buildTextInputContent(BuildContext context) { + if (_isTranscribing) { + return _buildTranscribingIndicator(); + } + return SizedBox.expand( + child: Align( + alignment: Alignment.centerLeft, + child: TextField( + controller: _messageController, + minLines: 1, + maxLines: 1, + style: const TextStyle( + fontSize: AppSpacing.lg, + height: 1, + color: AppColors.slate900, ), - ], - ], + textAlignVertical: TextAlignVertical.center, + decoration: const InputDecoration( + hintText: '输入消息...', + hintStyle: TextStyle( + fontSize: AppSpacing.lg, + height: 1, + color: AppColors.slate400, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + isCollapsed: true, + contentPadding: EdgeInsets.zero, + filled: false, + ), + onSubmitted: (_) => _sendMessage(context), + ), + ), ); } - Widget _buildRecordingAnimation() { - return _buildListeningIndicator(); + void _onRightActionTap(BuildContext context, ChatState state) { + if (_isTranscribing || _isRecording) { + return; + } + final isWaitingAgent = + state.isWaitingFirstToken || state.isStreaming || state.isCancelling; + if (isWaitingAgent) { + _onStopGenerating(); + return; + } + if (_hasMessage) { + _sendMessage(context); + return; + } + _toggleHoldToSpeakMode(); } void _toggleHoldToSpeakMode() { + if (_isRecording || _isTranscribing) { + return; + } setState(() { _isHoldToSpeakMode = !_isHoldToSpeakMode; }); } void _onHoldToSpeakStart() { - HapticFeedback.lightImpact(); + HapticFeedback.heavyImpact(); + HapticFeedback.vibrate(); + setState(() { + _isCancelGestureActive = false; + }); _startRecording(); } void _onHoldToSpeakEnd() { + if (_isCancelGestureActive) { + HapticFeedback.selectionClick(); + _cancelRecording(showToast: false); + return; + } + HapticFeedback.mediumImpact(); _stopRecording(autoSendAfterTranscribe: true); } void _onHoldToSpeakMoveUpdate(LongPressMoveUpdateDetails details) { - const cancelThreshold = -50.0; - if (details.offsetFromOrigin.dy < cancelThreshold) { - _cancelRecording(); + final willCancel = details.offsetFromOrigin.dy < _cancelThreshold; + if (willCancel != _isCancelGestureActive && mounted) { + HapticFeedback.selectionClick(); + setState(() { + _isCancelGestureActive = willCancel; + }); } } - Future _cancelRecording() async { + void _onHoldToSpeakCancel() { + _cancelRecording(showToast: false); + } + + Future _cancelRecording({bool showToast = true}) async { try { await _voiceRecorder.stop(); _listeningAnimationController.stop(); @@ -904,8 +863,11 @@ class _HomeScreenState extends State if (!mounted) return; setState(() { _isRecording = false; + _isCancelGestureActive = false; }); - Toast.show(context, '已取消', type: ToastType.info); + if (showToast) { + Toast.show(context, '已取消', type: ToastType.info); + } } Future _sendMessage(BuildContext context) async { @@ -933,8 +895,8 @@ class _HomeScreenState extends State }); } - Future _onStopGenerating(BuildContext context) async { - final canceled = await context.read().cancelCurrentRun(); + Future _onStopGenerating() async { + final canceled = await _chatBloc.cancelCurrentRun(); if (!mounted) { return; } @@ -943,40 +905,94 @@ class _HomeScreenState extends State } } - Widget _buildListeningIndicator() { - return SizedBox( - height: _inputMinHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - AnimatedBuilder( - animation: _listeningAnimationController, - builder: (context, _) { - final t = _listeningAnimationController.value; - final waveA = - 0.4 + 0.6 * (1 - ((t - 0.2).abs() * 2).clamp(0.0, 1.0)); - final waveB = - 0.4 + 0.6 * (1 - ((t - 0.5).abs() * 2).clamp(0.0, 1.0)); - final waveC = - 0.4 + 0.6 * (1 - ((t - 0.8).abs() * 2).clamp(0.0, 1.0)); - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildWaveDot(scale: waveA), - const SizedBox(width: 6), - _buildWaveDot(scale: waveB), - const SizedBox(width: 6), - _buildWaveDot(scale: waveC), - ], + Widget _buildWaveDots() { + return AnimatedBuilder( + animation: _listeningAnimationController, + builder: (context, _) { + final t = _listeningAnimationController.value; + final barCount = (AppSpacing.xxl * 2).toInt(); + final barColor = _isCancelGestureActive + ? AppColors.red500 + : AppColors.blue500; + + return SizedBox( + height: AppSpacing.lg, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(barCount, (index) { + final phase = (index / barCount + t) % 1; + final active = (1 - ((phase - 0.5).abs() * 2)).clamp(0.0, 1.0); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Container( + width: AppSpacing.xs / 2, + height: AppSpacing.sm + AppSpacing.xs * active, + decoration: BoxDecoration( + color: barColor.withValues(alpha: 0.35 + active * 0.65), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), ); - }, + }), ), - const SizedBox(width: 10), - const Text( - '正在聆听...', - style: TextStyle(fontSize: 14, color: AppColors.slate500), + ); + }, + ); + } + + Widget _buildRecordingGestureOverlay() { + final topColor = _isCancelGestureActive + ? AppColors.warningBackground + : AppColors.blue50; + final bottomColor = _isCancelGestureActive + ? AppColors.red400 + : AppColors.blue400; + final labelColor = _isCancelGestureActive + ? AppColors.red600 + : AppColors.white; + final label = _isCancelGestureActive ? '松手取消' : '松手发送,上移取消'; + + return IgnorePointer( + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: AppSpacing.xxl * 7), + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.xxl, + AppSpacing.xl, + AppSpacing.xxl, ), - ], + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(AppRadius.xxl), + topRight: Radius.circular(AppRadius.xxl), + ), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [topColor, bottomColor], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + label, + style: TextStyle( + fontSize: AppSpacing.xl, + color: labelColor, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: AppSpacing.md), + _buildWaveDots(), + ], + ), + ), ), ); } @@ -1002,20 +1018,6 @@ class _HomeScreenState extends State ); } - Widget _buildWaveDot({required double scale}) { - return Transform.scale( - scale: scale, - child: Container( - width: _recordingDotSize, - height: _recordingDotSize, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: AppColors.red600, - ), - ), - ); - } - Future _startRecording() async { try { await _voiceRecorder.start(); @@ -1025,6 +1027,7 @@ class _HomeScreenState extends State } setState(() { _isRecording = true; + _isCancelGestureActive = false; }); } catch (error) { if (!mounted) { @@ -1045,6 +1048,7 @@ class _HomeScreenState extends State setState(() { _isRecording = false; _isTranscribing = true; + _isCancelGestureActive = false; }); if (audioPath == null || audioPath.isEmpty) { throw StateError('录音失败,请重试'); diff --git a/apps/lib/features/settings/ui/screens/change_password_screen.dart b/apps/lib/features/settings/ui/screens/change_password_screen.dart index 66f2c05..87ad11d 100644 --- a/apps/lib/features/settings/ui/screens/change_password_screen.dart +++ b/apps/lib/features/settings/ui/screens/change_password_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/fixed_length_code_input.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/page_header.dart' as widgets; @@ -170,7 +171,7 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCodeInput(state.code.displayError != null, state), + _buildCodeInput(state), const SizedBox(height: 16), _buildPasswordInput(state.newPassword.displayError != null), const SizedBox(height: 16), @@ -185,7 +186,7 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { ); } - Widget _buildCodeInput(bool hasError, ResetPasswordState state) { + Widget _buildCodeInput(ResetPasswordState state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -201,18 +202,26 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { Row( children: [ Expanded( - child: TextField( + child: FixedLengthCodeInput( controller: _codeController, + length: 6, + semanticLabel: '修改密码验证码输入框', keyboardType: TextInputType.number, + allowedCharacters: const { + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + }, onChanged: (value) { context.read().codeChanged(value); }, - decoration: InputDecoration( - hintText: '请输入 6 位验证码', - errorText: hasError ? ' ' : null, - filled: true, - fillColor: AppColors.white, - ), ), ), const SizedBox(width: 12), diff --git a/apps/lib/main.dart b/apps/lib/main.dart index 690e9a8..9e3e40c 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -5,10 +5,9 @@ import 'core/di/injection.dart'; import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; import 'core/notifications/local_notification_service.dart'; -import 'features/auth/data/models/auth_response.dart'; import 'features/auth/presentation/bloc/auth_bloc.dart'; import 'features/auth/presentation/bloc/auth_event.dart'; -import 'features/calendar/data/services/mock_calendar_service.dart'; +import 'features/calendar/data/services/calendar_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -18,16 +17,7 @@ void main() async { await notificationService.initialize(); final authBloc = sl(); - - if (Env.isMockApi) { - authBloc.add( - AuthLoggedIn( - user: AuthUser(id: 'user_001', email: 'test@example.com'), - ), - ); - } else { - authBloc.add(AuthStarted()); - } + authBloc.add(AuthStarted()); try { final now = DateTime.now(); diff --git a/apps/lib/shared/widgets/fixed_length_code_input.dart b/apps/lib/shared/widgets/fixed_length_code_input.dart new file mode 100644 index 0000000..1a08b25 --- /dev/null +++ b/apps/lib/shared/widgets/fixed_length_code_input.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/theme/design_tokens.dart'; + +typedef CodeValueChanged = void Function(String value); + +class FixedLengthCodeInput extends StatefulWidget { + final TextEditingController controller; + final int length; + final CodeValueChanged? onChanged; + final TextInputType keyboardType; + final Iterable? allowedCharacters; + final bool uppercase; + final String semanticLabel; + + const FixedLengthCodeInput({ + required this.controller, + required this.length, + required this.semanticLabel, + super.key, + this.onChanged, + this.keyboardType = TextInputType.text, + this.allowedCharacters, + this.uppercase = false, + }); + + @override + State createState() => _FixedLengthCodeInputState(); +} + +class _FixedLengthCodeInputState extends State { + late final FocusNode _focusNode; + bool _isFocused = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChanged); + widget.controller.addListener(_onControllerChanged); + } + + @override + void didUpdateWidget(covariant FixedLengthCodeInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onControllerChanged); + widget.controller.addListener(_onControllerChanged); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _focusNode.removeListener(_onFocusChanged); + _focusNode.dispose(); + super.dispose(); + } + + void _onFocusChanged() { + if (_isFocused != _focusNode.hasFocus) { + _isFocused = _focusNode.hasFocus; + } + } + + void _onControllerChanged() { + if (mounted) { + setState(() {}); + } + } + + void _handleRawChanged(String rawValue) { + final normalized = _normalize(rawValue); + if (normalized != widget.controller.text) { + widget.controller.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + widget.onChanged?.call(normalized); + } + + String _normalize(String value) { + var output = widget.uppercase ? value.toUpperCase() : value; + + if (widget.allowedCharacters != null) { + final allow = widget.allowedCharacters!.toSet(); + output = output.split('').where(allow.contains).join(); + } + + if (output.length > widget.length) { + output = output.substring(0, widget.length); + } + return output; + } + + @override + Widget build(BuildContext context) { + final chars = widget.controller.text.split(''); + final slotHeight = AppSpacing.xl * 2; + final slotSpacing = AppSpacing.sm; + + return Semantics( + label: widget.semanticLabel, + child: GestureDetector( + onTap: () => _focusNode.requestFocus(), + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: slotHeight, + child: Stack( + alignment: Alignment.center, + children: [ + Opacity( + opacity: 0, + child: SizedBox( + width: double.infinity, + height: slotHeight, + child: TextField( + controller: widget.controller, + focusNode: _focusNode, + keyboardType: widget.keyboardType, + inputFormatters: [ + LengthLimitingTextInputFormatter(widget.length), + ], + onChanged: _handleRawChanged, + autofillHints: const [AutofillHints.oneTimeCode], + ), + ), + ), + IgnorePointer( + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + for (var index = 0; index < widget.length; index++) ...[ + Expanded( + child: _buildCodeCell( + index: index, + chars: chars, + slotHeight: slotHeight, + ), + ), + if (index != widget.length - 1) + SizedBox(width: slotSpacing), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildCodeCell({ + required int index, + required List chars, + required double slotHeight, + }) { + final hasChar = index < chars.length; + final isActive = + (chars.length == index && _isFocused) || + (chars.length >= widget.length && index == widget.length - 1); + + return Container( + height: slotHeight, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all( + color: isActive ? AppColors.primary : AppColors.slate300, + ), + ), + child: Text( + hasChar ? chars[index] : '', + style: const TextStyle( + fontSize: AppSpacing.xl, + fontWeight: FontWeight.w600, + color: AppColors.slate900, + ), + ), + ); + } +} diff --git a/apps/lib/shared/widgets/link_button.dart b/apps/lib/shared/widgets/link_button.dart new file mode 100644 index 0000000..f336ccc --- /dev/null +++ b/apps/lib/shared/widgets/link_button.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; + +class LinkButton extends StatelessWidget { + const LinkButton({ + super.key, + required this.text, + required this.onTap, + this.enabled = true, + this.textAlign = TextAlign.center, + }); + + final String text; + final VoidCallback? onTap; + final bool enabled; + final TextAlign textAlign; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44, + child: TextButton( + onPressed: enabled ? onTap : null, + style: TextButton.styleFrom( + foregroundColor: enabled ? AppColors.slate500 : AppColors.slate300, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + child: Text( + text, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + textAlign: textAlign, + ), + ), + ); + } +} diff --git a/apps/lib/shared/widgets/message_composer.dart b/apps/lib/shared/widgets/message_composer.dart new file mode 100644 index 0000000..9fe79bb --- /dev/null +++ b/apps/lib/shared/widgets/message_composer.dart @@ -0,0 +1,248 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +import '../../core/theme/design_tokens.dart'; + +enum MessageComposerMode { text, holdToSpeak } + +enum MessageComposerProcess { idle, recording, transcribing } + +const messageComposerContainerKey = ValueKey('message_composer_container'); +const messageComposerPlusButtonKey = ValueKey('message_composer_plus_button'); +const messageComposerRightButtonKey = ValueKey('message_composer_right_button'); +const messageComposerHoldAreaKey = ValueKey('message_composer_hold_area'); +const messageComposerRecordingHintKey = ValueKey( + 'message_composer_recording_hint', +); +const _holdActivateDurationMs = 120; + +class MessageComposer extends StatelessWidget { + const MessageComposer({ + super.key, + required this.mode, + required this.process, + required this.hasMessage, + required this.isWaitingAgent, + required this.iconSize, + required this.composerMinHeight, + required this.onTapPlus, + required this.onTapRightAction, + required this.onHoldToSpeakStart, + required this.onHoldToSpeakEnd, + required this.onHoldToSpeakMoveUpdate, + required this.onHoldToSpeakCancel, + required this.textInputChild, + required this.recordingAnimation, + this.holdToSpeakText = '按住说话', + this.recordingText = '松手发送', + this.transcribingText = '语音识别中...', + this.recordingHintText = '松开发送,上滑取消', + this.showRecordingInlineFeedback = true, + }); + + final MessageComposerMode mode; + final MessageComposerProcess process; + final bool hasMessage; + final bool isWaitingAgent; + final double iconSize; + final double composerMinHeight; + final VoidCallback onTapPlus; + final VoidCallback onTapRightAction; + final VoidCallback onHoldToSpeakStart; + final VoidCallback onHoldToSpeakEnd; + final ValueChanged onHoldToSpeakMoveUpdate; + final VoidCallback onHoldToSpeakCancel; + final Widget textInputChild; + final Widget recordingAnimation; + final String holdToSpeakText; + final String recordingText; + final String transcribingText; + final String recordingHintText; + final bool showRecordingInlineFeedback; + + bool get _isHoldMode => mode == MessageComposerMode.holdToSpeak; + + bool get _isRecording => process == MessageComposerProcess.recording; + + bool get _isTranscribing => process == MessageComposerProcess.transcribing; + + @override + Widget build(BuildContext context) { + return Container( + key: messageComposerContainerKey, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.slate200), + boxShadow: const [ + BoxShadow( + color: AppColors.slate200, + blurRadius: AppRadius.lg, + offset: Offset(AppSpacing.none, AppSpacing.xs), + ), + BoxShadow( + color: AppColors.white, + blurRadius: AppRadius.md, + offset: Offset(AppSpacing.none, -AppSpacing.xs), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IgnorePointer( + ignoring: _isRecording && _isHoldMode, + child: Opacity( + opacity: _isRecording && _isHoldMode ? AppSpacing.none : 1, + child: IconButton( + key: messageComposerPlusButtonKey, + visualDensity: VisualDensity.compact, + onPressed: onTapPlus, + icon: Icon( + LucideIcons.plus, + size: iconSize, + color: AppColors.slate500, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded(child: _buildCenterArea()), + const SizedBox(width: AppSpacing.sm), + IconButton( + key: messageComposerRightButtonKey, + visualDensity: VisualDensity.compact, + onPressed: onTapRightAction, + icon: _isTranscribing + ? const SizedBox( + width: AppSpacing.lg, + height: AppSpacing.lg, + child: CircularProgressIndicator( + strokeWidth: AppSpacing.xs / 2, + color: AppColors.blue600, + ), + ) + : Icon( + _resolveRightIcon(), + size: iconSize, + color: _resolveRightIconColor(), + ), + ), + ], + ), + ); + } + + Widget _buildCenterArea() { + return SizedBox( + height: composerMinHeight, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + child: _isHoldMode + ? _buildHoldToSpeakArea(key: const ValueKey('hold_mode')) + : _buildTextInputArea(key: const ValueKey('text_mode')), + ), + ); + } + + Widget _buildTextInputArea({required Key key}) { + return SizedBox(key: key, height: composerMinHeight, child: textInputChild); + } + + Widget _buildHoldToSpeakArea({required Key key}) { + return RawGestureDetector( + key: messageComposerHoldAreaKey, + behavior: HitTestBehavior.opaque, + gestures: { + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + duration: const Duration(milliseconds: _holdActivateDurationMs), + ), + (instance) { + instance.onLongPressStart = (_) => onHoldToSpeakStart(); + instance.onLongPressEnd = (_) => onHoldToSpeakEnd(); + instance.onLongPressMoveUpdate = onHoldToSpeakMoveUpdate; + instance.onLongPressCancel = onHoldToSpeakCancel; + }, + ), + }, + child: Container( + key: key, + width: double.infinity, + height: composerMinHeight, + alignment: Alignment.center, + child: _buildHoldToSpeakContent(), + ), + ); + } + + Widget _buildHoldToSpeakContent() { + if (_isRecording) { + if (!showRecordingInlineFeedback) { + return Align( + alignment: Alignment.center, + child: Text( + recordingText, + style: const TextStyle(color: AppColors.slate700), + ), + ); + } + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + recordingAnimation, + const SizedBox(height: AppSpacing.xs), + Text( + recordingHintText, + key: messageComposerRecordingHintKey, + style: const TextStyle(color: AppColors.slate500), + ), + ], + ); + } + + if (_isTranscribing) { + return Align( + alignment: Alignment.center, + child: Text( + transcribingText, + style: const TextStyle(color: AppColors.slate500), + ), + ); + } + + return Align( + alignment: Alignment.center, + child: Text( + holdToSpeakText, + style: const TextStyle(color: AppColors.slate500), + ), + ); + } + + IconData _resolveRightIcon() { + if (isWaitingAgent) { + return LucideIcons.square; + } + if (hasMessage) { + return LucideIcons.send; + } + return _isHoldMode ? LucideIcons.keyboard : LucideIcons.mic; + } + + Color _resolveRightIconColor() { + if (isWaitingAgent || hasMessage) { + return AppColors.blue600; + } + return AppColors.slate500; + } +} diff --git a/apps/lib/shared/widgets/page_header.dart b/apps/lib/shared/widgets/page_header.dart index 6d9f873..2933316 100644 --- a/apps/lib/shared/widgets/page_header.dart +++ b/apps/lib/shared/widgets/page_header.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/theme/design_tokens.dart'; + class PageHeader extends StatelessWidget { final Widget? leading; final Widget? trailing; @@ -32,20 +34,23 @@ class BackButton extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onPressed ?? () => Navigator.of(context).pop(), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: const Color(0xFFF8FAFF), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: const Color(0xFFDEE7F6)), + return SizedBox( + width: AppSpacing.xxl * 2, + height: AppSpacing.xxl * 2, + child: TextButton( + onPressed: onPressed ?? () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + padding: const EdgeInsets.all(AppSpacing.none), + backgroundColor: AppColors.surfaceTertiary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + side: const BorderSide(color: AppColors.borderTertiary), + ), ), child: const Icon( Icons.chevron_left, - size: 18, - color: Color(0xFF334155), + size: AppSpacing.lg + AppSpacing.xs, + color: AppColors.slate700, ), ), ); diff --git a/apps/test/features/calendar/data/calendar_api_test.dart b/apps/test/features/calendar/data/calendar_api_test.dart deleted file mode 100644 index 9f688c4..0000000 --- a/apps/test/features/calendar/data/calendar_api_test.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/core/api/mock_api_client.dart'; -import 'package:social_app/features/calendar/data/calendar_api.dart'; -import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; - -void main() { - group('CalendarApi', () { - test('listByRange parses metadata and attachments', () async { - final client = MockApiClient(); - client.registerPatternHandler( - RegExp(r'^/api/v1/schedule-items\?.*$'), - 'GET', - (_) => [ - { - 'id': 'evt_1', - 'title': '晨会', - 'description': '同步', - 'start_at': '2026-03-11T01:00:00Z', - 'end_at': '2026-03-11T02:00:00Z', - 'timezone': 'Asia/Shanghai', - 'metadata': { - 'color': '#4F46E5', - 'location': '会议室A', - 'notes': '带电脑', - 'reminder_minutes': 15, - 'attachments': [ - { - 'name': '议程文档', - 'visible_to': ['u1'], - 'url': 'https://example.com/a', - 'note': '会前阅读', - 'content': null, - 'type': 'document', - }, - ], - 'version': 1, - 'new_field': 'future', - }, - 'status': 'active', - 'source_type': 'manual', - 'created_at': '2026-03-10T01:00:00Z', - 'updated_at': '2026-03-10T01:30:00Z', - }, - ], - ); - - final api = CalendarApi(client); - final result = await api.listByRange( - startAt: DateTime.utc(2026, 3, 1), - endAt: DateTime.utc(2026, 3, 31, 23, 59, 59), - ); - - expect(result, hasLength(1)); - expect(result.first.metadata?.attachments, hasLength(1)); - expect(result.first.metadata?.raw['new_field'], 'future'); - expect(result.first.metadata?.reminderMinutes, 15); - expect(result.first.startAt.isUtc, isFalse); - }); - - test('create serializes full metadata', () async { - final client = MockApiClient(); - client.registerHandler('/api/v1/schedule-items', 'POST', (request) { - final body = request.data as Map; - expect(body['metadata']['version'], 1); - expect(body['metadata']['reminder_minutes'], 15); - expect(body['metadata']['attachments'], isA>()); - return { - 'id': 'evt_2', - ...body, - 'status': 'active', - 'source_type': 'manual', - 'created_at': '2026-03-10T01:00:00Z', - 'updated_at': '2026-03-10T01:00:00Z', - }; - }); - - final api = CalendarApi(client); - final created = await api.create( - ScheduleItemModel( - id: 'evt_local', - ownerId: 'user-1', - title: '评审', - startAt: DateTime.utc(2026, 3, 11, 3), - endAt: DateTime.utc(2026, 3, 11, 4), - metadata: ScheduleMetadata( - color: '#F59E0B', - location: '线上', - notes: '准备 demo', - attachments: [Attachment(name: 'PRD', type: 'document')], - reminderMinutes: 15, - version: 1, - ), - ), - ); - - expect(created.id, 'evt_2'); - expect(created.metadata?.location, '线上'); - }); - - test('update does not send unknown metadata fields', () async { - final client = MockApiClient(); - client.registerHandler('/api/v1/schedule-items/evt_3', 'PATCH', ( - request, - ) { - final body = request.data as Map; - final metadata = body['metadata'] as Map; - expect(metadata.containsKey('new_field'), isFalse); - expect(metadata['reminder_minutes'], 30); - return { - 'id': 'evt_3', - ...body, - 'status': 'active', - 'source_type': 'manual', - 'created_at': '2026-03-10T01:00:00Z', - 'updated_at': '2026-03-11T01:00:00Z', - }; - }); - - final api = CalendarApi(client); - final event = ScheduleItemModel( - id: 'evt_3', - ownerId: 'user-1', - title: '同步会', - startAt: DateTime.utc(2026, 3, 11, 1), - metadata: ScheduleMetadata.fromJson({ - 'color': '#3B82F6', - 'location': '会议室B', - 'notes': '更新周报', - 'attachments': const [], - 'version': 1, - 'reminder_minutes': 30, - 'new_field': 'future', - }), - ); - - final updated = await api.update(event); - expect(updated.id, 'evt_3'); - }); - }); -} diff --git a/apps/test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart b/apps/test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart deleted file mode 100644 index c4442f1..0000000 --- a/apps/test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:social_app/core/di/injection.dart'; -import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; -import 'package:social_app/features/calendar/data/services/mock_calendar_service.dart'; -import 'package:social_app/features/calendar/ui/screens/calendar_event_detail_screen.dart'; - -class _FakeCalendarService extends CalendarService { - final ScheduleItemModel? event; - - _FakeCalendarService({required this.event}) : super(apiClient: null); - - @override - Future getEventById(String id) async { - return event; - } -} - -void main() { - final getIt = GetIt.instance; - - setUp(() async { - await getIt.reset(); - }); - - testWidgets('详情页显示结构化提醒时间并不显示metadata原样区块', (tester) async { - sl.registerSingleton( - _FakeCalendarService( - event: ScheduleItemModel( - id: 'evt_1', - ownerId: 'user-1', - title: '评审会', - startAt: DateTime(2026, 3, 11, 15, 0), - endAt: DateTime(2026, 3, 11, 16, 0), - metadata: ScheduleMetadata( - color: '#4F46E5', - location: '会议室A', - reminderMinutes: 15, - version: 1, - ), - ), - ), - ); - - await tester.pumpWidget( - const MaterialApp(home: CalendarEventDetailScreen(eventId: 'evt_1')), - ); - await tester.pumpAndSettle(); - - expect(find.text('提醒时间'), findsOneWidget); - expect(find.text('开始前15分钟'), findsOneWidget); - expect(find.text('metadata'), findsNothing); - }); - - testWidgets('提醒分钟为空时显示无', (tester) async { - sl.registerSingleton( - _FakeCalendarService( - event: ScheduleItemModel( - id: 'evt_2', - ownerId: 'user-1', - title: '同步会', - startAt: DateTime(2026, 3, 12, 10, 0), - metadata: ScheduleMetadata(version: 1), - ), - ), - ); - - await tester.pumpWidget( - const MaterialApp(home: CalendarEventDetailScreen(eventId: 'evt_2')), - ); - await tester.pumpAndSettle(); - - expect(find.text('提醒时间'), findsOneWidget); - expect(find.text('无'), findsOneWidget); - }); -} diff --git a/apps/test/features/chat/ag_ui_service_test.dart b/apps/test/features/chat/ag_ui_service_test.dart deleted file mode 100644 index 8ea313e..0000000 --- a/apps/test/features/chat/ag_ui_service_test.dart +++ /dev/null @@ -1,603 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:social_app/core/api/mock_api_client.dart'; -import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart'; -import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; -import 'package:social_app/features/chat/data/tools/route_navigation_tool.dart'; -import 'package:social_app/features/chat/data/tools/tool_registry.dart'; -import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; - -class TestableAgUiService extends AgUiService { - TestableAgUiService({super.onEvent}); - - @override - Future sendMessage(String content, {List? images}) async { - await mockEventStream(content); - } - - Future mockEventStream(String content) async { - final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}'; - final runId = 'run_${DateTime.now().millisecondsSinceEpoch}'; - final engine = AiDecisionEngine(); - - onEvent(RunStartedEvent(threadId: threadId, runId: runId)); - - final forceTrigger = engine.tryForceTrigger(content); - if (forceTrigger != null) { - await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args); - } - - final replies = generateReplies(content, engine); - if (replies.isNotEmpty) { - await mockTextMessageStream(replies); - } - - onEvent(RunFinishedEvent(threadId: threadId, runId: runId)); - } - - Future mockToolCallFlowWithArgs( - String toolName, - Map args, - ) async { - final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}'; - - onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName)); - - onEvent(ToolCallArgsEvent(toolCallId: toolCallId, delta: '{}')); - - onEvent(ToolCallEndEvent(toolCallId: toolCallId)); - - if (toolName == 'front.navigate_to_route') { - return; - } - - final validation = ToolRegistry.validateArgs(toolName, args); - if (!validation.ok) { - onEvent( - ToolCallErrorEvent( - toolCallId: toolCallId, - error: validation.error ?? 'Validation failed', - code: 'VALIDATION_ERROR', - ), - ); - return; - } - - try { - ToolRegistry.initialize(); - await ToolRegistry.execute(toolName, args); - final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; - - onEvent( - ToolCallResultEvent( - messageId: messageId, - toolCallId: toolCallId, - content: '{"result":{"ok":true}}', - ), - ); - } catch (e) { - onEvent( - ToolCallErrorEvent( - toolCallId: toolCallId, - error: e.toString(), - code: 'EXECUTION_ERROR', - ), - ); - } - } - - List generateReplies(String content, AiDecisionEngine engine) { - final intent = engine.matchIntent(content); - - switch (intent) { - case Intent.createEvent: - return ['好的,我已经为您创建了日程安排。']; - case Intent.searchEvent: - return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审']; - case Intent.unknown: - return ['我理解了您的问题,让我来帮您处理。']; - } - } - - Future mockTextMessageStream(List replies) async { - for (final reply in replies) { - final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; - - onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant')); - - onEvent(TextMessageContentEvent(messageId: messageId, delta: reply)); - - onEvent(TextMessageEndEvent(messageId: messageId)); - } - } -} - -void main() { - late TestableAgUiService service; - late List capturedEvents; - - setUp(() { - capturedEvents = []; - ToolRegistry.initialize(); - RouteNavigationTool.instance.clearNavigator(); - service = TestableAgUiService( - onEvent: (event) { - capturedEvents.add(event); - }, - ); - }); - - group('AgUiService', () { - test('sendMessage first emits RunStartedEvent', () async { - await service.sendMessage('你好'); - - expect(capturedEvents.first, isA()); - }); - - test('sendMessage last emits RunFinishedEvent', () async { - await service.sendMessage('你好'); - - expect(capturedEvents.last, isA()); - }); - - test('sendMessage emits events in correct order', () async { - await service.sendMessage('你好'); - - expect(capturedEvents.first, isA()); - expect(capturedEvents.last, isA()); - - final types = capturedEvents.map((e) => e.type).toList(); - expect(types.first, AgUiEventType.runStarted); - expect(types.last, AgUiEventType.runFinished); - }); - - test( - 'creating schedule text does not trigger frontend tool call events', - () async { - await service.sendMessage('提醒我明天10点开会'); - - final toolCallStarts = capturedEvents - .whereType() - .toList(); - final toolCallEnds = capturedEvents - .whereType() - .toList(); - final toolCallResults = capturedEvents - .whereType() - .toList(); - - expect(toolCallStarts.isEmpty, true); - expect(toolCallEnds.isEmpty, true); - expect(toolCallResults.isEmpty, true); - }, - ); - - test('force trigger with #tool syntax', () async { - await service.sendMessage( - '#tool:front.navigate_to_route {"target": "/calendar/dayweek"}', - ); - - final toolCallStarts = capturedEvents - .whereType() - .toList(); - - expect(toolCallStarts.isNotEmpty, true); - expect(toolCallStarts.first.toolCallName, 'front.navigate_to_route'); - }); - - test('text message events are emitted for unknown intent', () async { - await service.sendMessage('你好'); - - final textStarts = capturedEvents - .whereType() - .toList(); - final textContents = capturedEvents - .whereType() - .toList(); - final textEnds = capturedEvents.whereType().toList(); - - expect(textStarts.isNotEmpty, true); - expect(textContents.isNotEmpty, true); - expect(textEnds.isNotEmpty, true); - }); - - test('search intent does not trigger tool calls', () async { - await service.sendMessage('今天有什么日程'); - - final toolCallStarts = capturedEvents - .whereType() - .toList(); - - expect(toolCallStarts.isEmpty, true); - }); - - test('frontend tool call keeps pending state before approval', () async { - await service.sendMessage('#tool:front.navigate_to_route {}'); - - final toolCallErrors = capturedEvents - .whereType() - .toList(); - final toolCallStarts = capturedEvents - .whereType() - .toList(); - - expect(toolCallStarts.isNotEmpty, true); - expect(toolCallErrors.isEmpty, true); - }); - }); - - group('AgUiService real api-path mock', () { - test('sendMessage posts only current user message to run API', () async { - final client = MockApiClient(); - final service = AgUiService(onEvent: (_) {}, apiClient: client); - client.clearMocks(); - - Map? postedRunInput; - client.registerHandler('/api/v1/agent/runs', 'POST', (request) { - postedRunInput = request.data as Map; - return { - 'taskId': 'task-1', - 'threadId': 'thread-1', - 'runId': 'run-1', - 'created': false, - }; - }); - client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) { - return [ - 'event: RUN_STARTED', - 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}', - '', - 'event: RUN_FINISHED', - 'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}', - '', - ]; - }); - - await service.sendMessage('只发送当前输入'); - - expect(postedRunInput, isNotNull); - final messages = postedRunInput!['messages'] as List; - expect(messages.length, 1); - final first = messages.first as Map; - expect(first['role'], 'user'); - expect(first['content'], '只发送当前输入'); - }); - - test('sendMessage uploads images then posts binary url blocks', () async { - final client = MockApiClient(); - final service = AgUiService(onEvent: (_) {}, apiClient: client); - client.clearMocks(); - - var uploadCalls = 0; - final uploadedPath = 'agent-inputs/user/thread-1/upload-1.png'; - client.registerHandler('/api/v1/agent/attachments', 'POST', (request) { - uploadCalls += 1; - return { - 'attachment': { - 'bucket': 'bucket-test', - 'path': uploadedPath, - 'mimeType': 'image/png', - 'url': 'https://signed.example/$uploadedPath', - }, - }; - }); - - Map? postedRunInput; - client.registerHandler('/api/v1/agent/runs', 'POST', (request) { - postedRunInput = request.data as Map; - return { - 'taskId': 'task-1', - 'threadId': 'thread-1', - 'runId': 'run-1', - 'created': false, - }; - }); - client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) { - return [ - 'event: RUN_STARTED', - 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}', - '', - 'event: RUN_FINISHED', - 'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}', - '', - ]; - }); - - final image = XFile.fromData( - Uint8List.fromList([1, 2, 3]), - mimeType: 'image/png', - name: 'demo.png', - ); - - await service.sendMessage('图文消息', images: [image]); - - expect(uploadCalls, 1); - expect(postedRunInput, isNotNull); - final messages = postedRunInput!['messages'] as List; - final first = messages.first as Map; - final content = first['content'] as List; - expect((content.first as Map)['type'], 'text'); - expect((content[1] as Map)['type'], 'binary'); - expect( - (content[1] as Map)['url'], - 'https://signed.example/$uploadedPath', - ); - final forwardedProps = - postedRunInput!['forwardedProps'] as Map; - final attachments = forwardedProps['attachments'] as List; - expect((attachments.first as Map)['path'], uploadedPath); - }); - - test('approveToolCall posts only tool message to resume API', () async { - final client = MockApiClient(); - final service = AgUiService(onEvent: (_) {}, apiClient: client); - RouteNavigationTool.instance.bindNavigator((_, {replace = false}) { - final _ = replace; - }); - client.clearMocks(); - - client.registerHandler('/api/v1/agent/runs', 'POST', (_) { - return { - 'taskId': 'task-1', - 'threadId': 'thread-1', - 'runId': 'run-1', - 'created': false, - }; - }); - var eventCallCount = 0; - client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) { - eventCallCount += 1; - if (eventCallCount == 1) { - return [ - 'event: RUN_STARTED', - 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}', - '', - 'event: RUN_FINISHED', - 'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}', - '', - ]; - } - return [ - 'event: RUN_STARTED', - 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}', - '', - 'event: RUN_FINISHED', - 'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}', - '', - ]; - }); - - Map? postedResumeInput; - client.registerHandler('/api/v1/agent/runs/thread-1/resume', 'POST', ( - request, - ) { - postedResumeInput = request.data as Map; - return { - 'taskId': 'task-2', - 'threadId': 'thread-1', - 'runId': 'run-2', - 'created': false, - }; - }); - - await service.sendMessage('初始化会话'); - await service.approveToolCall( - toolCallId: 'call-1', - toolName: 'front.navigate_to_route', - args: { - 'target': '/calendar/dayweek', - 'replace': false, - '__nonce': 'nonce-1', - }, - ); - - expect(postedResumeInput, isNotNull); - final messages = postedResumeInput!['messages'] as List; - expect(messages.length, 1); - final first = messages.first as Map; - expect(first['role'], 'tool'); - expect(first.containsKey('toolCallId'), true); - }); - - test('approveToolCall resumes and emits TOOL_CALL_RESULT', () async { - final events = []; - final realService = AgUiService(onEvent: events.add); - RouteNavigationTool.instance.bindNavigator((_, {replace = false}) { - final _ = replace; - }); - - await realService.sendMessage('打开日历页面'); - - final toolStart = events.whereType().first; - final toolArgsEvent = events.whereType().firstWhere( - (e) => e.toolCallId == toolStart.toolCallId, - ); - final toolArgs = jsonDecode(toolArgsEvent.delta) as Map; - expect(toolStart.toolCallName, 'front.navigate_to_route'); - expect( - events - .whereType() - .where((e) => e.toolCallId == toolStart.toolCallId) - .isEmpty, - true, - ); - - await realService.approveToolCall( - toolCallId: toolStart.toolCallId, - toolName: 'front.navigate_to_route', - args: toolArgs, - ); - - final results = events - .whereType() - .where((e) => e.toolCallId == toolStart.toolCallId) - .toList(); - expect(results.isNotEmpty, true); - }); - - test('approveToolCall aborts when local tool execution fails', () async { - final events = []; - final realService = AgUiService(onEvent: events.add); - - await realService.sendMessage('打开日历页面'); - final toolStart = events.whereType().first; - final toolArgsEvent = events.whereType().firstWhere( - (e) => e.toolCallId == toolStart.toolCallId, - ); - final toolArgs = jsonDecode(toolArgsEvent.delta) as Map; - - // replace navigator -> true 会失败,因为未绑定 navigator。 - toolArgs['target'] = '/settings'; - expect( - () => realService.approveToolCall( - toolCallId: toolStart.toolCallId, - toolName: 'front.navigate_to_route', - args: toolArgs, - ), - throwsA(isA()), - ); - }); - - test('stream ignores malformed SSE payload and continues', () async { - final events = []; - final client = MockApiClient(); - final service = AgUiService(onEvent: events.add, apiClient: client); - client.clearMocks(); - client.registerHandler('/api/v1/agent/runs', 'POST', (_) { - return { - 'taskId': 'task-1', - 'threadId': 'thread-1', - 'runId': 'run-1', - 'created': false, - }; - }); - client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) { - return [ - 'event: RUN_STARTED', - 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}', - '', - 'event: TEXT_MESSAGE_CONTENT', - 'data: {bad-json', - '', - 'event: TEXT_MESSAGE_CONTENT', - 'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m1","delta":"ok"}', - '', - 'event: RUN_FINISHED', - 'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}', - '', - ]; - }); - - await service.sendMessage('hi'); - - expect(events.whereType().length, 1); - expect(events.whereType().length, 1); - expect(events.whereType().length, 1); - }); - - test('subsequent SSE requests carry Last-Event-ID header', () async { - final client = MockApiClient(); - final service = AgUiService(onEvent: (_) {}, apiClient: client); - client.clearMocks(); - var runCount = 0; - final seenLastEventIds = []; - client.registerHandler('/api/v1/agent/runs', 'POST', (_) { - runCount += 1; - return { - 'taskId': 'task-$runCount', - 'threadId': 'thread-1', - 'runId': 'run-$runCount', - 'created': false, - }; - }); - client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', ( - request, - ) { - seenLastEventIds.add(request.headers?['Last-Event-ID']); - if (runCount == 1) { - return [ - 'id: 1-0', - 'event: RUN_STARTED', - 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}', - '', - 'id: 2-0', - 'event: RUN_FINISHED', - 'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}', - '', - ]; - } - return [ - 'id: 3-0', - 'event: RUN_STARTED', - 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}', - '', - 'id: 4-0', - 'event: RUN_FINISHED', - 'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}', - '', - ]; - }); - - await service.sendMessage('first'); - await service.sendMessage('second'); - - expect(seenLastEventIds.length, 2); - expect(seenLastEventIds[0], isNull); - expect(seenLastEventIds[1], '2-0'); - }); - - test('stream parses backend TOOL_CALL_RESULT payload with ui field', () async { - final events = []; - final client = MockApiClient(); - final service = AgUiService(onEvent: events.add, apiClient: client); - client.clearMocks(); - client.registerHandler('/api/v1/agent/runs', 'POST', (_) { - return { - 'taskId': 'task-1', - 'threadId': 'thread-1', - 'runId': 'run-1', - 'created': false, - }; - }); - client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) { - return [ - 'event: RUN_STARTED', - 'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}', - '', - 'event: TOOL_CALL_RESULT', - 'data: {"type":"TOOL_CALL_RESULT","messageId":"tool-result-1","toolCallId":"call-1","callId":"call-1","toolName":"calendar_write","result":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"ui":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"content":"已创建日程:项目评审(明天 10:00)"}', - '', - 'event: RUN_FINISHED', - 'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}', - '', - ]; - }); - - await service.sendMessage('创建日程'); - - final result = events.whereType().toList(); - expect(result.length, 1); - expect(result.first.ui?.cardType, 'calendar_operation.v1'); - }); - - test('fetchAttachmentPreview returns binary bytes', () async { - final client = MockApiClient(); - final service = AgUiService(onEvent: (_) {}, apiClient: client); - client.clearMocks(); - client.registerHandler( - '/api/v1/agent/runs/t1/attachments/m1/0', - 'GET', - (_) => [1, 2, 3, 4], - ); - - final data = await service.fetchAttachmentPreview( - '/api/v1/agent/runs/t1/attachments/m1/0', - ); - - expect(data, [1, 2, 3, 4]); - }); - }); -} diff --git a/apps/test/features/chat/chat_bloc_test.dart b/apps/test/features/chat/chat_bloc_test.dart deleted file mode 100644 index 611d94b..0000000 --- a/apps/test/features/chat/chat_bloc_test.dart +++ /dev/null @@ -1,410 +0,0 @@ -import 'dart:typed_data'; - -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; -import 'package:social_app/features/chat/data/models/chat_list_item.dart'; -import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; -import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; - -class MockAgUiService extends AgUiService { - MockAgUiService() : super(onEvent: (_) {}); - - int previewCalls = 0; - - @override - Future sendMessage(String content, {List? images}) async {} - - @override - Future fetchAttachmentPreview(String previewPath) async { - previewCalls += 1; - await Future.delayed(const Duration(milliseconds: 10)); - return Uint8List.fromList([1, 2, 3]); - } -} - -class _ThrowingAgUiService extends AgUiService { - _ThrowingAgUiService() : super(onEvent: (_) {}); - - @override - Future sendMessage(String content, {List? images}) async { - throw StateError('network down'); - } -} - -void main() { - late ChatBloc chatBloc; - late AgUiService service; - - setUp(() { - service = MockAgUiService(); - chatBloc = ChatBloc(service: service); - }); - - tearDown(() { - chatBloc.close(); - }); - - group('ChatBloc', () { - test('initial state is empty', () { - expect(chatBloc.state.items, isEmpty); - expect(chatBloc.state.isLoading, false); - expect(chatBloc.state.isSending, false); - expect(chatBloc.state.isWaitingFirstToken, false); - expect(chatBloc.state.isStreaming, false); - expect(chatBloc.state.currentMessageId, isNull); - expect(chatBloc.state.error, isNull); - }); - - blocTest( - 'sendMessage adds user message to items', - build: () => chatBloc, - act: (bloc) => bloc.sendMessage('Hello'), - expect: () => [ - isA() - .having((state) => state.items.length, 'items length', 1) - .having((state) => state.isSending, 'isSending', true) - .having( - (state) => state.isWaitingFirstToken, - 'isWaitingFirstToken', - true, - ) - .having( - (state) => state.items.first, - 'first item', - isA().having( - (item) => item.content, - 'content', - 'Hello', - ), - ), - ], - ); - - blocTest( - 'textMessageStart event adds AI message with streaming', - build: () => chatBloc, - act: (bloc) { - bloc.emit(chatBloc.state.copyWith(isStreaming: true)); - service.onEvent( - TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'), - ); - }, - expect: () => [ - isA().having((s) => s.isStreaming, 'isStreaming', true), - isA() - .having((s) => s.items.length, 'items length', 1) - .having((s) => s.currentMessageId, 'currentMessageId', 'msg_1') - .having( - (s) => s.items.first, - 'first item', - isA() - .having((item) => item.isStreaming, 'isStreaming', true) - .having((item) => item.sender, 'sender', MessageSender.ai), - ), - ], - ); - - blocTest( - 'textMessageContent event appends content', - build: () => chatBloc, - seed: () => ChatState( - items: [ - TextMessageItem( - id: 'msg_1', - content: '', - timestamp: DateTime.now(), - sender: MessageSender.ai, - isStreaming: true, - ), - ], - currentMessageId: 'msg_1', - ), - act: (bloc) { - service.onEvent( - TextMessageContentEvent(messageId: 'msg_1', delta: 'Hello'), - ); - }, - expect: () => [ - isA().having( - (s) => (s.items.first as TextMessageItem).content, - 'content', - 'Hello', - ), - ], - ); - - blocTest( - 'textMessageEnd event sets isStreaming to false', - build: () => chatBloc, - seed: () => ChatState( - items: [ - TextMessageItem( - id: 'msg_1', - content: 'Hello World', - timestamp: DateTime.now(), - sender: MessageSender.ai, - isStreaming: true, - ), - ], - currentMessageId: 'msg_1', - ), - act: (bloc) { - service.onEvent(TextMessageEndEvent(messageId: 'msg_1')); - }, - expect: () => [ - isA() - .having((s) => s.currentMessageId, 'currentMessageId', isNull) - .having((s) => s.isStreaming, 'isStreaming', false) - .having( - (s) => (s.items.first as TextMessageItem).isStreaming, - 'isStreaming', - false, - ), - ], - ); - - blocTest( - 'runStarted sets isLoading to true', - build: () => chatBloc, - act: (bloc) { - service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1')); - }, - expect: () => [ - isA() - .having((s) => s.isLoading, 'isLoading', true) - .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true) - .having((s) => s.error, 'error', isNull), - ], - ); - - blocTest( - 'runFinished sets isLoading to false', - build: () => chatBloc, - seed: () => const ChatState(isWaitingFirstToken: true), - act: (bloc) { - service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1')); - }, - expect: () => [ - isA() - .having((s) => s.isLoading, 'isLoading', false) - .having((s) => s.currentMessageId, 'currentMessageId', isNull), - ], - ); - - blocTest( - 'step events update currentStage', - build: () => chatBloc, - act: (bloc) { - service.onEvent(StepStartedEvent(stepName: 'execution')); - service.onEvent(StepFinishedEvent(stepName: 'execution')); - }, - expect: () => [ - isA().having( - (s) => s.currentStage, - 'currentStage', - AgentStage.execution, - ), - isA().having((s) => s.currentStage, 'currentStage', isNull), - ], - ); - - blocTest( - 'runError sets error message', - build: () => chatBloc, - seed: () => const ChatState(isWaitingFirstToken: true), - act: (bloc) { - service.onEvent( - RunErrorEvent(message: 'Something went wrong', code: 'ERR'), - ); - }, - expect: () => [ - isA() - .having((s) => s.isLoading, 'isLoading', false) - .having((s) => s.currentMessageId, 'currentMessageId', isNull) - .having((s) => s.error, 'error', 'Something went wrong'), - ], - ); - - blocTest( - 'cancelCurrentRun exits waiting states', - build: () => chatBloc, - seed: () => const ChatState(isWaitingFirstToken: true), - act: (bloc) => bloc.cancelCurrentRun(), - expect: () => [ - isA().having((s) => s.isCancelling, 'isCancelling', true), - isA() - .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false) - .having((s) => s.isStreaming, 'isStreaming', false) - .having((s) => s.isCancelling, 'isCancelling', false), - ], - ); - - blocTest( - 'sendMessage failure emits error and exits waiting state', - build: () => ChatBloc(service: _ThrowingAgUiService()), - act: (bloc) => bloc.sendMessage('hello'), - expect: () => [ - isA() - .having((s) => s.isSending, 'isSending', true) - .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true), - isA() - .having((s) => s.isSending, 'isSending', false) - .having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false) - .having((s) => s.error, 'error', contains('network down')), - ], - ); - - blocTest( - 'clearError removes error', - build: () => chatBloc, - seed: () => const ChatState(error: 'Some error'), - act: (bloc) => bloc.clearError(), - expect: () => [isA().having((s) => s.error, 'error', isNull)], - ); - - blocTest( - 'toolCallStart adds ToolCallItem', - build: () => chatBloc, - act: (bloc) { - service.onEvent( - ToolCallStartEvent( - toolCallId: 'tc_1', - toolCallName: 'back.mutate_calendar_event', - ), - ); - }, - expect: () => [ - isA().having( - (s) { - final item = s.items.first; - return item is ToolCallItem && - item.toolName == 'back.mutate_calendar_event' && - item.status == ToolCallStatus.pending; - }, - 'has pending tool call', - true, - ), - ], - ); - - blocTest( - 'toolCallResult without ui removes pending tool call and does not add empty card', - build: () => chatBloc, - seed: () => ChatState( - items: [ - ToolCallItem( - id: 'tc_1', - callId: 'tc_1', - toolName: 'front.navigate_to_route', - args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'}, - status: ToolCallStatus.executing, - timestamp: DateTime.now(), - sender: MessageSender.ai, - ), - ], - ), - act: (bloc) { - service.onEvent( - ToolCallResultEvent( - messageId: 'msg_tool_1', - toolCallId: 'tc_1', - content: '{"result":{"ok":true}}', - ), - ); - }, - expect: () => [ - isA().having((s) => s.items.isEmpty, 'items empty', true), - ], - ); - - blocTest( - 'toolCallResult with ui in payload.result adds ToolResultItem', - build: () => chatBloc, - seed: () => ChatState( - items: [ - ToolCallItem( - id: 'tc_2', - callId: 'tc_2', - toolName: 'back.mutate_calendar_event', - args: {'operation': 'create'}, - status: ToolCallStatus.executing, - timestamp: DateTime.now(), - sender: MessageSender.ai, - ), - ], - ), - act: (bloc) { - service.onEvent( - ToolCallResultEvent( - messageId: 'msg_tool_2', - toolCallId: 'tc_2', - content: - '{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true,"message":"done"},"actions":[]}}', - ), - ); - }, - expect: () => [ - isA().having( - (s) => s.items.first is ToolResultItem, - 'first item is ToolResultItem', - true, - ), - ], - ); - - blocTest( - 'state snapshot user message keeps attachments', - build: () => chatBloc, - act: (bloc) { - service.onEvent( - StateSnapshotEvent( - snapshot: { - 'scope': 'history_day', - 'messages': [ - { - 'id': 'u1', - 'role': 'user', - 'content': '请分析这张图', - 'attachments': [ - {'bucket': 'b', 'path': 'p', 'mimeType': 'image/png'}, - ], - }, - ], - }, - ), - ); - }, - expect: () => [ - isA().having( - (s) { - final item = s.items.first; - return item is TextMessageItem && item.attachments.length == 1; - }, - 'user attachment count', - true, - ), - ], - ); - - test( - 'loadAttachmentPreview deduplicates in-flight and caches result', - () async { - final mock = service as MockAgUiService; - final results = await Future.wait([ - chatBloc.loadAttachmentPreview('/api/preview/1'), - chatBloc.loadAttachmentPreview('/api/preview/1'), - ]); - final secondRound = await chatBloc.loadAttachmentPreview( - '/api/preview/1', - ); - - expect(results.first, isNotNull); - expect(results.last, isNotNull); - expect(secondRound, isNotNull); - expect(mock.previewCalls, 1); - }, - ); - }); -} diff --git a/apps/test/features/home/ui/screens/home_screen_test.dart b/apps/test/features/home/ui/screens/home_screen_test.dart deleted file mode 100644 index 5e15b1e..0000000 --- a/apps/test/features/home/ui/screens/home_screen_test.dart +++ /dev/null @@ -1,323 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:lucide_icons/lucide_icons.dart'; -import 'package:social_app/core/api/api_exception.dart'; -import 'package:social_app/core/api/mock_api_client.dart'; -import 'package:social_app/core/di/injection.dart'; -import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; -import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; -import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; -import 'package:social_app/features/home/data/voice_recorder.dart'; -import 'package:social_app/features/home/ui/screens/home_screen.dart'; -import 'package:social_app/features/messages/data/inbox_api.dart'; - -class _FakeVoiceRecorder implements VoiceRecorder { - bool started = false; - String? stoppedPath; - - @override - Future start() async { - started = true; - } - - @override - Future stop() async { - started = false; - stoppedPath ??= - '${Directory.systemTemp.path}/test-audio-${DateTime.now().microsecondsSinceEpoch}.wav'; - return stoppedPath; - } - - @override - Future dispose() async {} -} - -class _WaitingAgUiService extends AgUiService { - _WaitingAgUiService() : super(onEvent: (_) {}); - - final Completer _pending = Completer(); - - @override - Future sendMessage(String content, {List? images}) async { - onEvent(RunStartedEvent(threadId: 't1', runId: 'r1')); - return _pending.future; - } - - void emitStepStarted(String stepName) { - onEvent(StepStartedEvent(stepName: stepName)); - } -} - -void main() { - setUpAll(() { - if (!sl.isRegistered()) { - sl.registerSingleton(InboxApi(MockApiClient())); - } - }); - - IconData _inputActionIcon(WidgetTester tester) { - final icon = tester.widget( - find.byKey(const ValueKey('home_input_action_icon')), - ); - return icon.icon!; - } - - group('HomeScreen Widget Tests', () { - testWidgets('displays input field', (WidgetTester tester) async { - await tester.pumpWidget( - const MaterialApp(home: HomeScreen(autoLoadHistory: false)), - ); - await tester.pumpAndSettle(); - - expect(find.byType(TextField), findsOneWidget); - expect(find.text('输入消息...'), findsOneWidget); - }); - - testWidgets('displays header icons', (WidgetTester tester) async { - await tester.pumpWidget( - const MaterialApp(home: HomeScreen(autoLoadHistory: false)), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(LucideIcons.settings), findsOneWidget); - expect(find.byIcon(LucideIcons.calendar), findsOneWidget); - expect(find.byIcon(LucideIcons.messageSquare), findsOneWidget); - }); - - testWidgets('displays send or mic icon based on input', ( - WidgetTester tester, - ) async { - await tester.pumpWidget( - const MaterialApp(home: HomeScreen(autoLoadHistory: false)), - ); - await tester.pumpAndSettle(); - - expect(find.byIcon(LucideIcons.mic), findsOneWidget); - }); - - testWidgets('tap mic starts recording and shows listening state', ( - WidgetTester tester, - ) async { - final fakeRecorder = _FakeVoiceRecorder(); - await tester.pumpWidget( - MaterialApp( - home: HomeScreen(voiceRecorder: fakeRecorder, autoLoadHistory: false), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(LucideIcons.mic)); - await tester.pump(); - - expect(fakeRecorder.started, true); - expect(find.text('正在聆听...'), findsOneWidget); - expect(_inputActionIcon(tester), LucideIcons.send); - }); - - testWidgets('tap send while recording transcribes and auto sends message', ( - WidgetTester tester, - ) async { - final fakeRecorder = _FakeVoiceRecorder(); - String? sentTranscript; - await tester.pumpWidget( - MaterialApp( - home: HomeScreen( - voiceRecorder: fakeRecorder, - autoLoadHistory: false, - onTranscribeAudio: (filePath) async { - expect(filePath.endsWith('.wav'), true); - return '语音自动发送'; - }, - onAutoSendTranscript: (transcript) async { - sentTranscript = transcript; - }, - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(const Duration(milliseconds: 300)); - - expect(sentTranscript, '语音自动发送'); - expect(find.byIcon(LucideIcons.plus), findsOneWidget); - }); - - testWidgets('tap stop enters transcribing state', ( - WidgetTester tester, - ) async { - final fakeRecorder = _FakeVoiceRecorder(); - await tester.pumpWidget( - MaterialApp( - home: HomeScreen( - voiceRecorder: fakeRecorder, - autoLoadHistory: false, - onTranscribeAudio: (filePath) async { - expect(filePath.endsWith('.wav'), true); - return '语音转文字结果'; - }, - onAutoSendTranscript: (_) async {}, - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - - expect(find.text('语音识别中...'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsAtLeastNWidgets(1)); - }); - - testWidgets('tap stop shows readable unauthorized message', ( - WidgetTester tester, - ) async { - final fakeRecorder = _FakeVoiceRecorder(); - await tester.pumpWidget( - MaterialApp( - home: HomeScreen( - voiceRecorder: fakeRecorder, - autoLoadHistory: false, - onTranscribeAudio: (_) async { - throw const UnauthorizedException(); - }, - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(const Duration(milliseconds: 300)); - - expect(find.text('请重新登录'), findsOneWidget); - await tester.pump(const Duration(seconds: 3)); - }); - - testWidgets('tap stop shows message when transcript is empty', ( - WidgetTester tester, - ) async { - final fakeRecorder = _FakeVoiceRecorder(); - await tester.pumpWidget( - MaterialApp( - home: HomeScreen( - voiceRecorder: fakeRecorder, - autoLoadHistory: false, - onTranscribeAudio: (_) async => '', - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(const Duration(milliseconds: 300)); - - expect(find.text('未识别到有效语音,请靠近麦克风并连续说话后重试'), findsOneWidget); - await tester.pump(const Duration(seconds: 3)); - }); - - testWidgets('shows transcribing indicator while waiting ASR result', ( - WidgetTester tester, - ) async { - final fakeRecorder = _FakeVoiceRecorder(); - final completer = Completer(); - - await tester.pumpWidget( - MaterialApp( - home: HomeScreen( - voiceRecorder: fakeRecorder, - autoLoadHistory: false, - onTranscribeAudio: (_) => completer.future, - onAutoSendTranscript: (_) async {}, - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - - expect(find.text('语音识别中...'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - - completer.complete('识别完成'); - }); - - testWidgets('tap send unfocuses text input after sending', ( - WidgetTester tester, - ) async { - await tester.pumpWidget( - const MaterialApp(home: HomeScreen(autoLoadHistory: false)), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(TextField)); - await tester.pump(); - await tester.enterText(find.byType(TextField), 'hello'); - await tester.pump(); - - final editableBefore = tester.state( - find.byType(EditableText), - ); - expect(editableBefore.widget.focusNode.hasFocus, isTrue); - - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - - final editableAfter = tester.state( - find.byType(EditableText), - ); - expect(editableAfter.widget.focusNode.hasFocus, isFalse); - - await tester.pump(const Duration(milliseconds: 300)); - }); - - testWidgets('shows stop icon and waiting indicator while waiting agent', ( - WidgetTester tester, - ) async { - final waitingService = _WaitingAgUiService(); - final chatBloc = ChatBloc(service: waitingService); - await tester.pumpWidget( - MaterialApp( - home: HomeScreen(autoLoadHistory: false, chatBloc: chatBloc), - ), - ); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField), 'hello'); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - - expect(_inputActionIcon(tester), LucideIcons.square); - expect(find.text('正在思考...'), findsOneWidget); - - waitingService.emitStepStarted('intent'); - await tester.pump(); - - expect(find.text('意图识别中'), findsOneWidget); - expect(find.text('正在思考...'), findsNothing); - - await tester.tap(find.byKey(const ValueKey('home_input_action_button'))); - await tester.pump(); - - expect(find.text('已停止等待回复'), findsOneWidget); - await tester.pump(const Duration(seconds: 3)); - - await chatBloc.close(); - }); - }); -} diff --git a/apps/test/features/home/ui/widgets/home_composer_test.dart b/apps/test/features/home/ui/widgets/home_composer_test.dart new file mode 100644 index 0000000..abf363e --- /dev/null +++ b/apps/test/features/home/ui/widgets/home_composer_test.dart @@ -0,0 +1,237 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:social_app/shared/widgets/message_composer.dart'; + +Widget _buildTestApp({ + required MessageComposerMode mode, + required MessageComposerProcess process, + required bool hasMessage, + required bool isWaitingAgent, + VoidCallback? onHoldStart, + VoidCallback? onHoldEnd, + VoidCallback? onHoldCancel, +}) { + return MaterialApp( + home: Scaffold( + body: MessageComposer( + mode: mode, + process: process, + hasMessage: hasMessage, + isWaitingAgent: isWaitingAgent, + iconSize: 24, + composerMinHeight: 48, + onTapPlus: () {}, + onTapRightAction: () {}, + onHoldToSpeakStart: onHoldStart ?? () {}, + onHoldToSpeakEnd: onHoldEnd ?? () {}, + onHoldToSpeakMoveUpdate: (_) {}, + onHoldToSpeakCancel: onHoldCancel ?? () {}, + textInputChild: const SizedBox.shrink(), + recordingAnimation: const SizedBox.shrink(), + ), + ), + ); +} + +void main() { + group('MessageComposer', () { + testWidgets('renders one unified rounded composer container', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestApp( + mode: MessageComposerMode.text, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + ), + ); + + expect(find.byKey(messageComposerContainerKey), findsOneWidget); + + final containerFinder = find.byKey(messageComposerContainerKey); + final plusFinder = find.byKey(messageComposerPlusButtonKey); + final rightFinder = find.byKey(messageComposerRightButtonKey); + + expect( + find.descendant(of: containerFinder, matching: plusFinder), + findsOneWidget, + ); + expect( + find.descendant(of: containerFinder, matching: rightFinder), + findsOneWidget, + ); + }); + + testWidgets('right action icon follows state priority', (tester) async { + Future rightIconFor({ + required MessageComposerMode mode, + required MessageComposerProcess process, + required bool hasMessage, + required bool isWaitingAgent, + }) async { + await tester.pumpWidget( + _buildTestApp( + mode: mode, + process: process, + hasMessage: hasMessage, + isWaitingAgent: isWaitingAgent, + ), + ); + + final iconFinder = find.descendant( + of: find.byKey(messageComposerRightButtonKey), + matching: find.byType(Icon), + ); + final iconWidget = tester.widget(iconFinder.first); + return iconWidget.icon!; + } + + expect( + await rightIconFor( + mode: MessageComposerMode.text, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: true, + ), + LucideIcons.square, + ); + + expect( + await rightIconFor( + mode: MessageComposerMode.holdToSpeak, + process: MessageComposerProcess.idle, + hasMessage: true, + isWaitingAgent: false, + ), + LucideIcons.send, + ); + + expect( + await rightIconFor( + mode: MessageComposerMode.holdToSpeak, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + ), + LucideIcons.keyboard, + ); + + expect( + await rightIconFor( + mode: MessageComposerMode.text, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + ), + LucideIcons.mic, + ); + }); + + testWidgets('recording hint appears only while recording', (tester) async { + await tester.pumpWidget( + _buildTestApp( + mode: MessageComposerMode.holdToSpeak, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + ), + ); + expect(find.byKey(messageComposerRecordingHintKey), findsNothing); + + await tester.pumpWidget( + _buildTestApp( + mode: MessageComposerMode.holdToSpeak, + process: MessageComposerProcess.recording, + hasMessage: false, + isWaitingAgent: false, + ), + ); + expect(find.byKey(messageComposerRecordingHintKey), findsOneWidget); + expect(find.text('松开发送,上滑取消'), findsOneWidget); + }); + + testWidgets('composer height remains stable across mode switches', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestApp( + mode: MessageComposerMode.text, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + ), + ); + final textHeight = tester.getSize( + find.byKey(messageComposerContainerKey), + ); + + await tester.pumpWidget( + _buildTestApp( + mode: MessageComposerMode.holdToSpeak, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + ), + ); + final holdHeight = tester.getSize( + find.byKey(messageComposerContainerKey), + ); + + expect(textHeight.height, holdHeight.height); + }); + + testWidgets('invokes long press start/end callbacks in hold mode', ( + tester, + ) async { + var started = false; + var ended = false; + + await tester.pumpWidget( + _buildTestApp( + mode: MessageComposerMode.holdToSpeak, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + onHoldStart: () => started = true, + onHoldEnd: () => ended = true, + ), + ); + + final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey)); + final gesture = await tester.startGesture(center); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10)); + await gesture.up(); + await tester.pump(); + + expect(started, isTrue); + expect(ended, isTrue); + }); + + testWidgets('invokes long press cancel callback when gesture canceled', ( + tester, + ) async { + var canceled = false; + + await tester.pumpWidget( + _buildTestApp( + mode: MessageComposerMode.holdToSpeak, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + onHoldCancel: () => canceled = true, + ), + ); + + final center = tester.getCenter(find.byKey(messageComposerHoldAreaKey)); + final gesture = await tester.startGesture(center); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10)); + await gesture.cancel(); + await tester.pump(); + + expect(canceled, isTrue); + }); + }); +} diff --git a/apps/test/widget_test.dart b/apps/test/widget_test.dart deleted file mode 100644 index ce52c23..0000000 --- a/apps/test/widget_test.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:social_app/main.dart'; -import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; -import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; -import 'package:social_app/features/auth/data/auth_repository.dart'; -import 'package:social_app/core/di/injection.dart'; - -class MockAuthBloc extends Mock implements AuthBloc {} - -class MockAuthRepository extends Mock implements AuthRepository {} - -class FakeAuthState extends Fake implements AuthState {} - -void main() { - setUpAll(() { - registerFallbackValue(FakeAuthState()); - }); - - setUp(() async { - if (sl.isRegistered()) { - await sl.reset(); - } - sl.registerSingleton(MockAuthRepository()); - }); - - testWidgets('Login screen loads correctly', (WidgetTester tester) async { - final mockAuthBloc = MockAuthBloc(); - when(() => mockAuthBloc.state).thenReturn(AuthInitial()); - when( - () => mockAuthBloc.stream, - ).thenAnswer((_) => Stream.value(AuthInitial())); - - await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc)); - expect(find.text('linksy'), findsOneWidget); - expect(find.text('登录'), findsOneWidget); - expect(find.text('还没有账号?去注册'), findsOneWidget); - }); - - testWidgets('Main content is vertically centered above footer', ( - WidgetTester tester, - ) async { - final mockAuthBloc = MockAuthBloc(); - when(() => mockAuthBloc.state).thenReturn(AuthInitial()); - when( - () => mockAuthBloc.stream, - ).thenAnswer((_) => Stream.value(AuthInitial())); - - await tester.pumpWidget(LinksyApp(authBloc: mockAuthBloc)); - - final safeAreaRect = tester.getRect(find.byType(SafeArea)); - final mainRect = tester.getRect( - find.byKey(const Key('login_main_content')), - ); - final footerRect = tester.getRect(find.byKey(const Key('login_footer'))); - - final topSpace = mainRect.top - safeAreaRect.top; - final bottomSpace = footerRect.top - mainRect.bottom; - - expect((topSpace - bottomSpace).abs(), lessThanOrEqualTo(2)); - }); - - testWidgets('Login screen does not overflow when keyboard is visible', ( - WidgetTester tester, - ) async { - final mockAuthBloc = MockAuthBloc(); - when(() => mockAuthBloc.state).thenReturn(AuthInitial()); - when( - () => mockAuthBloc.stream, - ).thenAnswer((_) => Stream.value(AuthInitial())); - - await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData( - size: Size(390, 844), - viewInsets: EdgeInsets.only(bottom: 320), - ), - child: LinksyApp(authBloc: mockAuthBloc), - ), - ); - await tester.pumpAndSettle(); - - expect(tester.takeException(), isNull); - expect(find.text('登录'), findsOneWidget); - }); -} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 2513c7c..6847361 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -2,6 +2,13 @@ This document defines Python/FastAPI backend development constraints. +## Scope and Precedence + +- This file applies to all changes under `backend/**`. +- It extends root routing rules in `AGENTS.md` and workspace global runtime rules. +- If rules conflict, follow stricter requirements. +- Keep backend-only rules here; do not duplicate them in root `AGENTS.md`. + ## Python Environment **MUST use uv for dependency management and virtual environment execution.** @@ -71,7 +78,7 @@ Do not bypass or weaken checks (no ignores, disables, or config relaxations). Re 3. Implement minimal code (GREEN) - only to pass 4. Run tests - confirm success 5. Refactor (IMPROVE) -6. Verify coverage - must be 80%+ +6. Verify coverage - target 80%+ ### Enforcement @@ -132,10 +139,13 @@ Before ANY commit: # NEVER: Hardcoded secrets api_key = "sk-proj-xxxxx" -# ALWAYS: Environment variables -api_key = os.environ.get("OPENAI_API_KEY") +# ALWAYS: Read through centralized settings +from core.config.settings import Settings + +settings = Settings() +api_key = settings.openai_api_key if not api_key: - raise ValueError("OPENAI_API_KEY not configured") + raise ValueError("OPENAI_API_KEY not configured in settings") ``` ## Database Development Rules @@ -227,6 +237,11 @@ class AgentType(str, Enum): - [ ] Downgrade path is reversible and does not silently weaken intended production security - [ ] Any exemption is documented with clear non-exposure evidence +## Backend Startup + +**Always use `./infra/scripts/app.sh` to start/stop the backend.** Do not start uvicorn directly. +**Always use `./logs/*.log` to check the backend log output.** + ## Agent Loop (AG-UI Protocol) Agent loop functionality MUST follow the AG-UI protocol. **Use the `ag-ui` skill** for protocol reference and implementation guidance. @@ -235,8 +250,6 @@ Agent loop functionality MUST follow the AG-UI protocol. **Use the `ag-ui` skill Multi-agent orchestration MUST use the AgentScope framework. **Use the `agentscope-skill`** for framework reference and implementation guidance. -For workflows involving routing, LiteLLM proxy cost audit, or frontend/backend human approval loops, **use the `agentscope-hitl-cost` skill**. - ### Core Principles - Use AgentScope for orchestrating multiple agents working together diff --git a/backend/Dockerfile b/backend/Dockerfile index c3d901b..437b82d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,7 +9,9 @@ RUN uv sync --frozen --no-dev COPY backend/src ./backend/src COPY backend/alembic ./backend/alembic +COPY backend/scripts ./backend/scripts ENV PYTHONPATH=/app/backend/src +ENV PYTHONDONTWRITEBYTECODE=1 -CMD ["uv", "run", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] +CMD ["uv", "run", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5775", "--workers", "2"] diff --git a/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py b/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py index 9ef5e26..691bf82 100644 --- a/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py +++ b/backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py @@ -20,7 +20,7 @@ def upgrade() -> None: """ CREATE TABLE invite_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(8) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$'), + code VARCHAR(4) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$'), owner_id UUID REFERENCES profiles(id) ON DELETE SET NULL, status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'expired')), used_count INTEGER NOT NULL DEFAULT 0 CHECK (used_count >= 0), @@ -63,7 +63,7 @@ def upgrade() -> None: result TEXT := ''; i INT; BEGIN - FOR i IN 1..8 LOOP + FOR i IN 1..4 LOOP result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1); END LOOP; RETURN result; @@ -126,9 +126,9 @@ def upgrade() -> None: END LOOP; invite_code_value := NEW.raw_user_meta_data ->> 'invite_code'; - IF invite_code_value IS NOT NULL AND length(invite_code_value) = 8 THEN + IF invite_code_value IS NOT NULL AND length(invite_code_value) = 4 THEN invite_code_value := upper(invite_code_value); - IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$' THEN + IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$' THEN UPDATE public.invite_codes SET used_count = used_count + 1 WHERE code = invite_code_value diff --git a/backend/src/core/agentscope/events/tool_result_summary.py b/backend/src/core/agentscope/events/tool_result_summary.py index 5b98a82..ec57da1 100644 --- a/backend/src/core/agentscope/events/tool_result_summary.py +++ b/backend/src/core/agentscope/events/tool_result_summary.py @@ -47,11 +47,6 @@ def build_tool_content_summary( if target: return _truncate(f"已分享日程给 {target}") - if tool_name == "user_resolve": - target = _pick_first_str(normalized_result, ("name", "userName", "userId")) - if target: - return _truncate(f"已匹配用户:{target}") - result_content = _pick_first_str(normalized_result, ("content", "message")) if result_content: return _truncate(result_content) diff --git a/backend/src/core/agentscope/schemas/ui_schema.py b/backend/src/core/agentscope/schemas/ui_schema.py new file mode 100644 index 0000000..f35fea6 --- /dev/null +++ b/backend/src/core/agentscope/schemas/ui_schema.py @@ -0,0 +1,770 @@ +""" +UI Schema Protocol Implementation. + +This module is the single source of truth for UI Schema. +All implementations must follow docs/protocols/ui-schema.md. + +Version: 1.0 +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Literal, NotRequired, TypedDict, Union + + +# ========== Enums ========== + + +class SchemaType(str, Enum): + """Schema type identifier.""" + + TOOL_RESULT = "tool_result" + AGENT_RESPONSE = "agent_response" + NOTIFICATION = "notification" + + +class UiStatus(str, Enum): + """Unified status for all nodes.""" + + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + PENDING = "pending" + + +class IconSource(str, Enum): + """Icon source type.""" + + ICON = "icon" + EMOJI = "emoji" + URL = "url" + + +class ActionType(str, Enum): + """Action type identifier.""" + + NAVIGATION = "navigation" + URL = "url" + EVENT = "event" + TOOL = "tool" + COPY = "copy" + PAYLOAD = "payload" + + +class OperationType(str, Enum): + """Operation node operation type.""" + + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + EXECUTE = "execute" + + +class OperationResult(str, Enum): + """Operation node result type.""" + + SUCCESS = "success" + FAILURE = "failure" + PARTIAL = "partial" + + +class ContainerDirection(str, Enum): + """Container node direction.""" + + VERTICAL = "vertical" + HORIZONTAL = "horizontal" + + +class TextFormat(str, Enum): + """Text node format.""" + + PLAIN = "plain" + MARKDOWN = "markdown" + + +class KvLayout(str, Enum): + """Key-value node layout.""" + + VERTICAL = "vertical" + HORIZONTAL = "horizontal" + GRID = "grid" + + +class BadgeVariant(str, Enum): + """Badge variant.""" + + DEFAULT = "default" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + INFO = "info" + + +class ActionStyle(str, Enum): + """Action button style.""" + + PRIMARY = "primary" + SECONDARY = "secondary" + GHOST = "ghost" + DANGER = "danger" + + +class RendererTheme(str, Enum): + """Renderer theme.""" + + DEFAULT = "default" + DARK = "dark" + LIGHT = "light" + + +# ========== Common Types ========== + + +class UiIcon(TypedDict, total=False): + """Icon structure.""" + + source: IconSource + value: str + color: str + size: int + + +class UiBadge(TypedDict, total=False): + """Badge structure.""" + + label: str + variant: BadgeVariant + + +class Pagination(TypedDict): + """Pagination info.""" + + page: int + pageSize: int + total: int + hasMore: bool + + +class ActionConfirm(TypedDict, total=False): + """Action confirmation config.""" + + title: str + message: str + confirmLabel: str + cancelLabel: str + + +class KeyValuePair(TypedDict, total=False): + """Key-value pair.""" + + key: str + label: str + value: Union[str, int, bool] + copyable: bool + + +class TableColumn(TypedDict, total=False): + """Table column definition.""" + + key: str + label: str + width: str + align: Literal["left", "center", "right"] + + +class TableRow(TypedDict, total=False): + """Table row.""" + + id: str + cells: dict[str, Any] + metadata: dict[str, Any] + actions: list[dict[str, Any]] + + +class ListItem(TypedDict, total=False): + """List item.""" + + id: str + title: str + subtitle: str + description: str + icon: UiIcon + badge: UiBadge + metadata: dict[str, Any] + actions: list[dict[str, Any]] + + +# ========== Action Types ========== + + +class NavigateAction(TypedDict): + """Navigate to internal path.""" + + type: Literal["navigation"] + path: str + params: NotRequired[dict[str, Any]] + + +class LinkAction(TypedDict): + """Open external URL.""" + + type: Literal["url"] + url: str + target: NotRequired[Literal["_self", "_blank"]] + + +class EventAction(TypedDict): + """Trigger frontend event.""" + + type: Literal["event"] + event: str + payload: NotRequired[dict[str, Any]] + + +class ToolAction(TypedDict): + """Re-execute a tool.""" + + type: Literal["tool"] + toolId: str + params: NotRequired[dict[str, Any]] + + +class CopyAction(TypedDict): + """Copy content to clipboard.""" + + type: Literal["copy"] + content: str + successMessage: NotRequired[str] + + +class PayloadAction(TypedDict): + """Submit payload to endpoint.""" + + type: Literal["payload"] + payload: dict[str, Any] + submitTo: NotRequired[str] + + +# ========== Action ========== + + +class UiAction(TypedDict, total=False): + """Action structure.""" + + id: str + label: str + icon: UiIcon + style: ActionStyle + disabled: bool + action: ( + NavigateAction + | LinkAction + | EventAction + | ToolAction + | CopyAction + | PayloadAction + ) + confirm: ActionConfirm + + +# ========== Node Types (using dict for simplicity) ========== + +# Type alias for any node +UiNode = dict[str, Any] + + +# ========== Builder Functions ========== + + +def build_document( + status: UiStatus, + nodes: list[UiNode], + *, + version: str = "1.0", + schema_type: SchemaType = SchemaType.TOOL_RESULT, + doc_id: str | None = None, + timestamp: str | None = None, + locale: str = "zh-CN", + renderer: dict[str, Any] | None = None, + meta: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a UI schema document.""" + doc: dict[str, Any] = { + "version": version, + "schemaType": schema_type.value, + "status": status.value, + "nodes": nodes, + } + if doc_id: + doc["docId"] = doc_id + if timestamp: + doc["timestamp"] = timestamp + if locale: + doc["locale"] = locale + if renderer: + doc["renderer"] = renderer + if meta: + doc["meta"] = meta + return doc + + +def build_success_document( + nodes: list[UiNode], + **kwargs: Any, +) -> dict[str, Any]: + """Build a success document.""" + return build_document(status=UiStatus.SUCCESS, nodes=nodes, **kwargs) + + +def build_error_document( + nodes: list[UiNode], + **kwargs: Any, +) -> dict[str, Any]: + """Build an error document.""" + return build_document(status=UiStatus.ERROR, nodes=nodes, **kwargs) + + +def build_text_node( + content: str, + *, + node_id: str | None = None, + format: TextFormat = TextFormat.PLAIN, + icon: dict[str, Any] | None = None, + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a text node.""" + node: dict[str, Any] = { + "type": "text", + "content": content, + "format": format.value, + } + if node_id: + node["id"] = node_id + if icon: + node["icon"] = icon + if actions: + node["actions"] = actions + return node + + +def build_card_node( + *, + node_id: str | None = None, + title: str | None = None, + description: str | None = None, + icon: dict[str, Any] | None = None, + status: UiStatus | None = None, + timestamp: str | None = None, + children: list[UiNode] | None = None, + footer: dict[str, Any] | None = None, + extensions: dict[str, Any] | None = None, + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a card node.""" + node: dict[str, Any] = {"type": "card"} + if node_id: + node["id"] = node_id + if title: + node["title"] = title + if description: + node["description"] = description + if icon: + node["icon"] = icon + if status: + node["status"] = status.value + if timestamp: + node["timestamp"] = timestamp + if children: + node["children"] = children + if footer: + node["footer"] = footer + if extensions: + node["extensions"] = extensions + if actions: + node["actions"] = actions + return node + + +def build_kv_node( + pairs: list[dict[str, Any]], + *, + node_id: str | None = None, + title: str | None = None, + description: str | None = None, + icon: dict[str, Any] | None = None, + status: UiStatus | None = None, + layout: KvLayout = KvLayout.VERTICAL, + extensions: dict[str, Any] | None = None, + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a key-value node.""" + node: dict[str, Any] = { + "type": "kv", + "pairs": pairs, + "layout": layout.value, + } + if node_id: + node["id"] = node_id + if title: + node["title"] = title + if description: + node["description"] = description + if icon: + node["icon"] = icon + if status: + node["status"] = status.value + if extensions: + node["extensions"] = extensions + if actions: + node["actions"] = actions + return node + + +def build_list_node( + items: list[dict[str, Any]], + *, + node_id: str | None = None, + title: str | None = None, + description: str | None = None, + icon: dict[str, Any] | None = None, + status: UiStatus | None = None, + pagination: dict[str, Any] | None = None, + empty_text: str | None = None, + extensions: dict[str, Any] | None = None, + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a list node.""" + node: dict[str, Any] = { + "type": "list", + "items": items, + } + if node_id: + node["id"] = node_id + if title: + node["title"] = title + if description: + node["description"] = description + if icon: + node["icon"] = icon + if status: + node["status"] = status.value + if pagination: + node["pagination"] = pagination + if empty_text: + node["emptyText"] = empty_text + if extensions: + node["extensions"] = extensions + if actions: + node["actions"] = actions + return node + + +def build_error_node( + error_code: str, + message: str, + *, + node_id: str | None = None, + title: str | None = None, + icon: dict[str, Any] | None = None, + details: str | None = None, + stack: str | None = None, + retryable: bool = False, + suggestions: list[str] | None = None, + retry: dict[str, Any] | None = None, + support: dict[str, Any] | None = None, + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build an error node.""" + node: dict[str, Any] = { + "type": "error", + "errorCode": error_code, + "message": message, + "retryable": retryable, + } + if node_id: + node["id"] = node_id + if title: + node["title"] = title + if icon: + node["icon"] = icon + if details: + node["details"] = details + if stack: + node["stack"] = stack + if suggestions: + node["suggestions"] = suggestions + if retry: + node["retry"] = retry + if support: + node["support"] = support + if actions: + node["actions"] = actions + return node + + +def build_operation_node( + operation: OperationType, + result: OperationResult, + *, + node_id: str | None = None, + title: str | None = None, + description: str | None = None, + icon: dict[str, Any] | None = None, + status: UiStatus | None = None, + message: str | None = None, + affected_count: int | None = None, + details: dict[str, Any] | None = None, + rollback: dict[str, Any] | None = None, + extensions: dict[str, Any] | None = None, + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build an operation node.""" + node: dict[str, Any] = { + "type": "operation", + "operation": operation.value, + "result": result.value, + } + if node_id: + node["id"] = node_id + if title: + node["title"] = title + if description: + node["description"] = description + if icon: + node["icon"] = icon + if status: + node["status"] = status.value + if message: + node["message"] = message + if affected_count is not None: + node["affectedCount"] = affected_count + if details: + node["details"] = details + if rollback: + node["rollback"] = rollback + if extensions: + node["extensions"] = extensions + if actions: + node["actions"] = actions + return node + + +def build_container_node( + children: list[UiNode], + direction: ContainerDirection = ContainerDirection.VERTICAL, + *, + node_id: str | None = None, + gap: int | None = None, + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a container node.""" + node: dict[str, Any] = { + "type": "container", + "direction": direction.value, + "children": children, + } + if node_id: + node["id"] = node_id + if gap is not None: + node["gap"] = gap + if actions: + node["actions"] = actions + return node + + +def build_action( + action_id: str, + label: str, + action: ( + NavigateAction + | LinkAction + | EventAction + | ToolAction + | CopyAction + | PayloadAction + ), + *, + icon: dict[str, Any] | None = None, + style: ActionStyle | None = None, + disabled: bool = False, + confirm: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build an action.""" + act: dict[str, Any] = { + "id": action_id, + "label": label, + "action": action, + } + if icon: + act["icon"] = icon + if style: + act["style"] = style.value + if disabled: + act["disabled"] = disabled + if confirm: + act["confirm"] = confirm + return act + + +def build_icon( + source: IconSource, + value: str, + *, + color: str | None = None, + size: int | None = None, +) -> dict[str, Any]: + """Build an icon.""" + icon: dict[str, Any] = {"source": source.value, "value": value} + if color: + icon["color"] = color + if size is not None: + icon["size"] = size + return icon + + +# ========== Legacy Compatibility Wrappers ========== +# These wrappers maintain API compatibility with existing code + + +def build_card( + card_type: str, + data: dict[str, Any], + *, + version: str = "1.0", + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Legacy wrapper - builds a card node.""" + return { + "type": card_type, + "version": version, + "data": data, + "actions": actions or [], + } + + +def build_calendar_card( + title: str, + start_at: str, + *, + id: str | None = None, + end_at: str | None = None, + description: str | None = None, + timezone: str | None = None, + location: str | None = None, + color: str | None = None, + source_type: str | None = None, + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Legacy wrapper for calendar card.""" + data: dict[str, Any] = { + "title": title, + "startAt": start_at, + } + if id is not None: + data["id"] = id + if end_at is not None: + data["endAt"] = end_at + if description is not None: + data["description"] = description + if timezone is not None: + data["timezone"] = timezone + if location is not None: + data["location"] = location + if color is not None: + data["color"] = color + if source_type is not None: + data["sourceType"] = source_type + + return build_card( + card_type="calendar_card.v1", + data=data, + actions=actions, + ) + + +def build_calendar_list( + items: list[dict[str, Any]], + *, + page: int = 1, + page_size: int = 20, + total: int | None = None, + total_pages: int | None = None, +) -> dict[str, Any]: + """Legacy wrapper for calendar list.""" + pagination: dict[str, Any] = { + "page": page, + "pageSize": page_size, + } + if total is not None: + pagination["total"] = total + if total_pages is not None: + pagination["totalPages"] = total_pages + + return build_card( + card_type="calendar_event_list.v1", + data={ + "items": items, + "pagination": pagination, + }, + ) + + +def build_calendar_operation( + operation: str, + ok: bool, + message: str, + *, + code: str | None = None, + actions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Legacy wrapper for calendar operation.""" + data: dict[str, Any] = { + "operation": operation, + "ok": ok, + "message": message, + } + if code is not None: + data["code"] = code + + return build_card( + card_type="calendar_operation.v1", + data=data, + actions=actions, + ) + + +def build_error_card( + message: str, + *, + code: str | None = None, +) -> dict[str, Any]: + """Legacy wrapper for error card.""" + data: dict[str, Any] = {"message": message} + if code is not None: + data["code"] = code + + return build_card( + card_type="error_card.v1", + data=data, + ) + + +def build_action_legacy( + label: str, + action_type: str = "primary", + *, + target: str | None = None, + action: str | None = None, +) -> dict[str, Any]: + """Legacy wrapper for action.""" + result: dict[str, Any] = { + "type": action_type, + "label": label, + } + if target is not None: + result["target"] = target + if action is not None: + result["action"] = action + return result diff --git a/backend/src/core/agentscope/tools/custom/__init__.py b/backend/src/core/agentscope/tools/custom/__init__.py index e22853f..6914466 100644 --- a/backend/src/core/agentscope/tools/custom/__init__.py +++ b/backend/src/core/agentscope/tools/custom/__init__.py @@ -1,7 +1,7 @@ from core.agentscope.tools.custom.calendar import ( + calendar_share, calendar_read, calendar_write, - user_resolve, ) -__all__ = ["calendar_read", "calendar_write", "user_resolve"] +__all__ = ["calendar_read", "calendar_write", "calendar_share"] diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py index 12e52f0..b1594b4 100644 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ b/backend/src/core/agentscope/tools/custom/calendar.py @@ -1,42 +1,21 @@ from typing import Annotated, Any, Literal, cast from uuid import UUID +from fastapi import HTTPException from pydantic import Field from core.auth.jwt_verifier import JwtVerifier, TokenValidationError from core.agentscope.tools.custom.calendar_backend_ops import ( _execute_list_calendar_events, _execute_mutate_calendar_event, - _execute_resolve_user_identity, + _execute_share_calendar_event, +) +from core.agentscope.tools.tool_response_builder import build_tool_response +from core.agentscope.schemas.ui_schema import ( + build_calendar_list, + build_calendar_operation, ) from core.config.settings import config -from core.agentscope.tools.response import build_tool_response - - -def _unauthorized_response() -> dict[str, object]: - return { - "type": "calendar_operation.v1", - "version": "v1", - "data": { - "ok": False, - "code": "UNAUTHORIZED", - "message": "calendar.write requires validated user token", - }, - "actions": [], - } - - -def _invalid_argument_response(*, message: str) -> dict[str, object]: - return { - "type": "calendar_operation.v1", - "version": "v1", - "data": { - "ok": False, - "code": "INVALID_ARGUMENT", - "message": message, - }, - "actions": [], - } def _verify_user_token(*, user_token: str, owner_id: UUID) -> bool: @@ -56,6 +35,67 @@ def _verify_user_token(*, user_token: str, owner_id: UUID) -> bool: return isinstance(subject, str) and subject == str(owner_id) +def _failure_response( + *, + card_type: Literal["calendar_event_list.v1", "calendar_operation.v1"], + operation: str | None, + code: str, + message: str, +) -> dict[str, object]: + if card_type == "calendar_event_list.v1": + return build_calendar_list( + items=[], + page=1, + page_size=20, + total=0, + ) | {"data": {"ok": False, "code": code, "message": message}} + + return build_calendar_operation( + operation=operation or "operation", + ok=False, + message=message, + code=code, + ) + + +def _authorized_or_response( + *, + session: Any, + owner_id: Any, + user_token: str | None, + card_type: Literal["calendar_event_list.v1", "calendar_operation.v1"], + operation: str | None, +) -> tuple[Any, UUID] | dict[str, object]: + if session is None or owner_id is None: + raise ValueError("calendar tool missing runtime preset arguments") + if not isinstance(user_token, str) or not user_token.strip(): + return _failure_response( + card_type=card_type, + operation=operation, + code="UNAUTHORIZED", + message="calendar tool requires validated user token", + ) + if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)): + return _failure_response( + card_type=card_type, + operation=operation, + code="UNAUTHORIZED", + message="calendar tool requires validated user token", + ) + return cast(Any, session), cast(UUID, owner_id) + + +def _map_exception(exc: Exception) -> tuple[str, str]: + if isinstance(exc, HTTPException): + detail = exc.detail + if isinstance(detail, str) and detail.strip(): + return "OPERATION_FAILED", detail.strip() + return "OPERATION_FAILED", "calendar operation failed" + if isinstance(exc, ValueError): + return "INVALID_ARGUMENT", str(exc) + return "INTERNAL_ERROR", "calendar operation failed" + + async def calendar_read( query: Annotated[ str | None, @@ -73,31 +113,34 @@ async def calendar_read( owner_id: Any = None, user_token: str | None = None, ) -> Any: - """Read calendar events and return a structured paginated response. - - Args: - query: Optional search keyword for event filtering. - page: Page index starting from 1. - page_size: Page size for pagination. - session: Runtime-injected database session. - owner_id: Runtime-injected user ID. - user_token: Runtime-injected user access token. - - Returns: - A tool response payload containing a calendar event list. - """ - if session is None or owner_id is None: - raise ValueError("calendar.read missing runtime preset arguments") - if not isinstance(user_token, str) or not user_token.strip(): - return build_tool_response(_unauthorized_response()) - if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)): - return build_tool_response(_unauthorized_response()) - - result = await _execute_list_calendar_events( - session=cast(Any, session), - owner_id=cast(UUID, owner_id), - tool_args={"query": query, "page": page, "pageSize": page_size}, + auth_result = _authorized_or_response( + session=session, + owner_id=owner_id, + user_token=user_token, + card_type="calendar_event_list.v1", + operation=None, ) + if isinstance(auth_result, dict): + return build_tool_response(auth_result) + runtime_session, runtime_owner_id = auth_result + + try: + result = await _execute_list_calendar_events( + session=runtime_session, + owner_id=runtime_owner_id, + tool_args={"query": query, "page": page, "pageSize": page_size}, + ) + except Exception as exc: + code, message = _map_exception(exc) + return build_tool_response( + _failure_response( + card_type="calendar_event_list.v1", + operation=None, + code=code, + message=message, + ) + ) + return build_tool_response(result) @@ -151,6 +194,68 @@ async def calendar_write( bool, Field(description="Whether to use the replace strategy for conflicts."), ] = False, + session: Any = None, + owner_id: Any = None, + user_token: str | None = None, +) -> Any: + auth_result = _authorized_or_response( + session=session, + owner_id=owner_id, + user_token=user_token, + card_type="calendar_operation.v1", + operation=operation, + ) + if isinstance(auth_result, dict): + return build_tool_response(auth_result) + runtime_session, runtime_owner_id = auth_result + + tool_args: dict[str, object] = {"operation": operation, "replace": replace} + if event_id is not None: + tool_args["eventId"] = event_id + if title is not None: + tool_args["title"] = title + if description is not None: + tool_args["description"] = description + if start_at is not None: + tool_args["startAt"] = start_at + if end_at is not None: + tool_args["endAt"] = end_at + if timezone is not None: + tool_args["timezone"] = timezone + if location is not None: + tool_args["location"] = location + if color is not None: + tool_args["color"] = color + if reminder_minutes is not None: + tool_args["reminderMinutes"] = reminder_minutes + if status is not None: + tool_args["status"] = status + + try: + result = await _execute_mutate_calendar_event( + session=runtime_session, + owner_id=runtime_owner_id, + tool_args=tool_args, + ) + except Exception as exc: + code, message = _map_exception(exc) + return build_tool_response( + _failure_response( + card_type="calendar_operation.v1", + operation=operation, + code=code, + message=message, + ) + ) + + return build_tool_response(result) + + +async def calendar_share( + event_id: Annotated[ + str, + Field(description="Target event ID (UUID string)."), + ], invite_user_emails: Annotated[ list[str] | None, Field(description="Optional invite targets by email."), @@ -179,136 +284,45 @@ async def calendar_write( owner_id: Any = None, user_token: str | None = None, ) -> Any: - """Execute calendar write operations with runtime authorization checks. - - Args: - operation: Write operation type. - event_id: Target event ID. - title: Event title. - description: Event description. - start_at: Event start time in ISO 8601 format. - end_at: Event end time in ISO 8601 format. - timezone: Event timezone. - location: Event location. - color: Event color. - reminder_minutes: Reminder minutes before event start. - status: Event lifecycle status. - replace: Replace-strategy flag for conflict handling. - session: Runtime-injected database session. - owner_id: Runtime-injected user ID. - user_token: Runtime-injected user access token. - - Returns: - A tool response payload describing the mutation result. - """ - if operation in ("update", "delete") and ( - not isinstance(event_id, str) or not event_id.strip() - ): - return build_tool_response( - _invalid_argument_response( - message="event_id is required for update and delete operations" - ) - ) - if operation == "create" and isinstance(event_id, str) and event_id.strip(): - return build_tool_response( - _invalid_argument_response( - message="event_id must not be provided for create operation" - ) - ) - if isinstance(title, str) and len(title.strip()) > 255: - return build_tool_response( - _invalid_argument_response(message="title length must be <= 255") - ) - if isinstance(description, str) and len(description.strip()) > 2000: - return build_tool_response( - _invalid_argument_response(message="description length must be <= 2000") - ) - if isinstance(timezone, str) and len(timezone.strip()) > 50: - return build_tool_response( - _invalid_argument_response(message="timezone length must be <= 50") - ) - if reminder_minutes is not None and ( - reminder_minutes < 0 or reminder_minutes > 10080 - ): - return build_tool_response( - _invalid_argument_response(message="reminder_minutes must be 0..10080") - ) - - if session is None or owner_id is None: - raise ValueError("calendar.write missing runtime preset arguments") - if not isinstance(user_token, str) or not user_token.strip(): - return build_tool_response(_unauthorized_response()) - if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)): - return build_tool_response(_unauthorized_response()) + auth_result = _authorized_or_response( + session=session, + owner_id=owner_id, + user_token=user_token, + card_type="calendar_operation.v1", + operation="share", + ) + if isinstance(auth_result, dict): + return build_tool_response(auth_result) + runtime_session, runtime_owner_id = auth_result tool_args: dict[str, object] = { - "operation": operation, - "replace": replace, + "eventId": event_id, + "invitePermissionView": invite_permission_view, + "invitePermissionEdit": invite_permission_edit, + "invitePermissionInvite": invite_permission_invite, } - if event_id is not None: - tool_args["eventId"] = event_id - if title is not None: - tool_args["title"] = title - if description is not None: - tool_args["description"] = description - if start_at is not None: - tool_args["startAt"] = start_at - if end_at is not None: - tool_args["endAt"] = end_at - if timezone is not None: - tool_args["timezone"] = timezone - if location is not None: - tool_args["location"] = location - if color is not None: - tool_args["color"] = color - if reminder_minutes is not None: - tool_args["reminderMinutes"] = reminder_minutes - if status is not None: - tool_args["status"] = status if invite_user_emails is not None: tool_args["inviteUserEmails"] = invite_user_emails if invite_user_names is not None: tool_args["inviteUserNames"] = invite_user_names if invite_user_ids is not None: tool_args["inviteUserIds"] = invite_user_ids - tool_args["invitePermissionView"] = invite_permission_view - tool_args["invitePermissionEdit"] = invite_permission_edit - tool_args["invitePermissionInvite"] = invite_permission_invite - result = await _execute_mutate_calendar_event( - session=cast(Any, session), - owner_id=cast(UUID, owner_id), - tool_args=tool_args, - ) - return build_tool_response(result) - - -async def user_resolve( - user_email: Annotated[ - str | None, - Field(description="User email to resolve user ID."), - ] = None, - user_name: Annotated[ - str | None, - Field(description="Username to resolve user ID."), - ] = None, - session: Any = None, - owner_id: Any = None, - user_token: str | None = None, -) -> Any: - if session is None or owner_id is None: - raise ValueError("user.resolve missing runtime preset arguments") - if not isinstance(user_token, str) or not user_token.strip(): - return build_tool_response(_unauthorized_response()) - if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)): - return build_tool_response(_unauthorized_response()) - - result = await _execute_resolve_user_identity( - session=cast(Any, session), - owner_id=cast(UUID, owner_id), - tool_args={ - "userEmail": user_email, - "userName": user_name, - }, - ) + try: + result = await _execute_share_calendar_event( + session=runtime_session, + owner_id=runtime_owner_id, + tool_args=tool_args, + ) + except Exception as exc: + code, message = _map_exception(exc) + return build_tool_response( + _failure_response( + card_type="calendar_operation.v1", + operation="share", + code=code, + message=message, + ) + ) + return build_tool_response(result) diff --git a/backend/src/core/agentscope/tools/custom/calendar_backend_ops.py b/backend/src/core/agentscope/tools/custom/calendar_backend_ops.py index 6f505d5..cc33303 100644 --- a/backend/src/core/agentscope/tools/custom/calendar_backend_ops.py +++ b/backend/src/core/agentscope/tools/custom/calendar_backend_ops.py @@ -379,12 +379,6 @@ async def _execute_create( ) event_data = _event_payload(created) event_id = str(event_data["id"]) - invite_result = await _share_event_with_invitees( - session=service._session, - owner_id=service.require_user_id(), - event_id=UUID(event_id), - tool_args=tool_args, - ) return { "type": "calendar_card.v1", "version": "v1", @@ -393,7 +387,6 @@ async def _execute_create( "sourceType": "agent_generated", "ok": True, "message": "日程已创建", - "inviteResult": invite_result, }, "actions": [ { @@ -475,12 +468,6 @@ async def _execute_update( ScheduleItemUpdateRequest.model_validate(update_data), ) event_data = _event_payload(updated) - invite_result = await _share_event_with_invitees( - session=service._session, - owner_id=service.require_user_id(), - event_id=UUID(str(event_data["id"])), - tool_args=tool_args, - ) return { "type": "calendar_card.v1", "version": "v1", @@ -489,7 +476,6 @@ async def _execute_update( "sourceType": "agent_generated", "ok": True, "message": "日程已更新", - "inviteResult": invite_result, }, "actions": [ { @@ -538,3 +524,34 @@ async def _execute_mutate_calendar_event( if operation == "delete": return await _execute_delete(service=service, tool_args=tool_args) raise ValueError("operation must be one of: create, update, delete") + + +async def _execute_share_calendar_event( + *, + session: AsyncSession, + owner_id: UUID, + tool_args: dict[str, object], +) -> dict[str, object]: + event_id = _parse_event_id(tool_args.get("eventId")) + invite_result = await _share_event_with_invitees( + session=session, + owner_id=owner_id, + event_id=event_id, + tool_args=tool_args, + ) + if invite_result is None: + raise ValueError( + "at least one invite target is required: inviteUserEmails, inviteUserNames, or inviteUserIds" + ) + return { + "type": "calendar_operation.v1", + "version": "v1", + "data": { + "operation": "share", + "id": str(event_id), + "ok": True, + "message": "日程已分享", + "shareResult": invite_result, + }, + "actions": [], + } diff --git a/backend/src/core/agentscope/tools/hitl_middleware.py b/backend/src/core/agentscope/tools/hitl_middleware.py index b91bfbe..8435c78 100644 --- a/backend/src/core/agentscope/tools/hitl_middleware.py +++ b/backend/src/core/agentscope/tools/hitl_middleware.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, AsyncGenerator, Callable -from core.agentscope.tools.response import build_tool_response +from core.agentscope.tools.tool_response_builder import build_tool_response from core.agentscope.tools.tool_meta import ToolMeta diff --git a/backend/src/core/agentscope/tools/tool_meta.py b/backend/src/core/agentscope/tools/tool_meta.py index 455d955..15c7faf 100644 --- a/backend/src/core/agentscope/tools/tool_meta.py +++ b/backend/src/core/agentscope/tools/tool_meta.py @@ -6,7 +6,7 @@ from dataclasses import dataclass TOOL_APPROVAL_REQUIRED: dict[str, bool] = { "calendar_read": False, "calendar_write": False, - "user_resolve": False, + "calendar_share": False, } diff --git a/backend/src/core/agentscope/tools/response.py b/backend/src/core/agentscope/tools/tool_response_builder.py similarity index 100% rename from backend/src/core/agentscope/tools/response.py rename to backend/src/core/agentscope/tools/tool_response_builder.py diff --git a/backend/src/core/agentscope/tools/toolkit.py b/backend/src/core/agentscope/tools/toolkit.py index 307cdbf..6a2e281 100644 --- a/backend/src/core/agentscope/tools/toolkit.py +++ b/backend/src/core/agentscope/tools/toolkit.py @@ -7,9 +7,9 @@ from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession from core.agentscope.tools.custom.calendar import ( + calendar_share, calendar_read, calendar_write, - user_resolve, ) from core.agentscope.tools.hitl_middleware import register_tool_middlewares from core.agentscope.tools.tool_meta import TOOL_META @@ -29,12 +29,10 @@ class ToolGroup: TOOL_GROUPS: dict[str, ToolGroup] = { - "intent": ToolGroup( - stage="intent", tool_names=frozenset({"calendar_read", "user_resolve"}) - ), + "intent": ToolGroup(stage="intent", tool_names=frozenset({"calendar_read"})), "execution": ToolGroup( stage="execution", - tool_names=frozenset({"calendar_read", "calendar_write", "user_resolve"}), + tool_names=frozenset({"calendar_read", "calendar_write", "calendar_share"}), ), "report": ToolGroup(stage="report", tool_names=frozenset()), } @@ -73,8 +71,8 @@ def _load_custom_tool_bindings( }, ), CustomToolBinding( - name="user_resolve", - func=user_resolve, + name="calendar_share", + func=calendar_share, preset_kwargs={ "session": session, "owner_id": owner_id, diff --git a/backend/src/v1/agent/router.py b/backend/src/v1/agent/router.py index 3dc5231..f051a8c 100644 --- a/backend/src/v1/agent/router.py +++ b/backend/src/v1/agent/router.py @@ -68,12 +68,12 @@ def _verified_access_token_for_user( *, authorization: str | None, current_user: CurrentUser, -) -> str | None: +) -> str: if not isinstance(authorization, str): - return None + raise HTTPException(status_code=401, detail="Unauthorized") normalized = authorization.strip() if not normalized: - return None + raise HTTPException(status_code=401, detail="Unauthorized") if not normalized.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="Unauthorized") token = normalized[7:].strip() diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index 20371f9..e0f0524 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -15,12 +15,7 @@ class VerificationCreateRequest(BaseModel): email: EmailStr password: str = Field(min_length=SUPABASE_PASSWORD_MIN_LENGTH) redirect_to: str | None = None - invite_code: str | None = Field( - default=None, - min_length=8, - max_length=8, - pattern=r"^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$", - ) + invite_code: str | None = None class VerificationResendRequest(BaseModel): diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py index 5d356ca..8082784 100644 --- a/backend/src/v1/auth/service.py +++ b/backend/src/v1/auth/service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from typing import Protocol from v1.auth.schemas import ( @@ -56,7 +57,11 @@ class AuthService: async def create_verification( self, request: VerificationCreateRequest ) -> VerificationCreateResponse: - return await self._gateway.create_verification(request) + normalized_invite_code = _normalize_invite_code(request.invite_code) + normalized_request = request.model_copy( + update={"invite_code": normalized_invite_code} + ) + return await self._gateway.create_verification(normalized_request) async def verify_verification( self, request: VerificationVerifyRequest @@ -82,3 +87,17 @@ class AuthService: self, request: PasswordResetConfirmRequest ) -> None: await self._gateway.confirm_password_reset(request) + + +_INVITE_CODE_PATTERN = re.compile(r"^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$") + + +def _normalize_invite_code(value: str | None) -> str | None: + if value is None: + return None + + normalized = value.strip().upper() + if not normalized: + return None + + return normalized if _INVITE_CODE_PATTERN.fullmatch(normalized) else None diff --git a/backend/tests/integration/test_auth_routes.py b/backend/tests/integration/test_auth_routes.py index 6511b4b..f2ee314 100644 --- a/backend/tests/integration/test_auth_routes.py +++ b/backend/tests/integration/test_auth_routes.py @@ -783,7 +783,7 @@ class TestInviteCodeSignup: "username": "demo", "email": "user@example.com", "password": "secret123", - "invite_code": "A2B3C4D5", + "invite_code": "A2B3", }, ) assert response.status_code == 202 @@ -791,7 +791,7 @@ class TestInviteCodeSignup: finally: app.dependency_overrides = {} - def test_signup_with_invalid_invite_code_length_returns_422(self) -> None: + def test_signup_with_invalid_invite_code_length_returns_202(self) -> None: user = AuthUser(id="user-1", email="user@example.com") token_response = SessionResponse( access_token="access", @@ -815,14 +815,12 @@ class TestInviteCodeSignup: "invite_code": "ABC123", }, ) - assert response.status_code == 422 - assert response.headers["content-type"].startswith( - "application/problem+json" - ) + assert response.status_code == 202 + assert response.json() == {"email": "user@example.com"} finally: app.dependency_overrides = {} - def test_signup_with_invalid_invite_code_chars_returns_422(self) -> None: + def test_signup_with_invalid_invite_code_chars_returns_202(self) -> None: user = AuthUser(id="user-1", email="user@example.com") token_response = SessionResponse( access_token="access", @@ -846,9 +844,7 @@ class TestInviteCodeSignup: "invite_code": "ABCD1234", }, ) - assert response.status_code == 422 - assert response.headers["content-type"].startswith( - "application/problem+json" - ) + assert response.status_code == 202 + assert response.json() == {"email": "user@example.com"} finally: app.dependency_overrides = {} diff --git a/backend/tests/integration/v1/agent/test_routes.py b/backend/tests/integration/v1/agent/test_routes.py index 75fa66e..1392b52 100644 --- a/backend/tests/integration/v1/agent/test_routes.py +++ b/backend/tests/integration/v1/agent/test_routes.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from uuid import uuid4 from ag_ui.core import RunAgentInput +from fastapi import HTTPException from fastapi.testclient import TestClient from app import app @@ -150,6 +151,7 @@ def test_run_requires_auth_and_returns_202_task_id() -> None: app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService() client = TestClient(app) original_allow_run = agent_router._allow_run_request + original_verify_token = agent_router._verified_access_token_for_user async def _allow_run(*, user_id: str) -> bool: del user_id @@ -157,6 +159,13 @@ def test_run_requires_auth_and_returns_202_task_id() -> None: agent_router._allow_run_request = _allow_run # type: ignore[assignment] + def _verify_token(**kwargs: object) -> str: + if kwargs.get("authorization"): + return "token-ok" + raise HTTPException(status_code=401, detail="Unauthorized") + + agent_router._verified_access_token_for_user = _verify_token # type: ignore[assignment] + try: unauthorized = client.post( "/api/v1/agent/runs", @@ -177,6 +186,7 @@ def test_run_requires_auth_and_returns_202_task_id() -> None: ) authorized = client.post( "/api/v1/agent/runs", + headers={"Authorization": "Bearer token-ok"}, json={ "threadId": "00000000-0000-0000-0000-000000000001", "runId": "run-1", @@ -192,8 +202,23 @@ def test_run_requires_auth_and_returns_202_task_id() -> None: assert authorized.json()["threadId"] == "00000000-0000-0000-0000-000000000001" assert authorized.json()["runId"] == "run-1" assert authorized.json()["created"] is False + + missing_header = client.post( + "/api/v1/agent/runs", + json={ + "threadId": "00000000-0000-0000-0000-000000000001", + "runId": "run-2", + "state": {}, + "messages": [{"id": "u2", "role": "user", "content": "hello"}], + "tools": [], + "context": [], + "forwardedProps": {}, + }, + ) + assert missing_header.status_code == 401 finally: agent_router._allow_run_request = original_allow_run # type: ignore[assignment] + agent_router._verified_access_token_for_user = original_verify_token # type: ignore[assignment] app.dependency_overrides = {} @@ -390,10 +415,19 @@ def test_resume_accepts_tool_message_without_user_message() -> None: id=uuid4(), email="user@example.com" ) client = TestClient(app) + original_verify_token = agent_router._verified_access_token_for_user + + def _verify_token(**kwargs: object) -> str: + if kwargs.get("authorization"): + return "token-ok" + raise HTTPException(status_code=401, detail="Unauthorized") + + agent_router._verified_access_token_for_user = _verify_token # type: ignore[assignment] try: response = client.post( "/api/v1/agent/runs/00000000-0000-0000-0000-000000000001/resume", + headers={"Authorization": "Bearer token-ok"}, json={ "threadId": "00000000-0000-0000-0000-000000000001", "runId": "run-resume-1", @@ -413,7 +447,29 @@ def test_resume_accepts_tool_message_without_user_message() -> None: ) assert response.status_code == 202 assert response.json()["taskId"] == "task-resume-1" + + missing_header = client.post( + "/api/v1/agent/runs/00000000-0000-0000-0000-000000000001/resume", + json={ + "threadId": "00000000-0000-0000-0000-000000000001", + "runId": "run-resume-2", + "state": {}, + "messages": [ + { + "id": "tool-2", + "role": "tool", + "toolCallId": "call-2", + "content": '{"toolName":"navigate_to_route","toolArgs":{"target":"/calendar/dayweek"},"nonce":"n2","result":{"ok":true}}', + } + ], + "tools": [], + "context": [], + "forwardedProps": {}, + }, + ) + assert missing_header.status_code == 401 finally: + agent_router._verified_access_token_for_user = original_verify_token # type: ignore[assignment] app.dependency_overrides = {} diff --git a/backend/tests/unit/core/agentscope/test_calendar_tools.py b/backend/tests/unit/core/agentscope/test_calendar_tools.py index 54ff00f..5647b1d 100644 --- a/backend/tests/unit/core/agentscope/test_calendar_tools.py +++ b/backend/tests/unit/core/agentscope/test_calendar_tools.py @@ -16,13 +16,9 @@ async def test_calendar_read_returns_list_payload( ) -> None: async def _fake_execute(**kwargs: Any) -> dict[str, object]: del kwargs - return {"type": "calendar_event_list.v1", "version": "v1", "data": {}} + return {"type": "calendar_event_list.v1", "version": "v1", "data": {"ok": True}} - monkeypatch.setattr( - calendar_module, - "_execute_list_calendar_events", - _fake_execute, - ) + monkeypatch.setattr(calendar_module, "_execute_list_calendar_events", _fake_execute) monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True) monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) @@ -62,9 +58,7 @@ async def test_calendar_write_maps_event_id_for_update( return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}} monkeypatch.setattr( - calendar_module, - "_execute_mutate_calendar_event", - _fake_execute, + calendar_module, "_execute_mutate_calendar_event", _fake_execute ) monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True) monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) @@ -82,57 +76,6 @@ async def test_calendar_write_maps_event_id_for_update( assert "eventId" in captured -@pytest.mark.asyncio -async def test_calendar_write_requires_preset_user_token( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) - monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: False) - result = await calendar_module.calendar_write( - session=cast(AsyncSession, SimpleNamespace()), - owner_id=uuid4(), - user_token="bad-token", - operation="create", - ) - assert result["data"]["ok"] is False - assert result["data"]["code"] == "UNAUTHORIZED" - - -@pytest.mark.asyncio -async def test_calendar_write_rejects_missing_event_id_for_update( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) - - result = await calendar_module.calendar_write( - session=cast(AsyncSession, SimpleNamespace()), - owner_id=uuid4(), - user_token="token-abc", - operation="update", - ) - - assert result["data"]["ok"] is False - assert result["data"]["code"] == "INVALID_ARGUMENT" - - -@pytest.mark.asyncio -async def test_calendar_write_rejects_event_id_for_create( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) - - result = await calendar_module.calendar_write( - session=cast(AsyncSession, SimpleNamespace()), - owner_id=uuid4(), - user_token="token-abc", - operation="create", - event_id=str(uuid4()), - ) - - assert result["data"]["ok"] is False - assert result["data"]["code"] == "INVALID_ARGUMENT" - - @pytest.mark.asyncio async def test_calendar_write_maps_reminder_minutes( monkeypatch: pytest.MonkeyPatch, @@ -144,9 +87,7 @@ async def test_calendar_write_maps_reminder_minutes( return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}} monkeypatch.setattr( - calendar_module, - "_execute_mutate_calendar_event", - _fake_execute, + calendar_module, "_execute_mutate_calendar_event", _fake_execute ) monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True) monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) @@ -163,46 +104,54 @@ async def test_calendar_write_maps_reminder_minutes( @pytest.mark.asyncio -async def test_calendar_write_rejects_invalid_reminder_minutes( +async def test_calendar_write_returns_failed_tool_response_on_error( monkeypatch: pytest.MonkeyPatch, ) -> None: + async def _fake_execute(**kwargs: Any) -> dict[str, object]: + del kwargs + raise ValueError("eventId is required") + + monkeypatch.setattr( + calendar_module, "_execute_mutate_calendar_event", _fake_execute + ) + monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True) monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) result = await calendar_module.calendar_write( session=cast(AsyncSession, SimpleNamespace()), owner_id=uuid4(), user_token="token-abc", - operation="create", - reminder_minutes=10081, + operation="update", ) + assert result["type"] == "calendar_operation.v1" assert result["data"]["ok"] is False assert result["data"]["code"] == "INVALID_ARGUMENT" @pytest.mark.asyncio -async def test_calendar_write_maps_invite_arguments( +async def test_calendar_share_maps_arguments( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, object] = {} async def _fake_execute(**kwargs: Any) -> dict[str, object]: captured.update(cast(dict[str, object], kwargs["tool_args"])) - return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}} + return { + "type": "calendar_operation.v1", + "version": "v1", + "data": {"operation": "share", "ok": True}, + } - monkeypatch.setattr( - calendar_module, - "_execute_mutate_calendar_event", - _fake_execute, - ) + monkeypatch.setattr(calendar_module, "_execute_share_calendar_event", _fake_execute) monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True) monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) - await calendar_module.calendar_write( + result = await calendar_module.calendar_share( session=cast(AsyncSession, SimpleNamespace()), owner_id=uuid4(), user_token="token-abc", - operation="create", + event_id=str(uuid4()), invite_user_emails=["a@example.com"], invite_user_names=["alice"], invite_user_ids=[str(uuid4())], @@ -211,6 +160,8 @@ async def test_calendar_write_maps_invite_arguments( invite_permission_invite=True, ) + assert result["type"] == "calendar_operation.v1" + assert captured["eventId"] assert captured["inviteUserEmails"] == ["a@example.com"] assert captured["inviteUserNames"] == ["alice"] assert isinstance(captured["inviteUserIds"], list) @@ -220,46 +171,18 @@ async def test_calendar_write_maps_invite_arguments( @pytest.mark.asyncio -async def test_user_resolve_maps_identity_arguments( - monkeypatch: pytest.MonkeyPatch, -) -> None: - captured: dict[str, object] = {} - - async def _fake_execute(**kwargs: Any) -> dict[str, object]: - captured.update(cast(dict[str, object], kwargs["tool_args"])) - return {"type": "user_lookup.v1", "version": "v1", "data": {"ok": True}} - - monkeypatch.setattr( - calendar_module, - "_execute_resolve_user_identity", - _fake_execute, - ) - monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True) - monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) - - result = await calendar_module.user_resolve( - session=cast(AsyncSession, SimpleNamespace()), - owner_id=uuid4(), - user_token="token-abc", - user_email="a@example.com", - ) - - assert result["type"] == "user_lookup.v1" - assert captured == {"userEmail": "a@example.com", "userName": None} - - -@pytest.mark.asyncio -async def test_user_resolve_requires_valid_user_token( +async def test_calendar_share_requires_valid_user_token( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: False) monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) - result = await calendar_module.user_resolve( + result = await calendar_module.calendar_share( session=cast(AsyncSession, SimpleNamespace()), owner_id=uuid4(), user_token="bad-token", - user_name="alice", + event_id=str(uuid4()), + invite_user_emails=["a@example.com"], ) assert result["data"]["ok"] is False diff --git a/backend/tests/unit/core/agentscope/test_toolkit_registry.py b/backend/tests/unit/core/agentscope/test_toolkit_registry.py index 5c9e2a7..b6e5fae 100644 --- a/backend/tests/unit/core/agentscope/test_toolkit_registry.py +++ b/backend/tests/unit/core/agentscope/test_toolkit_registry.py @@ -22,7 +22,7 @@ async def test_build_toolkit_registers_calendar_tools() -> None: names = {item["function"]["name"] for item in schemas} assert "calendar_read" in names assert "calendar_write" in names - assert "user_resolve" in names + assert "calendar_share" in names write_schema = next( item for item in schemas if item["function"]["name"] == "calendar_write" diff --git a/backend/tests/unit/v1/auth/test_auth_models.py b/backend/tests/unit/v1/auth/test_auth_models.py index 5e0ad38..a290329 100644 --- a/backend/tests/unit/v1/auth/test_auth_models.py +++ b/backend/tests/unit/v1/auth/test_auth_models.py @@ -28,6 +28,17 @@ def test_signup_requires_username() -> None: ) +def test_signup_allows_any_invite_code_input() -> None: + request = VerificationCreateRequest( + username="demo", + email="user@example.com", + password="secret123", + invite_code="abc123", + ) + + assert request.invite_code == "abc123" + + def test_signup_verify_requires_six_digit_token() -> None: with pytest.raises(ValidationError): VerificationVerifyRequest(email="user@example.com", token="abc123") diff --git a/backend/tests/unit/v1/auth/test_auth_service.py b/backend/tests/unit/v1/auth/test_auth_service.py index 2809a49..85976da 100644 --- a/backend/tests/unit/v1/auth/test_auth_service.py +++ b/backend/tests/unit/v1/auth/test_auth_service.py @@ -22,10 +22,12 @@ from v1.auth.service import AuthService, AuthServiceGateway class FakeGateway(AuthServiceGateway): def __init__(self, response: SessionResponse) -> None: self._response = response + self.last_create_verification_request: VerificationCreateRequest | None = None async def create_verification( self, request: VerificationCreateRequest ) -> VerificationCreateResponse: + self.last_create_verification_request = request return VerificationCreateResponse(email=request.email) async def verify_verification( @@ -121,6 +123,58 @@ async def test_signup_resend_returns_none() -> None: assert result is None +@pytest.mark.asyncio +async def test_create_verification_ignores_invalid_invite_code() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + gateway = FakeGateway(token_response) + service = AuthService(gateway=gateway) + + await service.create_verification( + VerificationCreateRequest( + username="demo", + email="user@example.com", + password="secret123", + invite_code="bad-code", + ) + ) + + assert gateway.last_create_verification_request is not None + assert gateway.last_create_verification_request.invite_code is None + + +@pytest.mark.asyncio +async def test_create_verification_normalizes_valid_invite_code() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + gateway = FakeGateway(token_response) + service = AuthService(gateway=gateway) + + await service.create_verification( + VerificationCreateRequest( + username="demo", + email="user@example.com", + password="secret123", + invite_code="a2b3", + ) + ) + + assert gateway.last_create_verification_request is not None + assert gateway.last_create_verification_request.invite_code == "A2B3" + + @pytest.mark.asyncio async def test_supabase_signup_passes_username_in_metadata( monkeypatch: pytest.MonkeyPatch, diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example new file mode 100644 index 0000000..c6a746a --- /dev/null +++ b/deploy/.env.prod.example @@ -0,0 +1,83 @@ +# 环境变量配置模板(复制到 .env 并填写实际值) +# 警告:切勿将包含真实密钥的 .env 提交到代码仓库 + +############ +# 运行时配置 +############ +SOCIAL_RUNTIME__ENVIRONMENT=dev +SOCIAL_RUNTIME__DEBUG=true +SOCIAL_RUNTIME__LOG_LEVEL=INFO +SOCIAL_RUNTIME__SQL_LOG_QUERIES=false + +############ +# Web 服务器配置(Uvicorn) +############ +SOCIAL_WEB__HOST=0.0.0.0 +SOCIAL_WEB__PORT=5775 +SOCIAL_WEB__WORKERS=2 + +############ +# LiteLLM Proxy 网关配置 +############ +SOCIAL_LITELLM__PORT=3875 + +############ +# Redis 配置 +############ +SOCIAL_REDIS__PASSWORD=redis-secure-2026 +SOCIAL_REDIS__HOST=localhost +SOCIAL_REDIS__PORT=6379 +SOCIAL_REDIS__DB=0 + +############ +# Worker 队列分组配置(显式参数控制) +############ +# critical: 用户同步感知任务(验证码发送、鉴权后置关键动作) +# default: 常规异步任务 +# bulk: 批处理/重计算/可延迟任务 +SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY=2 + +SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY=2 + +SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY=1 + +############ +# Taskiq(可选,默认回落到 Redis URL) +############ +# SOCIAL_TASKIQ__BROKER_URL=redis://:password@localhost:6379/0 +# SOCIAL_TASKIQ__RESULT_BACKEND_URL=redis://:password@localhost:6379/0 + +############ +# Supabase(云模式,后端必需) +############ +SOCIAL_SUPABASE__PUBLIC_URL=https://your-project.supabase.co +SOCIAL_SUPABASE__ANON_KEY= +SOCIAL_SUPABASE__SERVICE_ROLE_KEY= +# 使用阿里云 DescribeInstanceAuthInfo 返回的 JwtSecret +SOCIAL_SUPABASE__JWT_SECRET= +SOCIAL_SUPABASE__JWT_ALGORITHM=HS256 + +# Postgres 连接信息(后端与 Supabase 共用密码) +SOCIAL_DATABASE__HOST=localhost +SOCIAL_DATABASE__PORT=5434 +SOCIAL_DATABASE__NAME=postgres +SOCIAL_DATABASE__USER=postgres +SOCIAL_DATABASE__PASSWORD=change-me-strong-password + +############ +# Agent Chat 附件存储配置(仅基础设施变量) +############ +SOCIAL_STORAGE__PROVIDER=supabase +SOCIAL_STORAGE__BUCKET=agent-chat-attachments +SOCIAL_STORAGE__SIGNED_URL_TTL_SECONDS=600 +SOCIAL_STORAGE__MAX_FILE_SIZE_MB=20 +SOCIAL_STORAGE__RETENTION_DAYS=30 + +###### +# LLM API KEY +SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE= +SOCIAL_LLM__PROVIDER_KEYS__MINIMAX= +SOCIAL_LLM__PROVIDER_KEYS__MOONSHOT= +SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK= +SOCIAL_LLM__PROVIDER_KEYS__ARK= +SOCIAL_LLM__PROVIDER_KEYS__ZAI= diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..340dc16 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,124 @@ +# Production Deploy Package + +本目录是单机 `docker compose` 的生产交付包,架构为: + +- 应用层:`litellm + web + worker-critical + worker-default + worker-bulk + init-job` +- 中间件:`redis` +- 数据与认证:云 Supabase(通过环境变量访问) +- 反向代理:由服务器侧 nginx 托管(不在本目录编排) + +## 交付物 + +1. `deploy/build-prod-image.sh`:构建并导出生产镜像。 +2. `deploy/docker-compose.prod.yml`:生产 Docker Compose 编排。 +3. `deploy/.env.prod.example`:生产环境变量模板。 +4. `deploy/README.md`:部署说明书。 + +## 安全基线 + +- `deploy/.env.prod.example` 仅作为模板,真实密钥请在服务器上填写到 `deploy/.env.prod`,不要提交仓库。 +- Redis 密码必填;为空时容器会启动失败。 +- 后端镜像默认使用非 root 用户运行。 +- 容器间通信仅走 Docker 内网(`redis`、`litellm` 服务名)。 + +## 目录结构 + +```text +deploy/ +├── build-prod-image.sh +├── docker-compose.prod.yml +├── .env.prod.example +└── README.md +``` + +## 一次性交付:构建生产镜像 + +在仓库根目录执行: + +```bash +bash deploy/build-prod-image.sh +``` + +成功后会产出: + +- 镜像名:`social-app-backend:prod`(可用 `SOCIAL_BACKEND_IMAGE` 覆盖) +- 归档文件:`deploy/social-app-backend-prod.tar.gz` + +如果你要把镜像拷贝到另一台服务器,传输该 `.tar.gz` 后执行: + +```bash +gunzip -c social-app-backend-prod.tar.gz | docker load +``` + +## 启动流程(生产) + +### 1) 检查环境变量 + +先创建生产环境变量文件: + +```bash +cp deploy/.env.prod.example deploy/.env.prod +``` + +确认 `deploy/.env.prod` 至少包含以下关键变量(云 Supabase 与数据库连接): + +- `SOCIAL_SUPABASE__PUBLIC_URL` +- `SOCIAL_SUPABASE__ANON_KEY` +- `SOCIAL_SUPABASE__SERVICE_ROLE_KEY` +- `SOCIAL_SUPABASE__JWT_SECRET` +- `SOCIAL_DATABASE__HOST` +- `SOCIAL_DATABASE__PORT` +- `SOCIAL_DATABASE__NAME` +- `SOCIAL_DATABASE__USER` +- `SOCIAL_DATABASE__PASSWORD` +- `SOCIAL_REDIS__PASSWORD` + +说明: + +- 容器内通信统一使用 Docker 内网:`SOCIAL_REDIS__HOST=redis`、`SOCIAL_LITELLM__HOST=litellm`。 +- `SOCIAL_WEB__HOST`/`SOCIAL_LITELLM__BIND_HOST` 是容器内监听地址,生产建议保持 `0.0.0.0`。 + +### 2) 启动常驻服务 + +```bash +docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d redis litellm web worker-critical worker-default worker-bulk +``` + +### 3) 执行一次性 bootstrap + +```bash +docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml run --rm init-job +``` + +### 4) 查看状态与日志 + +```bash +docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml ps +docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml logs -f web +``` + +## 停止与重启 + +停止: + +```bash +docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml down +``` + +重启: + +```bash +docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d +``` + +## nginx 对接建议 + +- 反向代理到 `127.0.0.1:${SOCIAL_WEB__PORT}`(默认 `5775`)。 +- 仅开放 nginx 对外端口;应用容器仅发布到本机回环地址。 +- 如果 nginx 运行在宿主机,`web` 需要保留 `127.0.0.1:host_port:container_port` 端口映射。 +- 如果 nginx 也运行在 Docker 同网络内,可以移除 `web.ports`,改为容器内反向代理(例如 `proxy_pass http://web:5775`)。 + +## 已知约束 + +- LiteLLM 会在容器启动时动态生成 `/tmp/litellm-proxy-config.yaml`,依赖 `SOCIAL_LLM__PROVIDER_KEYS__*` 已配置。 +- `init-job` 为一次性任务,不长期驻留。 diff --git a/deploy/build-prod-image.sh b/deploy/build-prod-image.sh new file mode 100755 index 0000000..e7f5e75 --- /dev/null +++ b/deploy/build-prod-image.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +IMAGE_NAME="${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod}" +OUTPUT_FILE="$ROOT_DIR/deploy/social-app-backend-prod.tar.gz" + +echo "[1/2] Building image: ${IMAGE_NAME}" +docker build -t "${IMAGE_NAME}" -f "$ROOT_DIR/backend/Dockerfile" "$ROOT_DIR" + +docker image inspect "${IMAGE_NAME}" >/dev/null + +echo "[2/2] Exporting image archive: ${OUTPUT_FILE}" +docker save "${IMAGE_NAME}" | gzip > "${OUTPUT_FILE}" + +echo "Done" +echo "Image: ${IMAGE_NAME}" +echo "Archive: ${OUTPUT_FILE}" diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..9ad5a6c --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,185 @@ +name: social-app-prod + +services: + redis: + image: redis:7-alpine + container_name: social-prod-redis + restart: unless-stopped + environment: + - REDIS_PASSWORD=${SOCIAL_REDIS__PASSWORD} + command: > + sh -c 'test -n "$$REDIS_PASSWORD" && exec redis-server --appendonly yes --dir /data --requirepass "$$REDIS_PASSWORD" || (echo "REDIS_PASSWORD is required" >&2; exit 1)' + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli -a \"$$REDIS_PASSWORD\" ping; else redis-cli ping; fi"] + interval: 5s + timeout: 3s + retries: 10 + + litellm: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-litellm + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + command: > + sh -c '.venv/bin/python backend/scripts/build_litellm_proxy_config.py --output /tmp/litellm-proxy-config.yaml && .venv/bin/litellm --config /tmp/litellm-proxy-config.yaml --host ${SOCIAL_LITELLM__BIND_HOST:-0.0.0.0} --port ${SOCIAL_LITELLM__PORT:-3875}' + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import os,sys,urllib.request;port=os.getenv('SOCIAL_LITELLM__PORT','3875');u=f'http://127.0.0.1:{port}/health';sys.exit(0 if urllib.request.urlopen(u, timeout=3).getcode() < 500 else 1)", + ] + interval: 15s + timeout: 5s + retries: 10 + + web: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-web + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=web + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: > + sh -c '.venv/bin/uvicorn app:app --host ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${SOCIAL_WEB__PORT:-5775} --workers ${SOCIAL_WEB__WORKERS:-2} --log-level $(printf "%s" "${SOCIAL_RUNTIME__LOG_LEVEL:-info}" | tr "[:upper:]" "[:lower:]")' + ports: + - "127.0.0.1:${SOCIAL_WEB__PORT:-5775}:${SOCIAL_WEB__PORT:-5775}" + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import os,sys,urllib.request;port=os.getenv('SOCIAL_WEB__PORT','5775');u=f'http://127.0.0.1:{port}/health';sys.exit(0 if urllib.request.urlopen(u, timeout=3).getcode() < 500 else 1)", + ] + interval: 15s + timeout: 5s + retries: 10 + + worker-critical: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-worker-critical + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=worker-critical + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: > + sh -c '.venv/bin/taskiq worker core.taskiq.app:critical_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}' + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + + worker-default: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-worker-default + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=worker-default + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: > + sh -c '.venv/bin/taskiq worker core.taskiq.app:default_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}' + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + + worker-bulk: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-worker-bulk + restart: unless-stopped + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=worker-bulk + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: > + sh -c '.venv/bin/taskiq worker core.taskiq.app:bulk_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}' + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + + init-job: + image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} + container_name: social-prod-init-job + restart: "no" + env_file: + - ./.env.prod + environment: + - PYTHONPATH=/app/backend/src + - PYTHONDONTWRITEBYTECODE=1 + - SOCIAL_RUNTIME__SERVICE_NAME=init-job + - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} + - SOCIAL_REDIS__HOST=redis + - SOCIAL_REDIS__PORT=6379 + - SOCIAL_LITELLM__HOST=litellm + - SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875} + command: .venv/bin/python -m core.runtime.cli bootstrap + depends_on: + redis: + condition: service_healthy + litellm: + condition: service_healthy + volumes: + - ../logs:/app/logs + profiles: + - job + +volumes: + redis_data: diff --git a/docs/plans/2026-03-11-agent-multimodal-smoke-runbook.md b/docs/plans/2026-03-11-agent-multimodal-smoke-runbook.md deleted file mode 100644 index cd5f905..0000000 --- a/docs/plans/2026-03-11-agent-multimodal-smoke-runbook.md +++ /dev/null @@ -1,126 +0,0 @@ -# Agent Multimodal Smoke Runbook - -**Goal:** 固化 agent 三条主链路(runs/events/history)的真实冒烟标准与输入基线。 - -## 1. 覆盖范围 - -1. `POST /api/v1/agent/runs` - 接收多模态消息(文本+图片) -2. `GET /api/v1/agent/runs/{thread_id}/events` - SSE 事件流,事件名符合 AG-UI 标准(`RUN_STARTED`、`STEP_STARTED`、`TOOL_CALL_*`、`RUN_FINISHED`/`RUN_ERROR`) -3. `GET /api/v1/agent/runs/{thread_id}/history` - 返回 `STATE_SNAPSHOT`,含 `attachments` metadata -4. `sessions/messages` 落库完整:message_count、tokens、cost、latency、title、metadata -5. tool result 存储:大 payload 写 storage,metadata 记录 `storage_bucket`/`storage_path` -6. storage bucket 来源:必须来自环境变量 `SOCIAL_STORAGE__BUCKET` - -## 2. 固定测试输入 - -- 图片夹具:`backend/tests/fixtures/images/calendar_text_cn.png` -- 多模态消息: - - 文本:`"识别图片中的日历内容并调用 calendar.write 创建日程"` - - 图片:`{"type":"binary","data":"","mimeType":"image/png"}` - -## 3. 账号与凭据 - -- 冒烟账号:`dagronl@126.com` / `123456` -- 通过环境变量注入:`AGENT_LIVE_EMAIL`、`AGENT_LIVE_PASSWORD` - -## 4. 执行命令 - -```bash -AGENT_LIVE_INTEGRATION=1 \ -AGENT_LIVE_EMAIL="dagronl@126.com" \ -AGENT_LIVE_PASSWORD="123456" \ -uv run pytest tests/integration/v1/agent/test_sse_flow_live.py::test_agent_runs_events_history_live_with_image_input -q -s -``` - -## 5. 结果记录模板 - -- `thread_id` / `run_id` -- `runs` 状态码与响应 -- `events` 事件序列 -- `history` 是否含 `attachments[].bucket/path/mimeType` -- `sessions` 字段:message_count / total_tokens / total_cost / status / title -- `messages` 字段:role / content / metadata / tokens / cost / latency -- `tool_result` 是否写 storage - -## 6. 安全注意 - -- 禁止将密码/token 写入 git 跟踪文件 - -## 7. 已修复问题清单 - -| 问题 | 修复内容 | -|------|----------| -| bucket 写入失败回退 | 改为直接报错,禁止回退到硬编码 bucket | -| user.resolve 工具 | 新增按 email/name 解析 user_id | -| calendar.write 邀请参数 | 增加 invite 参数透传 | -| inbox_repository 缺失 | 修复 calendar runtime 依赖 | -| runtime 模型名拼接 | 修复无效 model name | -| 多模态透传 | runtime 透传 binary.data,不过滤为 `` | -| sessions.title 生成 | 首条用户消息持久化时自动生成 | -| assistant latency 入库 | `messages.latency_ms` 列写入 | -| intent/execution 阶段消息落库 | 新增 `text.*` 和 `tool.result` 事件 | -| DIRECT_RESPONSE 早返回 | intent 判定后直接返回,不进入 report 阶段 | - -## 8. 待修复问题(用户新增) - -1. **意图/执行阶段 tokens/cost 入库** - 目前仅 report 阶段入库 -2. **连续会话记忆测试** - 验证 session 是否从数据库读取历史上下文 -3. **工具调用测试** - calendar 读/写/删/分享 + 用户查找 + 时间感知 -4. **session 失败排查** - 找出最新失败原因并修复 - -## 9. 本轮进展与结论(2026-03-12) - -### 9.1 反馈闭环状态 - -1. **intent/execution 阶段 tokens/cost 入库**:已解决。 -2. **连续会话记忆(今天+昨天上下文)**:已解决。 -3. **工具调用冒烟(读/写/删/分享 + user 查询 + 时间感知)**:部分解决。 -4. **最新失败 session 根因定位与修复**:已解决。 -5. **反馈同步到文档**:已完成(本节)。 - -### 9.2 关键修复 - -1. **stage telemetry 补齐**(intent/execution): - - usage 缺失时补 token 估算; - - 通过 `LiteLLMService.calculate_cost` 按项目定价估算 cost; - - 回填 `response_metadata.inputTokens/outputTokens/cost` 并落库。 - -2. **会话记忆上下文注入**: - - runtime 在执行前读取同一 session 最近两天(今天+昨天)的 user/assistant 消息; - - intent prompt 增加 `[Conversation Context]`,避免只看最新用户输入。 - -3. **工具调用稳定性修复**: - - tool 名统一为下划线(`calendar_read`/`calendar_write`/`user_resolve`),修复 OpenAI/LiteLLM tool name 正则错误; - - intent prompt 注入 intent+execution 合并工具 schema,避免误判“无可用写入工具”。 - -### 9.3 Live 证据 - -#### A) tokens/cost 入库(thread=`cb1681c2-c223-4ced-bcfd-76f7252ba2d8`) - -- intent: `input_tokens=1541`,`output_tokens=37`,`cost=0.000382` -- execution: `input_tokens=2161`,`output_tokens=376`,`cost=0.005450` -- report: `input_tokens=3266`,`output_tokens=318`,`cost=0.007256` -- session 聚合:`total_tokens=13518`,`total_cost=0.019473` - -#### B) 连续会话记忆(thread=`9c456736-d5e5-48a4-b9db-55f507baf573`) - -- run `mem-1`:`请记住口令是蓝鲸42,只回复已记住。` -- run `mem-2`:`只回复我刚才让你记住的口令,不要解释。` -- assistant 回复:`蓝鲸42`(记忆命中)。 - -#### C) 工具调用 + 时间感知(thread=`cb1681c2-c223-4ced-bcfd-76f7252ba2d8`,run=`run-tool-1`) - -- 事件序列含 execution 阶段与多次 `TOOL_CALL_RESULT` -- 工具调用结果:`calendar_write`、`calendar_read`(多次) -- assistant 回复包含时间感知信息(北京时间日期/星期/时刻) - -### 9.4 最新失败 session 根因 - -- 失败样本:`d6bc4dbd-8361-4a39-bf09-12b3392e0e70` -- 根因:tool 名含点号(如 `calendar.write`)触发校验失败: - - `Invalid 'tools[0].function.name' ... expected pattern ^[a-zA-Z0-9_-]+$` -- 修复后:同类执行链路已可稳定进入 execution 并产出 `TOOL_CALL_RESULT`。 - -### 9.5 当前未闭环项 - -- `user_resolve` + calendar **分享 + 删除** 组合链路的完整 live 证据还未补齐(本轮执行中断:`Tool execution aborted`)。 diff --git a/docs/plans/2026-03-12-agent-ui-schema-events-design.md b/docs/plans/2026-03-12-agent-ui-schema-events-design.md deleted file mode 100644 index ba56a82..0000000 --- a/docs/plans/2026-03-12-agent-ui-schema-events-design.md +++ /dev/null @@ -1,173 +0,0 @@ -# Agent Tool UI Schema and Frontend Event Wiring Design - -## Goal - -修正 agent 工具结果的数据契约与前后端对接: - -1. SSE `TOOL_CALL_RESULT` 继续携带可实时渲染的 `ui`。 -2. 落库时 `messages.content` 仅存关键摘要,完整工具结果(含 `ui schema`)存对象存储。 -3. `messages.metadata` 仅存访问路径和索引字段,history 通过 metadata 回填完整工具卡片数据。 -4. 前端正式接通 runs/events/history 三路,并统一实时与历史渲染行为。 - -## Constraints - -- 暂缓冒烟测试,先完成工具数据修正与前后端接口对接。 -- 保持现有前端 `UiSchemaRenderer` 可解析格式,不做破坏性协议改动。 -- `resume` 新需求暂不扩展。 -- 遵循 AG-UI 事件语义和现有 FastAPI 路由约定。 - -## Selected Approach - -采用兼容增强方案: - -- 事件流对前端保持兼容(`TOOL_CALL_RESULT` 带 `ui` + `content`)。 -- 持久化与回放做结构化增强(storage + metadata 索引 + 摘要 content)。 -- 前端实时与历史统一映射层,保证同类消息一致渲染。 - -## Design A: Unified Data Contract - -### SSE Event Contract (Realtime) - -`TOOL_CALL_RESULT` 事件继续包含前端当前可解析字段: - -- `callId` -- `toolName` -- `args` -- `result` -- `error` -- `content` (关键结果摘要) -- `ui` (工具卡片 schema) - -这保证前端实时流不需要等待 history 即可显示工具卡片。 - -### Persistence Contract (Database + Storage) - -对 tool message 持久化采用双层: - -- `messages.content`: 仅保存 `content_summary`(短文本,供低成本上下文和兜底展示)。 -- 对象存储: 保存完整 payload(`ui`、`args`、`result`、`error`、时间戳、工具标识等)。 -- `messages.metadata`: 只保存索引和访问路径: - - `tool_call_id` - - `tool_name` - - `run_id` - - `stage` - - `task_id` - - `storage_bucket` - - `storage_path` - - `summary_version` - -### History Contract - -history 序列化时: - -1. 先通过 `metadata.storage_bucket/storage_path` 读取完整 payload。 -2. 从 payload 回填 `ui`,并保留摘要 `content`。 -3. storage 读取失败时,回退 `messages.content`,确保历史可读。 - -## Design B: Frontend Wiring (runs/events/history) - -### runs - -- `POST /api/v1/agent/runs` 仅负责创建 run 与启动执行。 -- 前端保留 `threadId/runId` 和本地流状态,不承载渲染业务。 - -### events - -- SSE 作为唯一实时渲染来源。 -- `TOOL_CALL_RESULT` 直接读取事件内 `ui` 渲染 `ToolResultItem`。 -- `STEP_STARTED/STEP_FINISHED` 显示三阶段状态(intent/execution/report)。 - -### history - -- 通过 `/api/v1/agent/history` 或 `/api/v1/agent/runs/{threadId}/history` 回放。 -- tool message 优先读 `ui`(由后端从 metadata+storage 回填)。 -- user message 读取 `attachments` 渲染多模态内容。 - -### Consistency Rule - -- 实时事件与历史快照统一进入同一 `ChatListItem` 映射层。 -- `content` 只做兜底文本,不作为工具卡片主数据。 - -## Design C: Backend Implementation Details - -### Modules to Change - -- `backend/src/core/agentscope/events/store.py` - - 增加 tool result 的摘要生成与 storage 上传。 - - `append_message` 时写入摘要 content + metadata 索引。 -- `backend/src/core/agentscope/tools/tool_result_storage.py` - - 复用现有 `upload_json/read_json`,作为完整 payload 存取层。 -- `backend/src/v1/agent/repository.py` - - `_to_snapshot_message` 对 tool message 优先按 metadata 读取 storage 并回填 `ui`。 -- `backend/src/core/agentscope/runtime/agent_route_runtime.py` - - 确保 `tool.result` 事件继续带 `ui` 和摘要 `content`。 - -### Failure Fallback - -- storage 写失败:不阻断主流程,至少保证 `messages.content` 可读,metadata 标记缺失。 -- storage 读失败:history 返回摘要 `content`,`ui` 为空。 - -## Design D: content_summary Rule Engine - -### Function - -新增纯函数: - -`build_tool_content_summary(tool_name, args, result, error) -> str` - -### Rules (Priority) - -1. 错误优先:有 `error` 直接输出失败摘要。 -2. 工具专用模板: - - `calendar_write`: `已创建日程:{title}({start_time})` - - `calendar_read`: `查询到 {count} 条日程({date_range})` - - `calendar_delete`: `已删除日程:{title_or_id}` - - `calendar_share`: `已分享日程给 {target}` - - `user_resolve`: `已匹配用户:{name_or_id}` -3. 通用回退:优先 `result.content`,否则抽取常见键拼句。 -4. 最终兜底:`{tool_name} 执行完成/执行失败`。 -5. 清洗:去换行与多空格,限制长度,避免大段 JSON。 - -### Summary Storage Policy - -- `messages.content` 存摘要。 -- `summary_version` 存入 metadata,支持未来摘要算法演进。 - -## Testing and Acceptance - -### Backend - -- 单元测试: - - `events/store`: tool result 摘要写入、metadata 路径写入、storage 异常回退。 - - `v1/agent/repository`: history 按 metadata 回填 `ui`;storage 缺失回退 content。 - - 摘要函数:覆盖成功/失败/缺字段/超长文本场景。 -- 集成测试: - - `/runs` + `/events`:实时 `TOOL_CALL_RESULT` 带 `ui`。 - - `/history`:返回 tool message 的 `ui` 来自 metadata+storage。 - -### Frontend - -- 单元/组件测试: - - `AgUiService` 解析 `TOOL_CALL_RESULT` 的 `ui`。 - - `ChatBloc`:实时事件与 history 快照都能产出 `ToolResultItem`。 - - `UiSchemaRenderer`:history 回放卡片渲染一致。 - - user message 附件渲染(history)。 -- 页面行为验证: - - events 到达即实时更新消息列表。 - - step 三阶段状态正确切换。 - - 上拉历史后工具卡片可正常显示。 - -## Risks and Mitigations - -- 风险:storage 不可用导致 history 卡片缺失。 - - 缓解:保底展示摘要 content,不阻断对话。 -- 风险:事件格式变更导致前端实时解析失败。 - - 缓解:维持现有 `ToolCallResultEvent` 字段,不做破坏性改名。 -- 风险:摘要规则覆盖不足。 - - 缓解:规则版本化 + 测试样例扩展。 - -## Out of Scope - -- resume 扩展协议与交互策略。 -- 新一轮 live 冒烟验收。 -- 新 UI 风格重构,仅实现链路打通与数据契约修正。 diff --git a/docs/plans/2026-03-12-agent-ui-schema-events-implementation-plan.md b/docs/plans/2026-03-12-agent-ui-schema-events-implementation-plan.md deleted file mode 100644 index 80a7b1c..0000000 --- a/docs/plans/2026-03-12-agent-ui-schema-events-implementation-plan.md +++ /dev/null @@ -1,283 +0,0 @@ -# Agent UI Schema and Event Wiring Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 打通 agent 工具结果在实时事件与历史回放的一致渲染链路:SSE 实时带 UI,落库 content 存摘要,完整 UI schema 存 storage 并通过 metadata 回填。 - -**Architecture:** 后端在 `TOOL_CALL_RESULT` 持久化链路中引入“摘要 + 全量分离”策略:摘要写 `messages.content`,全量 payload 写对象存储,metadata 仅存索引路径;history 读取时按 metadata 反查 storage 回填 `ui`。前端复用现有 AG-UI 事件模型,实现 runs/events/history 三路统一映射到 `ChatListItem`,并补齐 step 事件渲染与 history 多模态渲染。 - -**Tech Stack:** FastAPI, SQLAlchemy, AgentScope runtime/events, Supabase Storage, Flutter (Bloc/Cubit), Dart models/tests, AG-UI events - ---- - -### Task 1: Add Tool Summary Rule Engine (Backend) - -**Files:** -- Create: `backend/src/core/agentscope/events/tool_result_summary.py` -- Test: `backend/tests/unit/core/agentscope/events/test_tool_result_summary.py` - -**Step 1: Write the failing test** - -```python -from core.agentscope.events.tool_result_summary import build_tool_content_summary - - -def test_calendar_write_summary() -> None: - text = build_tool_content_summary( - tool_name="calendar_write", - args={"title": "项目评审"}, - result={"start_time": "明天 10:00"}, - error=None, - ) - assert text.startswith("已创建日程") -``` - -**Step 2: Run test to verify it fails** - -Run: `uv run pytest backend/tests/unit/core/agentscope/events/test_tool_result_summary.py -q` -Expected: FAIL with import/module/function missing. - -**Step 3: Write minimal implementation** - -```python -def build_tool_content_summary(*, tool_name: str, args, result, error) -> str: - if error: - return f"{tool_name} 执行失败" - if tool_name == "calendar_write": - return "已创建日程" - return f"{tool_name} 执行完成" -``` - -**Step 4: Run test to verify it passes** - -Run: `uv run pytest backend/tests/unit/core/agentscope/events/test_tool_result_summary.py -q` -Expected: PASS. - -**Step 5: Extend tests for all rules and refactor** - -Add cases for `calendar_read/calendar_delete/calendar_share/user_resolve/error/fallback/truncation` and implement full rule table. - -**Step 6: Commit** - -```bash -git add backend/src/core/agentscope/events/tool_result_summary.py backend/tests/unit/core/agentscope/events/test_tool_result_summary.py -git commit -m "feat: add deterministic tool result summary engine" -``` - -### Task 2: Persist Full Tool Payload to Storage and Keep Content Lightweight - -**Files:** -- Modify: `backend/src/core/agentscope/events/store.py` -- Test: `backend/tests/unit/core/agentscope/events/test_store.py` - -**Step 1: Write the failing tests** - -Add tests asserting: -- `TOOL_CALL_RESULT` persists summary to `content`. -- metadata includes `storage_bucket/storage_path/tool_call_id`. -- uploaded payload includes full `ui/args/result/error`. - -**Step 2: Run targeted tests (RED)** - -Run: `uv run pytest backend/tests/unit/core/agentscope/events/test_store.py -q` -Expected: FAIL on new assertions. - -**Step 3: Implement minimal storage write path** - -In `_persist_tool_call_result`: -- build `full_payload` from event fields. -- call summary engine for `content`. -- upload payload via tool result storage (inject dependency if needed). -- store only path/index in metadata. - -**Step 4: Run tests (GREEN)** - -Run: `uv run pytest backend/tests/unit/core/agentscope/events/test_store.py -q` -Expected: PASS. - -**Step 5: Add fallback test and implementation** - -Add case where storage upload fails but tool message still persists with summary and no crash. - -**Step 6: Commit** - -```bash -git add backend/src/core/agentscope/events/store.py backend/tests/unit/core/agentscope/events/test_store.py -git commit -m "feat: store tool payload in object storage with metadata index" -``` - -### Task 3: Hydrate History Tool UI from Metadata Storage Path - -**Files:** -- Modify: `backend/src/v1/agent/repository.py` -- Test: `backend/tests/unit/v1/agent/test_repository.py` - -**Step 1: Write failing tests** - -Add/adjust assertions: -- history tool payload resolves `ui` from storage payload. -- when storage missing, fallback to `messages.content` summary. - -**Step 2: Run tests (RED)** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_repository.py -q` -Expected: FAIL on `ui` hydration and fallback assertions. - -**Step 3: Implement minimal hydration logic** - -In `_to_snapshot_message` for tool role: -- read storage via `metadata.storage_bucket/storage_path`. -- map hydrated payload fields to snapshot (`ui`, `content`, `toolCallId`). -- keep safe fallback when storage read fails. - -**Step 4: Run tests (GREEN)** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_repository.py -q` -Expected: PASS. - -**Step 5: Commit** - -```bash -git add backend/src/v1/agent/repository.py backend/tests/unit/v1/agent/test_repository.py -git commit -m "fix: hydrate tool ui from metadata storage in history snapshots" -``` - -### Task 4: Keep SSE TOOL_CALL_RESULT Compatible with Existing Frontend Parsing - -**Files:** -- Modify: `backend/src/core/agentscope/runtime/agent_route_runtime.py` -- Test: `backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py` - -**Step 1: Write failing test** - -Add assertion that emitted `TOOL_CALL_RESULT` data contains expected renderable fields (`callId/toolName/result/error` and `ui` path from result payload). - -**Step 2: Run tests (RED)** - -Run: `uv run pytest backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py -q` -Expected: FAIL on missing/incorrect payload fields. - -**Step 3: Implement minimal payload normalization** - -Normalize tool result event payload so frontend can keep current parsing without contract breaks. - -**Step 4: Run tests (GREEN)** - -Run: `uv run pytest backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py -q` -Expected: PASS. - -**Step 5: Commit** - -```bash -git add backend/src/core/agentscope/runtime/agent_route_runtime.py backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py -git commit -m "fix: preserve frontend-compatible tool result event payload" -``` - -### Task 5: Wire Frontend History + Events to Unified Rendering Path - -**Files:** -- Modify: `apps/lib/features/chat/data/services/ag_ui_service.dart` -- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart` -- Modify: `apps/lib/features/chat/data/models/tool_result.dart` -- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` -- Test: `apps/test/features/chat/ag_ui_service_test.dart` -- Create/Modify: `apps/test/features/chat/chat_bloc_test.dart` - -**Step 1: Write failing tests** - -Add tests asserting: -- history tool message with `ui` becomes `ToolResultItem`. -- SSE `TOOL_CALL_RESULT` with `ui` renders same item shape. -- attachments in history user message are mapped for multimodal rendering. - -**Step 2: Run tests (RED)** - -Run: `cd apps && flutter test test/features/chat/ag_ui_service_test.dart` -Expected: FAIL on new mapping assertions. - -**Step 3: Implement minimal mapping changes** - -- In service/bloc, unify history and event mapping into same conversion path. -- Keep existing `UiSchemaRenderer` input format untouched. -- Ensure fallback to content text when `ui` missing. - -**Step 4: Run tests (GREEN)** - -Run: `cd apps && flutter test test/features/chat/ag_ui_service_test.dart` -Expected: PASS. - -**Step 5: Commit** - -```bash -git add apps/lib/features/chat/data/services/ag_ui_service.dart apps/lib/features/chat/presentation/bloc/chat_bloc.dart apps/lib/features/chat/data/models/tool_result.dart apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/chat/ag_ui_service_test.dart apps/test/features/chat/chat_bloc_test.dart -git commit -m "feat: unify realtime and history tool card rendering" -``` - -### Task 6: Add Step Event Rendering for Intent/Execution/Report - -**Files:** -- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart` -- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` -- Test: `apps/test/features/chat/chat_bloc_test.dart` - -**Step 1: Write failing test** - -Add test verifying `STEP_STARTED/STEP_FINISHED` transitions produce visible stage state. - -**Step 2: Run tests (RED)** - -Run: `cd apps && flutter test test/features/chat/chat_bloc_test.dart` -Expected: FAIL on missing stage state. - -**Step 3: Implement minimal state and UI** - -- Track current stage enum in `ChatState`. -- Render compact stage progress row in chat screen. - -**Step 4: Run tests (GREEN)** - -Run: `cd apps && flutter test test/features/chat/chat_bloc_test.dart` -Expected: PASS. - -**Step 5: Commit** - -```bash -git add apps/lib/features/chat/presentation/bloc/chat_bloc.dart apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/chat/chat_bloc_test.dart -git commit -m "feat: render agent step progress from AG-UI events" -``` - -### Task 7: Verification Gate (Backend + Frontend) - -**Files:** -- Modify (if needed): `docs/plans/2026-03-11-agent-multimodal-smoke-runbook.md` - -**Step 1: Run backend targeted tests** - -Run: `uv run pytest backend/tests/unit/core/agentscope/events/test_tool_result_summary.py backend/tests/unit/core/agentscope/events/test_store.py backend/tests/unit/v1/agent/test_repository.py backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py -q` -Expected: PASS. - -**Step 2: Run frontend targeted tests** - -Run: `cd apps && flutter test test/features/chat/ag_ui_service_test.dart test/features/chat/chat_bloc_test.dart` -Expected: PASS. - -**Step 3: Run backend quality checks** - -Run: `uv run ruff check backend/src backend/tests` -Expected: PASS. - -**Step 4: Run backend type checks** - -Run: `uv run basedpyright` -Expected: 0 errors. - -**Step 5: Update runbook evidence** - -Record changed contract, test evidence, and known follow-ups. - -**Step 6: Commit** - -```bash -git add docs/plans/2026-03-11-agent-multimodal-smoke-runbook.md -git commit -m "docs: record tool ui schema storage and rendering verification" -``` diff --git a/docs/plans/2026-03-12-home-composer-redesign-design.md b/docs/plans/2026-03-12-home-composer-redesign-design.md new file mode 100644 index 0000000..90e0c05 --- /dev/null +++ b/docs/plans/2026-03-12-home-composer-redesign-design.md @@ -0,0 +1,122 @@ +# Home 输入组件重做设计(HomeComposer Redesign) + +## 1. 目标与范围 + +### 1.1 目标 +- 解决当前输入组件“质感弱、结构割裂、录音时布局漂移”的问题。 +- 统一 `+` 按钮、输入区、右侧动作图标到一个圆角矩形主容器内。 +- 保留并复用现有录音、转写、自动发送、停止生成、Toast 错误处理逻辑。 + +### 1.2 非目标 +- 不改动聊天流、消息发送后端协议、语音识别接口。 +- 不改动 `+` 按钮业务行为。 +- 不新增独立的页面级浮层录音面板。 + +## 2. 问题诊断(现状) + +- 当前输入区由多个分离容器拼接,视觉上像“纸片贴上去”。 +- 输入框本体和右侧图标视觉上未合为一个整体容器。 +- “按住说话”提示与录音动画在主布局外追加,录音时造成结构上下跳动。 + +## 3. 方案对比 + +### 方案 A:单容器双模式(推荐) +- 单一胶囊主容器承载三段:左操作、中间主内容、右操作。 +- 中间区域在文本模式与按住说话模式之间替换(`AnimatedSwitcher`)。 +- 录音动画仅在中间区域内部切换显示,不改变主容器高度。 + +优点:结构稳定、状态清晰、维护成本低。 +缺点:视觉表达自由度略低于 Overlay 方案。 + +### 方案 B:双容器交叉切换 +- 文本容器和语音容器完整分离,做交叉淡入。 + +优点:切换动效可做得更明显。 +缺点:状态同步复杂,容易再次出现错位与边界问题。 + +### 方案 C:Overlay 浮层 +- 保持输入容器不变,录音时叠加浮层。 + +优点:动画自由度高。 +缺点:与“整块替换”诉求不一致,事件命中与无障碍处理更复杂。 + +结论:采用方案 A。 + +## 4. 信息架构与组件边界 + +## 4.1 新组件 +- 新建 `HomeComposer`(从 `home_screen.dart` 抽离输入区渲染职责)。 +- `HomeScreen` 继续持有业务状态与行为方法,`HomeComposer` 负责展示与手势分发。 + +## 4.2 主容器结构 +- 一个圆角矩形主容器(轻拟物胶囊风格)。 +- 左侧:`+` 按钮(行为不变)。 +- 中间: + - 文本模式:无边框输入区(文字垂直居中)。 + - 语音模式:按住说话按钮区。 + - 录音中/识别中:在语音模式内部替换状态内容。 +- 右侧:动作图标(声波/键盘/发送/停止)。 + +## 5. 状态机设计 + +## 5.1 状态定义 +- 模式层:`text` / `holdToSpeak` +- 过程层:`idle` / `recording` / `transcribing` + +## 5.2 核心约束 +- 主容器高度固定,状态变化不得引发外层布局高度变化。 +- `recording` 时禁止模式切换,避免状态错位。 + +## 5.3 右侧图标决策 +- Agent 等待中:停止图标。 +- 非等待: + - 有文本:发送图标。 + - 无文本且 `text`:`LucideIcons.activity`。 + - 无文本且 `holdToSpeak`:`LucideIcons.keyboard`。 + +## 6. 交互与动画 + +## 6.1 长按语音流程 +1. `onLongPressStart`:触发 `HapticFeedback.lightImpact()`,开始录音。 +2. `onLongPressMoveUpdate`:上滑超过阈值,取消录音。 +3. `onLongPressEnd`:未取消则停止录音,转写并自动发送。 + +## 6.2 提示文案显示策略 +- “松开发送,上滑取消”仅在 `recording` 显示。 +- 空闲按住说话模式不显示该提示。 + +## 6.3 动画策略 +- 模式切换:短时 `AnimatedSwitcher`(淡入/轻位移)。 +- 录音波形:仅在 `recording` 驱动;停止后立刻回收。 +- 动画渲染在中间区域内部,不新增外部占位。 + +## 7. 视觉规范(Design Tokens) + +- 严格使用 `apps/lib/core/theme/design_tokens.dart` 中的 `AppColors`、`AppSpacing`、`AppRadius`。 +- 禁止硬编码颜色、间距、圆角、尺寸、阴影。 +- 主容器采用白色底 + 细边 + 柔和阴影,形成轻拟物层次。 +- 输入区内部无额外边框,确保文字垂直居中和图标视觉对齐。 + +## 8. 验收标准 + +- 图标与输入区在同一圆角矩形中,不再分离。 +- 录音全流程不出现输入组件上移、下坠或高度抖动。 +- 提示文案仅在实际录音中显示。 +- 文本/语音模式切换平滑;`+` 与发送逻辑行为保持一致。 + +## 9. 风险与缓解 + +- 风险:重构过程中影响现有发送/停止生成分支。 + - 缓解:优先复用原有行为方法,仅调整 UI 结构与状态映射。 +- 风险:手势与模式切换并发导致状态错乱。 + - 缓解:录音期间加切换锁,结束后释放。 + +## 10. 验证计划 + +- 手工验证: + - 文本发送、停止生成、`+` 弹层、语音长按录音、上滑取消、自动发送。 + - 文本模式与语音模式往返切换稳定性。 +- Widget 测试(建议新增): + - 右侧图标状态映射测试。 + - 录音中提示文案显示条件测试。 + - 模式切换时主容器高度恒定测试。 diff --git a/docs/plans/2026-03-12-home-composer-redesign-implementation-plan.md b/docs/plans/2026-03-12-home-composer-redesign-implementation-plan.md new file mode 100644 index 0000000..0c16542 --- /dev/null +++ b/docs/plans/2026-03-12-home-composer-redesign-implementation-plan.md @@ -0,0 +1,275 @@ +# Home Composer Redesign Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 重做 Home 输入组件,统一为单胶囊容器并稳定语音长按交互,消除布局漂移,同时保持 `+` 与发送等既有业务逻辑不变。 + +**Architecture:** 采用“单容器双模式”方案:`HomeScreen` 继续持有业务状态与动作,新增 `HomeComposer` 专注 UI 与手势分发;中间区域用受控状态在文本/按住说话/录音中/识别中之间切换,外层高度固定。录音提示与声波动画都内聚在主容器内部渲染,避免额外布局占位。 + +**Tech Stack:** Flutter, flutter_bloc, lucide_icons, design tokens (`AppColors`/`AppSpacing`/`AppRadius`), widget test + +--- + +### Task 1: 建立 HomeComposer 组件骨架与参数契约 + +**Files:** +- Create: `apps/lib/features/home/ui/widgets/home_composer.dart` +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` +- Test: `apps/test/features/home/ui/widgets/home_composer_test.dart` + +**Step 1: 写失败测试(渲染结构)** + +```dart +testWidgets('renders one unified rounded composer container', (tester) async { + // pump HomeComposer with minimum required callbacks/states + // expect: one root container, plus button, center content slot, right action slot +}); +``` + +**Step 2: 运行测试确认失败** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "renders one unified rounded composer container"` +Expected: FAIL(`HomeComposer` 未定义或结构不匹配) + +**Step 3: 写最小实现** + +```dart +class HomeComposer extends StatelessWidget { + const HomeComposer({ + super.key, + required this.isHoldToSpeakMode, + required this.isRecording, + required this.isTranscribing, + required this.hasMessage, + required this.isWaitingAgent, + required this.onTapPlus, + required this.onTapRightAction, + required this.centerChild, + }); + // unified capsule container with left/center/right slots +} +``` + +**Step 4: 再跑测试确认通过** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "renders one unified rounded composer container"` +Expected: PASS + +**Step 5: 小步提交(仅用户明确要求时)** + +```bash +git add apps/lib/features/home/ui/widgets/home_composer.dart apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/home/ui/widgets/home_composer_test.dart +git commit -m "refactor: extract unified home composer container" +``` + +### Task 2: 完成右侧图标状态映射(activity/keyboard/send/stop) + +**Files:** +- Modify: `apps/lib/features/home/ui/widgets/home_composer.dart` +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` +- Test: `apps/test/features/home/ui/widgets/home_composer_test.dart` + +**Step 1: 写失败测试(图标状态机)** + +```dart +testWidgets('right action icon follows state priority', (tester) async { + // waiting > hasMessage > holdToSpeakMode > textMode + // expect LucideIcons.square/send/keyboard/activity respectively +}); +``` + +**Step 2: 运行测试确认失败** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "right action icon follows state priority"` +Expected: FAIL(图标选择逻辑尚未完整实现) + +**Step 3: 最小实现图标决策** + +```dart +IconData resolveRightIcon(...) { + if (isWaitingAgent) return LucideIcons.square; + if (hasMessage) return LucideIcons.send; + return isHoldToSpeakMode ? LucideIcons.keyboard : LucideIcons.activity; +} +``` + +**Step 4: 再跑测试确认通过** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "right action icon follows state priority"` +Expected: PASS + +**Step 5: 小步提交(仅用户明确要求时)** + +```bash +git add apps/lib/features/home/ui/widgets/home_composer.dart apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/home/ui/widgets/home_composer_test.dart +git commit -m "refactor: stabilize composer right action icon mapping" +``` + +### Task 3: 实现中间区域双模式替换并固定高度 + +**Files:** +- Modify: `apps/lib/features/home/ui/widgets/home_composer.dart` +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` +- Test: `apps/test/features/home/ui/widgets/home_composer_test.dart` + +**Step 1: 写失败测试(模式切换不改变高度)** + +```dart +testWidgets('composer height remains stable across mode switches', (tester) async { + // measure size in text mode and hold-to-speak mode + // expect equal heights +}); +``` + +**Step 2: 运行测试确认失败** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "composer height remains stable across mode switches"` +Expected: FAIL(当前结构切换时高度波动) + +**Step 3: 最小实现(AnimatedSwitcher + fixed constraints)** + +```dart +SizedBox( + height: composerHeight, + child: AnimatedSwitcher( + duration: switchDuration, + child: isHoldToSpeakMode ? holdToSpeakChild : textInputChild, + ), +) +``` + +**Step 4: 再跑测试确认通过** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "composer height remains stable across mode switches"` +Expected: PASS + +**Step 5: 小步提交(仅用户明确要求时)** + +```bash +git add apps/lib/features/home/ui/widgets/home_composer.dart apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/home/ui/widgets/home_composer_test.dart +git commit -m "refactor: keep composer layout stable during mode switch" +``` + +### Task 4: 实现长按录音交互(开始/上滑取消/松开发送) + +**Files:** +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` +- Modify: `apps/lib/features/home/ui/widgets/home_composer.dart` +- Test: `apps/test/features/home/ui/widgets/home_composer_test.dart` + +**Step 1: 写失败测试(录音提示只在 recording)** + +```dart +testWidgets('recording hint appears only while recording', (tester) async { + // idle hold-to-speak: no hint + // recording: show "松开发送,上滑取消" +}); +``` + +**Step 2: 运行测试确认失败** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "recording hint appears only while recording"` +Expected: FAIL + +**Step 3: 最小实现录音流程映射** + +```dart +onLongPressStart => HapticFeedback.lightImpact() + onHoldStart(); +onLongPressMoveUpdate => if (dy < threshold) onHoldCancel(); +onLongPressEnd => onHoldEnd(autoSend: true); +``` + +**Step 4: 再跑测试确认通过** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "recording hint appears only while recording"` +Expected: PASS + +**Step 5: 小步提交(仅用户明确要求时)** + +```bash +git add apps/lib/features/home/ui/widgets/home_composer.dart apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/home/ui/widgets/home_composer_test.dart +git commit -m "feat: rework hold-to-speak interaction with stable recording state" +``` + +### Task 5: 视觉重构为轻拟物胶囊(仅 tokens) + +**Files:** +- Modify: `apps/lib/features/home/ui/widgets/home_composer.dart` +- Modify: `apps/lib/core/theme/design_tokens.dart`(仅当现有 token 不足时新增) +- Test: `apps/test/features/home/ui/widgets/home_composer_test.dart` + +**Step 1: 写失败测试(主容器统一性)** + +```dart +testWidgets('plus, center and right action are inside same capsule', (tester) async { + // find one capsule host and verify children are descendants +}); +``` + +**Step 2: 运行测试确认失败** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "plus, center and right action are inside same capsule"` +Expected: FAIL + +**Step 3: 最小视觉实现(不硬编码)** + +```dart +// use AppColors/AppSpacing/AppRadius and existing shadow tokens +// no hardcoded color/spacing/radius/size +``` + +**Step 4: 再跑测试确认通过** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart --plain-name "plus, center and right action are inside same capsule"` +Expected: PASS + +**Step 5: 小步提交(仅用户明确要求时)** + +```bash +git add apps/lib/features/home/ui/widgets/home_composer.dart apps/lib/core/theme/design_tokens.dart apps/test/features/home/ui/widgets/home_composer_test.dart +git commit -m "refactor: redesign home composer with neumorphic capsule style" +``` + +### Task 6: 集成回归与文档同步 + +**Files:** +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` +- Modify: `docs/runtime/runtime-route.md`(若交互说明有变化) + +**Step 1: 运行目标测试文件** + +Run: `flutter test test/features/home/ui/widgets/home_composer_test.dart` +Expected: PASS + +**Step 2: 运行 Home 相关回归测试(若新增)** + +Run: `flutter test test/features/home` +Expected: PASS(若目录存在) + +**Step 3: 运行应用侧基础回归** + +Run: `flutter test` +Expected: PASS 或仅存在与本改动无关的已知失败 + +**Step 4: 记录验证结论** + +```text +- 输入组件统一容器:通过 +- 模式切换稳定:通过 +- 录音提示条件:通过 +- + 按钮/发送逻辑:通过 +``` + +**Step 5: 小步提交(仅用户明确要求时)** + +```bash +git add apps/lib/features/home/ui/screens/home_screen.dart apps/lib/features/home/ui/widgets/home_composer.dart apps/test/features/home/ui/widgets/home_composer_test.dart docs/runtime/runtime-route.md +git commit -m "test: add regression coverage for home composer redesign" +``` + +## 实施注意事项 + +- 保持 `HomeScreen` 作为业务状态单一来源,避免在 `HomeComposer` 内部复制业务状态。 +- 录音中 (`recording`) 禁止触发模式切换,防止并发手势引发错位。 +- 严格遵守 `apps/AGENTS.md`:不硬编码视觉值,必须使用 design tokens。 +- 用户反馈统一使用 `Toast.show(...)`,不得引入 `SnackBar`。 diff --git a/docs/protocols/routes.md b/docs/protocols/routes.md new file mode 100644 index 0000000..0eb82d1 --- /dev/null +++ b/docs/protocols/routes.md @@ -0,0 +1,13 @@ +# Auth Routes Protocol Notes + +## POST `/api/v1/auth/verifications` + +- `invite_code` is optional. +- Recommended format is fixed `4` chars and pattern `^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$`. +- Backend normalizes invite codes to uppercase and validates in service logic. +- Invalid invite code values are ignored (treated as empty), and signup verification email flow still continues. + +## Verification Token Input Convention + +- Verification token for signup/recovery uses fixed `6` digits. +- Client UI should use fixed-length segmented input to reduce mistyped values. diff --git a/docs/protocols/ui-schema.md b/docs/protocols/ui-schema.md new file mode 100644 index 0000000..cbff018 --- /dev/null +++ b/docs/protocols/ui-schema.md @@ -0,0 +1,466 @@ +# UI Schema Protocol + +> **NOTE**: This document is the single source of truth. All implementations must follow this specification. + +## Overview + +A generic UI schema for rendering tool/agent execution results. Designed for AI Agent / Tool ecosystem with extensibility. + +## Version + +- **Current**: `1.0` +- **Status**: Frozen (no new node types) + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ UiSchemaDocument (root) │ +│ - version / schemaType / status / docId │ +│ - meta (protocol-level metadata) │ +│ - nodes (array of UiNode) │ +├─────────────────────────────────────────────────────────────┤ +│ Field Layers: │ +│ 1. Public fields (all renderers must handle) │ +│ id / type / title / description / icon / status / │ +│ timestamp / actions │ +│ 2. meta (protocol-level, not rendered) │ +│ requestId / toolId / traceId / userId │ +│ 3. extensions (tool私有扩展, renderer透传) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Data Types + +### SchemaType + +```typescript +type SchemaType = 'tool_result' | 'agent_response' | 'notification'; +``` + +### UiStatus + +```typescript +type UiStatus = 'info' | 'success' | 'warning' | 'error' | 'pending'; +``` + +### IconSource + +```typescript +type IconSource = 'icon' | 'emoji' | 'url'; +``` + +### ActionType + +```typescript +type ActionType = 'navigation' | 'url' | 'event' | 'tool' | 'copy' | 'payload'; +``` + +--- + +## Root Structure + +```typescript +interface UiSchemaDocument { + // Protocol identifier + version: string; // "1.0" + schemaType: SchemaType; // tool_result | agent_response | notification + + // Document metadata + docId?: string; // For local refresh / diff / analytics + timestamp?: string; // ISO 8601 + locale?: string; // "zh-CN" + + // Unified status + status: UiStatus; + + // Render control + renderer?: { + renderer?: string; // Dedicated renderer name + theme?: 'default' | 'dark' | 'light'; + }; + + // Protocol-level metadata (not rendered) + meta?: { + requestId?: string; + toolId?: string; + traceId?: string; + userId?: string; + [key: string]: any; + }; + + // Root nodes + nodes: UiNode[]; +} +``` + +--- + +## Node Types (v1 Whitelist) + +``` +✅ Supported in v1: + - card 卡片 + - list 列表 + - table 表格 + - text 文本/Markdown + - kv 键值对 + - operation 操作结果 + - error 错误提示 + - container 容器 + +❌ Not supported in v1 (reserved for v2): + - chart / metric / image / video / tabs / accordion / form +``` + +--- + +## Common Node Fields + +All nodes share these fields: + +```typescript +interface UiBaseNode { + id?: string; // For local refresh / action targeting + type: string; // Node type identifier +} + +interface UiTitledNode extends UiBaseNode { + title?: string; + description?: string; + icon?: UiIcon; + status?: UiStatus; + timestamp?: string; +} + +interface UiActionableNode extends UiTitledNode { + actions?: UiAction[]; +} + +interface UiExtendableNode extends UiActionableNode { + extensions?: Record; // Tool私有扩展, 通用renderer透传 +} +``` + +--- + +## Node Definitions + +### 1. Card Node + +```typescript +interface UiCardNode extends UiExtendableNode { + type: 'card'; + children?: UiNode[]; // Nested nodes + footer?: UiTextNode; +} +``` + +### 2. List Node + +```typescript +interface UiListNode extends UiExtendableNode { + type: 'list'; + items: ListItem[]; + pagination?: { + page: number; + pageSize: number; + total: number; + hasMore: boolean; + }; + emptyText?: string; +} + +interface ListItem { + id: string; + title: string; + subtitle?: string; + description?: string; + icon?: UiIcon; + badge?: { label: string; variant?: 'default' | 'success' | 'warning' | 'error' | 'info' }; + metadata?: Record; + actions?: UiAction[]; +} +``` + +### 3. Table Node + +```typescript +interface UiTableNode extends UiExtendableNode { + type: 'table'; + columns: TableColumn[]; + rows: TableRow[]; + pagination?: Pagination; +} + +interface TableColumn { + key: string; + label: string; + width?: string; + align?: 'left' | 'center' | 'right'; +} + +interface TableRow { + id: string; + cells: Record; + metadata?: Record; + actions?: UiAction[]; +} +``` + +### 4. Text Node + +```typescript +interface UiTextNode extends UiBaseNode { + type: 'text'; + content: string; + format?: 'plain' | 'markdown'; + icon?: UiIcon; + actions?: UiAction[]; +} +``` + +### 5. Key-Value Node + +```typescript +interface UiKvNode extends UiExtendableNode { + type: 'kv'; + pairs: KeyValuePair[]; + layout?: 'vertical' | 'horizontal' | 'grid'; +} + +interface KeyValuePair { + key: string; + label?: string; + value: string | number | boolean; + copyable?: boolean; +} +``` + +### 6. Operation Node + +```typescript +interface UiOperationNode extends UiExtendableNode { + type: 'operation'; + operation: 'create' | 'update' | 'delete' | 'execute'; + result: 'success' | 'failure' | 'partial'; + message?: string; + affectedCount?: number; + details?: UiNode; + rollback?: UiAction; +} +``` + +### 7. Error Node + +```typescript +interface UiErrorNode extends UiBaseNode { + type: 'error'; + title?: string; + icon?: UiIcon; + errorCode: string; + message: string; + details?: string; + stack?: string; + retryable: boolean; + suggestions?: string[]; + retry?: UiAction; + support?: UiAction; + actions?: UiAction[]; +} +``` + +### 8. Container Node + +```typescript +interface UiContainerNode extends UiBaseNode { + type: 'container'; + direction: 'vertical' | 'horizontal'; + gap?: number; + children: UiNode[]; + actions?: UiAction[]; +} +``` + +--- + +## Action Structure + +```typescript +interface UiAction { + id: string; + label: string; + icon?: UiIcon; + style?: 'primary' | 'secondary' | 'ghost' | 'danger'; + disabled?: boolean; + action: ActionSpec; + confirm?: { + title?: string; + message?: string; + confirmLabel?: string; + cancelLabel?: string; + }; +} + +type ActionSpec = + | { type: 'navigation'; path: string; params?: Record } + | { type: 'url'; url: string; target?: '_self' | '_blank' } + | { type: 'event'; event: string; payload?: Record } + | { type: 'tool'; toolId: string; params?: Record } + | { type: 'copy'; content: string; successMessage?: string } + | { type: 'payload'; payload: Record; submitTo?: string }; +``` + +--- + +## Icon Structure + +```typescript +interface UiIcon { + source: IconSource; // 'icon' | 'emoji' | 'url' + value: string; // icon name / emoji / URL + color?: string; // "#FF0000" + size?: number; // pixels +} +``` + +--- + +## Usage Rules + +### operation vs error + +| Scenario | Node Type | +|----------|-----------| +| Tool execution failed, system exception | `UiErrorNode` | +| Business operation result (CRUD) | `UiOperationNode` | +| Network error / permission denied | `UiErrorNode` | + +### extensions Usage Constraints + +1. ❌ NO: Any rendering-related style / text / layout +2. ❌ NO: Any fields other renderers need to read +3. ✅ YES: Business identifiers (eventId, orderId) +4. ✅ YES: Dedicated renderer private config +5. ✅ YES: Analytics / logging context data +6. ✅ YES: Data that generic renderer doesn't care about + +--- + +## JSON Examples + +### Example 1: Success Card + +```json +{ + "version": "1.0", + "schemaType": "tool_result", + "docId": "doc_evt_001", + "timestamp": "2026-03-12T10:30:00Z", + "status": "success", + "renderer": { "renderer": "calendar" }, + "meta": { "requestId": "req_abc", "toolId": "calendar.create_event" }, + "nodes": [ + { + "id": "node_card_1", + "type": "card", + "title": "日程已创建", + "description": "会议日程创建成功", + "icon": { "source": "icon", "value": "event_available" }, + "extensions": { "eventId": "evt_abc123", "color": "#3B82F6" }, + "children": [ + { + "type": "kv", + "pairs": [ + { "key": "title", "label": "主题", "value": "Q1 规划会议" }, + { "key": "time", "label": "时间", "value": "2026-03-15 14:00" } + ] + } + ], + "actions": [ + { + "id": "action_view", + "label": "查看详情", + "style": "primary", + "action": { "type": "navigation", "path": "/calendar/evt_abc123" } + } + ] + } + ] +} +``` + +### Example 2: List Result + +```json +{ + "version": "1.0", + "schemaType": "tool_result", + "status": "success", + "meta": { "toolId": "search.documents" }, + "nodes": [ + { "type": "text", "content": "找到 **3** 个相关文档" }, + { + "id": "node_list_1", + "type": "list", + "items": [ + { + "id": "item_1", + "title": "API 设计规范", + "subtitle": "v2.1", + "icon": { "source": "emoji", "value": "📄" }, + "actions": [ + { "id": "a1", "label": "查看", "action": { "type": "url", "url": "/docs/api" } } + ] + } + ], + "pagination": { "page": 1, "pageSize": 10, "total": 3, "hasMore": false } + } + ] +} +``` + +### Example 3: Error Result + +```json +{ + "version": "1.0", + "schemaType": "tool_result", + "status": "error", + "meta": { "toolId": "user.delete" }, + "nodes": [ + { + "id": "node_error_1", + "type": "error", + "title": "删除用户失败", + "icon": { "source": "icon", "value": "error_outline" }, + "errorCode": "PERMISSION_DENIED", + "message": "您没有权限执行此操作", + "details": "需要管理员权限", + "retryable": true, + "suggestions": ["联系管理员", "联系技术支持"], + "retry": { + "id": "action_retry", + "label": "重试", + "style": "primary", + "action": { "type": "tool", "toolId": "user.delete", "params": { "userId": "u1" } } + } + } + ] +} +``` + +--- + +## Evolution (v2) + +Reserved for future: + +- Nested containers: `tabs`, `accordion`, `carousel` +- Data visualization: `chart`, `metric`, `progress` +- Rich media: `image`, `video`, `audio`, `file` +- Internationalization: `i18nKey` field +- Version migration: `deprecated` flag +- Offline support: `offline` flag diff --git a/docs/runtime/runtime-database.md b/docs/runtime/runtime-database.md deleted file mode 100644 index 4731bb1..0000000 --- a/docs/runtime/runtime-database.md +++ /dev/null @@ -1,545 +0,0 @@ -# Database Schema - -**Status:** Active -**Last Updated:** 2026-03-06 - ---- - -## 架构概览 - -### 数据库层职责 - -- **Supabase**: 认证(JWT 签发与验证) -- **Backend**: 业务授权(Service 层)、数据访问(Repository 层) -- **ORM**: SQLAlchemy(async + asyncpg,使用 service_role 连接) - -### 核心模块 - -| 模块 | 路径 | 说明 | -|------|------|------| -| Base Classes | `backend/src/core/db/` | ORM 基类、Session 管理、Repository 基类 | -| Models | `backend/src/models/` | 数据模型定义 | -| Migrations | `backend/alembic/versions/` | 数据库迁移脚本 | - ---- - -## 设计约定 - -### 枚举存储 - -**所有枚举使用字符串存储,不使用整数值:** - -- Database: `VARCHAR(20)` + `CHECK` 约束 -- Code: Python `Enum` 继承 `str` - -```python -class AgentType(str, Enum): - INTENT_RECOGNITION = "INTENT_RECOGNITION" - TASK_EXECUTION = "TASK_EXECUTION" - RESULT_REPORTING = "RESULT_REPORTING" -``` - -### 软删除 - -**软删除标记数据为不可见,不级联删除:** - -- 使用 `deleted_at: datetime | None` 列(通过 `SoftDeleteMixin`) -- 查询过滤:Repository `_apply_soft_delete_filter()` 自动排除已删除记录 -- 级联策略:默认不级联,强依赖关系在 Service 层手动处理 -- 恢复策略:只恢复记录本身,关联数据通过查询自动恢复可见 -- 唯一约束:使用 partial index 排除 `deleted_at IS NOT NULL` - -```sql --- Partial unique index in migration -CREATE UNIQUE INDEX ux_user_email -ON users(email) -WHERE deleted_at IS NULL -``` - ---- - -## 表清单 - -| 表名 | 说明 | 状态 | -|------|------|------| -| `profiles` | 用户资料(含 settings JSONB) | Active | -| `memories` | 用户记忆 | Active | -| `friendships` | 好友关系 | Active | -| `groups` | 群组 | Active | -| `group_members` | 群组成员 | Active | -| `schedule_items` | 日程事项 | Active | -| `schedule_subscriptions` | 日程订阅与权限 | Active | -| `inbox_messages` | 待处理消息 | Active | -| `todos` | 待办 | Active | -| `todo_sources` | 待办与日程来源关联 | Active | -| `automation_jobs` | 定时任务 | Active | -| `agent_chat_sessions` | Agent 对话会话 | Active | -| `agent_chat_messages` | 会话消息记录 | Active | -| `llm_factory` | LLM 工厂配置 | Active | -| `llms` | LLM 模型实例 | Active | -| `system_agents` | 系统级 Agent 配置 | Active | -| `invite_codes` | 邀请码 | Active | - ---- - -## 表结构详细 - -### profiles - -用户资料表,含用户设置。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK, FK → auth.users.id | 用户 ID | -| `username` | VARCHAR(30) | UNIQUE, NOT NULL | 用户名 | -| `avatar_url` | TEXT | NULLABLE | 头像 URL | -| `bio` | VARCHAR(200) | NULLABLE | 个人简介 | -| `settings` | JSONB | NOT NULL, DEFAULT '{}' | 用户设置 | -| `referred_by` | UUID | NULLABLE, FK → profiles.id | 邀请人 ID | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - -**settings JSONB 结构:** -```json -{ - "version": 1, - "preferences": { - "interface_language": "zh-CN", - "ai_language": "zh-CN", - "timezone": "Asia/Shanghai" - }, - "agent_prompts": { - "INTENT_RECOGNITION": "自定义提示词...", - "TASK_EXECUTION": "自定义提示词..." - }, - "privacy": {}, - "notification": {} -} -``` - ---- - -### memories - -用户记忆。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 记忆 ID | -| `owner_id` | UUID | NOT NULL, FK → profiles.id | 所有者 ID | -| `memory_type` | VARCHAR(20) | NOT NULL, CHECK | 枚举:`user`, `work` | -| `title` | VARCHAR(255) | NOT NULL | 标题 | -| `content` | JSONB | NOT NULL | 记忆内容 | -| `source` | VARCHAR(20) | NOT NULL | 来源:`manual`, `agent`, `imported` | -| `status` | VARCHAR(20) | NOT NULL | 状态:`active`, `disabled` | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | - -**content JSONB 示例:** -```json -// 用户记忆 -{"type": "preference", "data": {"style": "concise", "language": "zh-CN"}} - -// 工作记忆 -{"type": "workflow_summary", "data": {"task": "代码审查", "learnings": ["优先检查安全漏洞"], "improvements": []}} -``` - ---- - -### friendships - -好友关系(双向规范化)。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 关系 ID | -| `user_low_id` | UUID | NOT NULL | 较小 UUID | -| `user_high_id` | UUID | NOT NULL | 较大 UUID | -| `initiator_id` | UUID | NOT NULL | 发起方用户 ID | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`pending`, `accepted`, `blocked`, `declined`, `canceled` | -| `requested_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 请求时间 | -| `accepted_at` | TIMESTAMPTZ | NULLABLE | 接受时间 | -| `blocked_by` | UUID | NULLABLE | 阻止者用户 ID | -| `created_by` | UUID | NULLABLE | 创建者 | -| `updated_by` | UUID | NULLABLE | 更新者 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - -**约束:** -- `user_low_id < user_high_id` -- `UNIQUE(user_low_id, user_high_id)` - ---- - -### groups - -群组。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 群组 ID | -| `name` | VARCHAR(100) | NOT NULL | 群组名称 | -| `description` | TEXT | NULLABLE | 群组描述 | -| `owner_id` | UUID | NOT NULL, FK → profiles.id | 创建者 ID | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`active`, `archived` | -| `created_by` | UUID | NULLABLE | 创建者 | -| `updated_by` | UUID | NULLABLE | 更新者 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - ---- - -### group_members - -群组成员。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 成员 ID | -| `group_id` | UUID | NOT NULL, FK → groups.id | 群组 ID | -| `user_id` | UUID | NOT NULL, FK → profiles.id | 用户 ID | -| `role` | VARCHAR(20) | NOT NULL, CHECK | 角色:`owner`, `admin`, `member` | -| `join_source` | VARCHAR(20) | NOT NULL | 加入方式:`invited`, `joined` | -| `invited_by` | UUID | NULLABLE, FK → profiles.id | 邀请人 ID | -| `joined_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 加入时间 | -| `removed_at` | TIMESTAMPTZ | NULLABLE | 移除时间 | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`active`, `muted`, `removed` | -| `created_by` | UUID | NULLABLE | 创建者 | -| `updated_by` | UUID | NULLABLE | 更新者 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - -**约束:** `UNIQUE(group_id, user_id)` - ---- - -### schedule_items - -日程事项。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 事项 ID | -| `owner_id` | UUID | NOT NULL, FK → profiles.id | 所有者 ID | -| `title` | VARCHAR(255) | NOT NULL | 标题 | -| `description` | TEXT | NULLABLE | 描述 | -| `start_at` | TIMESTAMPTZ | NOT NULL | 开始时间 | -| `end_at` | TIMESTAMPTZ | NULLABLE | 结束时间 | -| `timezone` | VARCHAR(50) | NOT NULL, DEFAULT 'UTC' | 时区 | -| `metadata` | JSONB | NOT NULL, DEFAULT '{}' | 扩展字段 | -| `recurrence_rule` | VARCHAR(255) | NULLABLE | 循环规则 | -| `source_type` | VARCHAR(20) | NOT NULL, CHECK | 来源:`manual`, `imported`, `agent_generated` | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`active`, `completed`, `canceled`, `archived` | -| `created_by` | UUID | NULLABLE | 创建者 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - -**metadata JSONB 结构:** -```json -{ - "color": "#FF6B6B", - "location": "会议室A", - "notes": "记得提前准备投影仪", - "attachments": [ - { - "name": "会议纪要.pdf", - "url": "https://...", - "visible_to": [], - "type": "document" - }, - { - "name": "投影仪提醒", - "visible_to": ["uuid1"], - "type": "reminder", - "content": "记得带投影仪" - } - ], - "version": 1 -} -``` - ---- - -### schedule_subscriptions - -日程订阅与权限。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 订阅 ID | -| `item_id` | UUID | NOT NULL, FK → schedule_items.id | 日程事项 ID | -| `subscriber_id` | UUID | NOT NULL, FK → profiles.id | 订阅者 ID | -| `permission` | INTEGER | NOT NULL, CHECK, DEFAULT 1 | 权限位图(view=1, invite=2, edit=4) | -| `notify_level` | VARCHAR(20) | NOT NULL, DEFAULT 'all' | 通知级别:`all`, `mentions`, `none` | -| `status` | VARCHAR(20) | NOT NULL, DEFAULT 'active' | 状态:`active`, `paused`, `unsubscribed` | -| `created_by` | UUID | NULLABLE | 创建者 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | - -**约束:** -- `UNIQUE(item_id, subscriber_id)` -- `permission BETWEEN 0 AND 7` - ---- - -### inbox_messages - -待处理消息(接收者视角)。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 消息 ID | -| `recipient_id` | UUID | NOT NULL, FK → profiles.id | 接收者 ID | -| `sender_id` | UUID | NULLABLE, FK → profiles.id | 发送者 ID(系统消息可为 NULL) | -| `message_type` | VARCHAR(20) | NOT NULL, CHECK | 类型:`friend_request`, `calendar`, `system`, `group` | -| `friendship_id` | UUID | NULLABLE, FK → friendships.id | 好友请求关联(friend_request 时必填) | -| `schedule_item_id` | UUID | NULLABLE, FK → schedule_items.id | 日程关联(calendar 时必填) | -| `group_id` | UUID | NULLABLE, FK → groups.id | 群组关联(group 时必填) | -| `content` | TEXT | NULLABLE | 消息内容(system 用) | -| `is_read` | BOOLEAN | NOT NULL, DEFAULT false | 是否已读 | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`pending`, `accepted`, `rejected`, `dismissed` | -| `created_by` | UUID | NULLABLE | 创建者 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | - -**message_type 与业务字段对应:** -| message_type | 必填字段 | -|--------------|----------| -| friend_request | friendship_id | -| calendar | schedule_item_id | -| system | 全部可空 | -| group | group_id | - -**sender 约束:** system 类型 sender_id 为空,其他类型 sender_id 必填 - ---- - -### todos - -待办事项。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 待办 ID | -| `owner_id` | UUID | NOT NULL, FK → profiles.id | 所有者 ID | -| `title` | VARCHAR(255) | NOT NULL | 标题 | -| `description` | VARCHAR(1000) | NULLABLE | 描述 | -| `due_at` | TIMESTAMPTZ | NULLABLE | 截止时间 | -| `priority` | INTEGER | NOT NULL, CHECK, DEFAULT 3 | 优先级(1-4,1=重要且紧急) | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`pending`, `done`, `canceled` | -| `completed_at` | TIMESTAMPTZ | NULLABLE | 完成时间 | -| `created_by` | UUID | NULLABLE | 创建者 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - -**约束:** `priority BETWEEN 1 AND 4` - ---- - -### todo_sources - -待办与日程来源关联。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 关联 ID | -| `todo_id` | UUID | NOT NULL, FK → todos.id ON DELETE CASCADE | 待办 ID | -| `schedule_item_id` | UUID | NOT NULL, FK → schedule_items.id ON DELETE CASCADE | 日程事项 ID | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | - -**约束:** `UNIQUE(todo_id, schedule_item_id)` - ---- - -### automation_jobs - -自动化定时任务。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 任务 ID | -| `owner_id` | UUID | NOT NULL, FK → profiles.id | 所有者 ID | -| `title` | VARCHAR(255) | NOT NULL | 任务标题 | -| `prompt` | TEXT | NOT NULL | AI 执行 prompt | -| `schedule_type` | VARCHAR(20) | NOT NULL, CHECK | 调度类型:`daily`, `weekly` | -| `run_at` | TIMESTAMPTZ | NOT NULL | 首次运行时间 | -| `next_run_at` | TIMESTAMPTZ | NULLABLE | 下次运行时间 | -| `timezone` | VARCHAR(50) | NOT NULL, DEFAULT 'UTC' | 时区 | -| `last_run_at` | TIMESTAMPTZ | NULLABLE | 最近运行时间 | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`active`, `disabled` | -| `created_by` | UUID | NULLABLE | 创建者 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - -**约束:** `UNIQUE(id, owner_id)` - ---- - -### agent_chat_sessions - -Agent 对话会话。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 会话 ID | -| `user_id` | UUID | NOT NULL, FK → profiles.id | 用户 ID | -| `session_type` | VARCHAR(20) | NOT NULL, CHECK | 会话类型:`chat`, `automation` | -| `job_id` | UUID | NULLABLE, FK → automation_jobs.id ON DELETE RESTRICT | 自动化任务 ID(automation 时必填) | -| `title` | VARCHAR(255) | NULLABLE | 会话标题 | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`pending`, `running`, `completed`, `failed` | -| `last_activity_at` | TIMESTAMPTZ | NULLABLE | 最后活跃时间 | -| `message_count` | INTEGER | NOT NULL, DEFAULT 0 | 消息计数 | -| `total_tokens` | INTEGER | NOT NULL, DEFAULT 0 | 总 token 数 | -| `total_cost` | NUMERIC(12,6) | NOT NULL, DEFAULT 0 | 总费用 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - -**约束:** -- `session_type='chat' → job_id IS NULL` -- `session_type='automation' → job_id IS NOT NULL` - ---- - -### agent_chat_messages - -会话消息记录。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 消息 ID | -| `session_id` | UUID | NOT NULL, FK → agent_chat_sessions.id | 会话 ID | -| `seq` | INTEGER | NOT NULL | 消息序号 | -| `role` | VARCHAR(20) | NOT NULL, CHECK | 角色:`user`, `assistant`, `system`, `tool` | -| `content` | TEXT | NOT NULL | 消息内容 | -| `model_code` | VARCHAR(50) | NULLABLE | 模型标识 | -| `tool_name` | VARCHAR(100) | NULLABLE | 工具名称 | -| `input_tokens` | INTEGER | NOT NULL, DEFAULT 0 | 输入 token 数 | -| `output_tokens` | INTEGER | NOT NULL, DEFAULT 0 | 输出 token 数 | -| `cost` | NUMERIC(12,6) | NOT NULL, DEFAULT 0 | 费用 | -| `currency` | VARCHAR(3) | NOT NULL, DEFAULT 'USD' | 货币 | -| `latency_ms` | INTEGER | NULLABLE | 延迟(毫秒) | -| `metadata` | JSONB | NOT NULL, DEFAULT '{}' | 扩展字段 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - -**约束:** `UNIQUE(session_id, seq)` - ---- - -### llm_factory - -LLM 工厂配置。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 工厂 ID | -| `name` | VARCHAR(50) | UNIQUE, NOT NULL | 工厂名称 | -| `request_url` | VARCHAR(255) | NOT NULL | API 请求 URL | -| `avatar` | TEXT | NULLABLE | 头像 URL | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - ---- - -### llms - -LLM 模型实例。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 模型 ID | -| `factory_id` | UUID | NOT NULL, FK → llm_factory.id ON DELETE RESTRICT | 工厂 ID | -| `model_code` | VARCHAR(50) | UNIQUE, NOT NULL | 模型标识 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | -| `deleted_at` | TIMESTAMPTZ | NULLABLE | 软删时间 | - ---- - -### system_agents - -系统级 Agent 配置(原 user_agent_catalog)。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `agent_type` | VARCHAR(20) | PK, CHECK | Agent 类型 | -| `llm_id` | UUID | NOT NULL, FK → llms.id ON DELETE RESTRICT | 关联的 LLM 模型 | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`active`, `paused`, `migrating` | -| `config` | JSONB | NOT NULL, DEFAULT '{}' | Agent 配置参数 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | - -**agent_type 枚举值:** -- `INTENT_RECOGNITION` - 意图识别 -- `TASK_EXECUTION` - 任务执行 -- `RESULT_REPORTING` - 结果报告 - ---- - -### invite_codes - -邀请码。 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| `id` | UUID | PK | 邀请码 ID | -| `code` | VARCHAR(8) | UNIQUE, NOT NULL, CHECK | 邀请码(8 位大写字母数字) | -| `owner_id` | UUID | NOT NULL, FK → profiles.id | 拥有者 ID | -| `status` | VARCHAR(20) | NOT NULL, CHECK | 状态:`active`, `disabled`, `expired` | -| `used_count` | INTEGER | NOT NULL, DEFAULT 0, CHECK | 已使用次数 | -| `max_uses` | INTEGER | NULLABLE | 最大使用次数 | -| `expires_at` | TIMESTAMPTZ | NULLABLE | 过期时间 | -| `reward_config` | JSONB | NULLABLE | 奖励配置 | -| `created_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 创建时间 | -| `updated_at` | TIMESTAMPTZ | NOT NULL, DEFAULT now() | 更新时间 | - -**约束:** -- `code` 符合 `[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}` -- `used_count >= 0` - ---- - -## 外键删除策略 - -| 外键 | 删除策略 | 说明 | -|------|----------|------| -| `agent_chat_sessions.job_id` | RESTRICT | 禁止删除正在使用的自动化任务 | -| `todo_sources.todo_id` | CASCADE | 删除待办时级联删除关联 | -| `todo_sources.schedule_item_id` | CASCADE | 删除日程时级联删除关联 | -| `inbox_messages.friendship_id` | CASCADE | 删除好友关系时级联删除消息 | -| `inbox_messages.schedule_item_id` | CASCADE | 删除日程时级联删除消息 | -| `inbox_messages.group_id` | CASCADE | 删除群组时级联删除消息 | -| `llms.factory_id` | RESTRICT | 禁止删除正在使用的工厂配置 | -| `system_agents.llm_id` | RESTRICT | 禁止删除正在使用的 LLM 模型 | - ---- - -## RLS 策略 - -所有 `public` 业务表默认启用 RLS: -- `anon`: 全部 DENY -- `authenticated`: 全部 DENY -- `service_role`: 由后端服务连接,不依赖 RLS - -**例外:** 迁移表 `alembic_version` 不暴露给任何角色。 - ---- - -## Change Log - -| 日期 | 变更 | -|------|------| -| 2026-03-06 | 删除 `user_agents` 表,重命名 `user_agent_catalog` → `system_agents`,更新 `agent_chat_sessions` / `agent_chat_messages` 表名,删除 `memories.agent_id` 字段 | -| 2026-02-28 | 新增 `invite_codes` 表、`profiles.referred_by` 字段 | -| 2026-02-26 | 初始版本,基于数据模型重设计 | diff --git a/docs/runtime/runtime-frontend.md b/docs/runtime/runtime-frontend.md deleted file mode 100644 index 406731f..0000000 --- a/docs/runtime/runtime-frontend.md +++ /dev/null @@ -1,264 +0,0 @@ -# Frontend Runtime - -**Date:** 2026-03-06 -**Status:** Active -**Audience:** 前端开发 - ---- - -## 技术栈 - -- **Framework:** Flutter (Dart) -- **Routing:** go_router -- **State Management:** BLoC + Cubit -- **API Client:** Dio + Retrofit -- **Mock Mode:** 支持 `--dart-define=MOCK_API=true` - ---- - -## 开发环境 - -### Mock 模式 - -前端开发时可通过 `--dart-define` 切换 Mock 模式,无需后端即可运行: - -```bash -# Mock 模式(本地开发,无需后端) -flutter run --dart-define=MOCK_API=true - -# 正式模式(需要后端运行) -flutter run -``` - -### Mock 自动登录 - -Mock 模式下,启动 App 时会自动使用测试账号登录并跳转到首页。 - -**测试账号(Mock):** - -| 场景 | 邮箱 | 密码 | 说明 | -|------|------|------|------| -| 正常登录 | 任意非 error@test.com | 任意 | 登录成功 | -| 登录失败 | error@test.com | 任意 | 返回 401 | - -**验证码:** 任意 6 位数字(建议使用 `123456`) - ---- - -## 路由结构 - -### 认证路由(无需登录) - -| 路由 | 页面 | 说明 | -|------|------|------| -| `/` | LoginScreen | 登录页(默认首页) | -| `/register` | RegisterScreen | 注册页 | -| `/register/verification` | RegisterVerificationScreen | 注册验证码页 | -| `/reset-password` | ResetPasswordScreen | 重置密码页 | - -### 受保护路由(需要登录) - -| 路由 | 页面 | 说明 | -|------|------|------| -| `/home` | HomeScreen | 首页(AI 助手) | -| `/contacts` | ContactsScreen | 通讯录 | -| `/contacts/add` | AddContactScreen | 添加联系人 | -| `/calendar/month` | CalendarMonthScreen | 月视图 | -| `/calendar/dayweek` | CalendarDayweekScreen | 日/周视图 | -| `/calendar/events/:id` | CalendarEventDetailScreen | 日程详情 | -| `/todo` | TodoQuadrantsScreen | 待办四象限 | -| `/messages/invites` | MessageInviteListScreen | 消息邀请列表 | -| `/messages/invites/:id` | MessageInviteDetailScreen | 消息邀请详情 | -| `/settings` | SettingsScreen | 设置首页 | -| `/settings/features` | FeaturesScreen | 功能开关 | -| `/settings/memory` | MemoryScreen | 记忆管理 | -| `/settings/account` | AccountScreen | 账号设置 | - ---- - -## 功能模块 - -### Auth(认证) - -**路径:** `apps/lib/features/auth/` - -| 文件 | 说明 | -|------|------| -| `presentation/bloc/auth_bloc.dart` | 认证状态管理 | -| `presentation/cubits/` | 登录/注册/重置密码 Cubit | -| `ui/screens/` | 认证相关页面 | -| `data/repositories/auth_repository.dart` | 认证 API 调用 | - -**流程:** -1. 注册: `/register` → 输入邮箱/用户名/密码 → `/register/verification` → 输入验证码 → `/home` -2. 登录: `/` → 输入邮箱/密码 → `/home` -3. 重置密码: `/reset-password` → 输入邮箱 → 收到邮件 → 输入验证码+新密码 → `/` - ---- - -### Home(首页/AI 助手) - -**路径:** `apps/lib/features/home/` - -| 文件 | 说明 | -|------|------| -| `ui/screens/home_screen.dart` | 首页(AI 助手入口) | -| `ui/screens/home_sheet.dart` | 首页底部弹出面板 | - -**功能:** -- AI 助手对话入口 -- 快速访问常用功能 - ---- - -### Calendar(日历) - -**路径:** `apps/lib/features/calendar/` - -| 文件 | 说明 | -|------|------| -| `ui/screens/calendar_month_screen.dart` | 月视图 | -| `ui/screens/calendar_dayweek_screen.dart` | 日/周视图 | -| `ui/screens/calendar_event_detail_screen.dart` | 日程详情 | -| `ui/calendar_time_utils.dart` | 时间工具函数 | - -**功能:** -- 月/日/周三视图切换 -- 日程创建/编辑/删除 -- 日程分享 - ---- - -### Todo(待办) - -**路径:** `apps/lib/features/todo/` - -| 文件 | 说明 | -|------|------| -| `ui/screens/todo_quadrants_screen.dart` | 四象限视图 | -| `ui/screens/todo_detail_screen.dart` | 待办详情 | - -**功能:** -- 四象限管理(重要/紧急矩阵) -- 待办创建/编辑/完成/删除 - ---- - -### Contacts(通讯录) - -**路径:** `apps/lib/features/contacts/` - -| 文件 | 说明 | -|------|------| -| `ui/screens/contacts_screen.dart` | 通讯录列表 | -| `ui/screens/add_contact_screen.dart` | 添加联系人 | - -**功能:** -- 好友列表 -- 搜索用户 -- 添加好友 - ---- - -### Messages(消息) - -**路径:** `apps/lib/features/messages/` - -| 文件 | 说明 | -|------|------| -| `ui/screens/message_invite_list_screen.dart` | 邀请消息列表 | -| `ui/screens/message_invite_detail_screen.dart` | 邀请详情 | - -**功能:** -- 日程邀请通知 -- 好友请求通知 -- 群组邀请通知 - ---- - -### Settings(设置) - -**路径:** `apps/lib/features/settings/` - -| 文件 | 说明 | -|------|------| -| `ui/screens/settings_screen.dart` | 设置首页 | -| `ui/screens/features_screen.dart` | 功能开关 | -| `ui/screens/memory_screen.dart` | 记忆管理 | -| `ui/screens/account_screen.dart` | 账号设置 | - -**功能:** -- 个人资料编辑 -- 记忆管理(用户/工作记忆) -- 功能开关 -- 账号安全设置 - ---- - -## 打包构建 - -### Debug Build - -```bash -# Mock 模式 -flutter build apk --debug --dart-define=MOCK_API=true - -# 正式模式 -flutter build apk --debug -``` - -### Release Build - -Release 构建强制使用正式 API,不受 `MOCK_API` 影响: - -```bash -flutter build apk --release -``` - ---- - -## 调试运行 - -### 命令行调试 - -```bash -# Mock 模式(无需后端,自动登录) -flutter run --dart-define=MOCK_API=true -d emulator-5554 - -# 正式模式(需要后端运行) -flutter run -d emulator-5554 -``` - -### VSCode 调试配置 - -在 `.vscode/launch.json` 中添加配置: - -```json -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Mock Mode", - "request": "launch", - "type": "dart", - "args": ["--dart-define=MOCK_API=true"] - }, - { - "name": "正式模式", - "request": "launch", - "type": "dart" - } - ] -} -``` - -配置完成后,在 VSCode 左侧 Debug 面板的 dropdown 中选择 "Mock Mode" 或 "正式模式" 进行调试。 - ---- - -## Change Log - -| 日期 | 变更 | -|------|------| -| 2026-03-06 | 完善路由结构、功能模块说明,补充技术栈信息 | -| 2026-02-27 | 新增 Frontend Runbook,支持 --dart-define=MOCK_API=true 切换 Mock 模式 | diff --git a/docs/runtime/runtime-route.md b/docs/runtime/runtime-route.md deleted file mode 100644 index 3ccbfba..0000000 --- a/docs/runtime/runtime-route.md +++ /dev/null @@ -1,891 +0,0 @@ -# Runtime API Routes - -本文档记录所有 HTTP API 端点。修改路由时必须同步更新此文档。 - -## 格式说明 - -- Request/Response 使用 JSON 格式 -- 错误响应使用 RFC 7807 `application/problem+json` -- 所有端点前缀: `/api/v1` - -## Auth - -### POST /auth/verifications - -创建验证码(注册发起)。 - -**Request:** -```json -{ - "username": "string (3-30 chars)", - "email": "string (email)", - "password": "string (min 6 chars)", - "redirect_to": "string? (optional)", - "invite_code": "string? (8 chars, 排除易混淆字符 0/1/I/L/O)" -} -``` - -**Response:** 202 Accepted -```json -{ - "email": "user@example.com" -} -``` - -**邀请码说明:** -- 可选字段,不填则注册不受影响 -- 格式:8 位字母数字组合,排除易混淆字符 (0, 1, I, L, O) -- 注册时传入有效邀请码会建立邀请关系并增加邀请码使用次数 -- 无效邀请码(不存在/已禁用/已过期/已达上限)不会阻断注册成功 - -**Errors:** -- 422: 请求参数无效 -- 429: 请求过于频繁 - ---- - -### POST /auth/resend - -重发验证码(统一端点,支持注册/找回密码)。 - -**Request:** -```json -{ - "type": "signup | recovery (default: signup)", - "email": "string (email)", - "redirect_to": "string? (仅 recovery 可选)" -} -``` - -**Response:** 204 No Content - -**Errors:** -- 422: 请求参数无效 -- 429: 请求过于频繁 - ---- - -### POST /auth/verify - -验证码校验(统一端点,按 `type` 区分场景)。 - -**Request (signup):** -```json -{ - "type": "signup", - "email": "string (email)", - "token": "string (6 digits)" -} -``` - -**Response (signup):** 200 OK -```json -{ - "access_token": "string", - "refresh_token": "string", - "expires_in": 3600, - "token_type": "bearer", - "user": { - "id": "string", - "email": "string" - } -} -``` - -**Request (recovery):** -```json -{ - "type": "recovery", - "email": "string (email)", - "token": "string (6 digits)", - "new_password": "string (min 6 chars)" -} -``` - -**Response (recovery):** 204 No Content - -**Errors:** -- 401: 验证码无效或已过期 -- 422: 请求参数无效 -- 429: 请求过于频繁 - ---- - -### POST /auth/sessions - -登录(创建会话)。 - -**Request:** -```json -{ - "email": "string (email)", - "password": "string (min 6 chars)" -} -``` - -**Response:** 200 OK -```json -{ - "access_token": "string", - "refresh_token": "string", - "expires_in": 3600, - "token_type": "bearer", - "user": { - "id": "string", - "email": "string" - } -} -``` - -**Errors:** -- 401: 邮箱或密码错误 -- 422: 请求参数无效 -- 429: 请求过于频繁 - ---- - -### POST /auth/sessions/refresh - -刷新 Token。 - -**Request:** -```json -{ - "refresh_token": "string" -} -``` - -**Response:** 200 OK -```json -{ - "access_token": "string", - "refresh_token": "string", - "expires_in": 3600, - "token_type": "bearer", - "user": { - "id": "string", - "email": "string" - } -} -``` - -**Errors:** -- 401: 无效的 refresh token -- 422: 请求参数无效 -- 429: 请求过于频繁 - ---- - -### DELETE /auth/sessions - -登出(删除会话)。 - -**Request:** -```json -{ - "refresh_token": "string" -} -``` - -**Response:** 204 No Content - -**Errors:** -- 422: 请求参数无效 -- 429: 请求过于频繁 - ---- - -### GET /auth/users - -按邮箱查询用户(需要认证)。 - -**Query Parameters:** -- `email`: string (required) - -**Response:** 200 OK -```json -{ - "id": "string", - "email": "string", - "created_at": "string (ISO 8601)", - "email_confirmed_at": "string? (ISO 8601)" -} -``` - -**Errors:** -- 403: 无权限访问 -- 404: 用户不存在 -- 422: 请求参数无效 - ---- - -## Schedule Items - -### POST /schedule-items - -创建日历事项(需要认证)。 - -**Request:** -```json -{ - "title": "string (1-255 chars, required)", - "description": "string? (max 2000 chars)", - "start_at": "string (ISO 8601 datetime, required)", - "end_at": "string? (ISO 8601 datetime)", - "timezone": "string? (default: UTC)", - "metadata": { - "color": "#FF6B6B", - "location": "会议室A", - "notes": "记得带身份证", - "attachments": [], - "version": 1 - } -} -``` - -**Response:** 201 Created -```json -{ - "id": "uuid", - "title": "string", - "description": "string?", - "start_at": "string", - "end_at": "string?", - "timezone": "string", - "metadata": {}, - "status": "active", - "source_type": "manual", - "created_at": "string", - "updated_at": "string" -} -``` - -**Errors:** -- 400: end_at 早于 start_at -- 401: 未认证 -- 503: 服务不可用 - ---- - -### GET /schedule-items - -按时间范围查询日历事项列表(需要认证)。 - -**Query Parameters:** -- `start_at`: ISO 8601 date/datetime(查询范围起始) -- `end_at`: ISO 8601 date/datetime(查询范围结束) - -**Response:** 200 OK -```json -[ - { - "id": "uuid", - "title": "string", - "description": "string?", - "start_at": "string", - "end_at": "string?", - "timezone": "string", - "metadata": { - "color": "#FF6B6B", - "location": "会议室A", - "notes": "记得带身份证", - "attachments": [], - "version": 1 - }, - "status": "active", - "source_type": "manual", - "created_at": "string", - "updated_at": "string" - } -] -``` - -**Errors:** -- 400: end_at 早于 start_at -- 401: 未认证 - ---- - -### GET /schedule-items/{id} - -获取单个日历事项详情(需要认证)。 - -**Response:** 200 OK - -**Errors:** -- 401: 未认证 -- 404: 事项不存在 - ---- - -### PATCH /schedule-items/{id} - -更新日历事项(需要认证)。 - -**Request:** 支持 `title`/`description`/`start_at`/`end_at`/`timezone`/`metadata`/`status` 部分更新 - -**Response:** 200 OK - -**Errors:** -- 401: 未认证 -- 404: 事项不存在 - ---- - -### DELETE /schedule-items/{id} - -删除日历事项(软删除,需要认证)。 - -**Response:** 204 No Content - -**Errors:** -- 401: 未认证 -- 404: 事项不存在 - ---- - -### POST /schedule-items/{id}/share - -分享日历事项给他人(需要认证)。 - -通过邮箱邀请其他用户,被邀请人将收到待办消息邀请。 - -**Request:** -```json -{ - "email": "string (required, email of user to share with)", - "permission_view": "boolean (default: true)", - "permission_edit": "boolean (default: false)", - "permission_invite": "boolean (default: false)" -} -``` - -**Permission 位说明:** -| 权限 | 值 | 说明 | -|------|-----|------| -| view | 1 | 查看事项详情 | -| invite | 2 | 邀请其他人订阅此事项 | -| edit | 4 | 修改事项内容、管理订阅 | - -可组合使用,如 view+edit = 5,view+invite+edit = 7。 - -**Response:** 200 OK -```json -{ - "message": "Invitation sent to user@example.com" -} -``` - -**Errors:** -- 401: 未认证 -- 403: 非日历所有者无权分享 -- 404: 日历事项不存在或用户不存在 - ---- - -## Inbox Messages - -### GET /inbox/messages - -获取当前用户的待办消息列表(需要认证)。 - -**Query Parameters:** -- `status`: string (optional) - 过滤状态:`pending`/`accepted`/`rejected`/`dismissed` - -**Response:** 200 OK -```json -[ - { - "id": "uuid", - "recipient_id": "uuid", - "sender_id": "uuid?", - "message_type": "calendar", - "schedule_item_id": "uuid?", - "content": "string?", - "is_read": false, - "status": "pending", - "created_at": "2024-01-01T00:00:00Z" - } -] -``` - -**Errors:** -- 401: 未认证 - ---- - -## Users - -### GET /users/me - -获取当前用户信息(需要认证)。 - -**Response:** 200 OK -```json -{ - "id": "string", - "username": "string", - "avatar_url": "string?", - "bio": "string?" -} -``` - -**Errors:** -- 401: 未认证 - ---- - -### PATCH /users/me - -更新当前用户信息(需要认证)。 - -**Request:** -```json -{ - "username": "string? (3-30 chars)", - "avatar_url": "string? (URL)", - "bio": "string? (max 200 chars)" -} -``` - -**Response:** 200 OK -```json -{ - "id": "string", - "username": "string", - "avatar_url": "string?", - "bio": "string?" -} -``` - -**Errors:** -- 401: 未认证 -- 422: 请求参数无效 - ---- - -### POST /users/search - -搜索用户(需要认证)。 - -支持两种查询模式: -- **用户名查询**:模糊匹配,返回最多 20 个结果 -- **邮箱查询**:精确匹配,返回 0 或 1 个结果 - -查询类型自动识别:包含 `@` 符号视为邮箱查询。 - -**Request:** -```json -{ - "query": "string (1-100 chars)" -} -``` - -**Response:** 200 OK -```json -[ - { - "id": "string", - "username": "string", - "avatar_url": "string?", - "bio": "string?" - } -] -``` - -**Errors:** -- 401: 未认证 -- 503: Auth 服务不可用(仅邮箱查询) -- 422: 请求参数无效 - ---- - -## Friends - -### POST /friends/requests - -发送好友请求(需要认证)。 - -**Request:** -```json -{ - "target_user_id": "string (uuid)", - "content": "string? (max 500 chars)" -} -``` - -**Response:** 201 Created -```json -{ - "id": "uuid", - "from_user_id": "uuid", - "to_user_id": "uuid", - "content": "string?", - "status": "pending", - "created_at": "string (ISO 8601)", - "updated_at": "string (ISO 8601)" -} -``` - -**Errors:** -- 400: 不能添加自己为好友 -- 401: 未认证 -- 404: 目标用户不存在 -- 409: 已是好友或请求已存在 -- 422: 请求参数无效 - ---- - -### GET /friends/requests/inbox - -获取收到的好友请求(需要认证)。 - -**Response:** 200 OK -```json -[ - { - "id": "uuid", - "from_user_id": "uuid", - "to_user_id": "uuid", - "content": "string?", - "status": "pending", - "created_at": "string (ISO 8601)", - "updated_at": "string (ISO 8601)" - } -] -``` - -**Errors:** -- 401: 未认证 - ---- - -### GET /friends/requests/outgoing - -获取发出的好友请求(需要认证)。 - -**Response:** 200 OK -```json -[ - { - "id": "uuid", - "from_user_id": "uuid", - "to_user_id": "uuid", - "content": "string?", - "status": "pending", - "created_at": "string (ISO 8601)", - "updated_at": "string (ISO 8601)" - } -] -``` - -**Errors:** -- 401: 未认证 - ---- - -### POST /friends/requests/{id}/accept - -接受好友请求(需要认证)。 - -**Response:** 200 OK -```json -{ - "id": "uuid", - "from_user_id": "uuid", - "to_user_id": "uuid", - "content": "string?", - "status": "accepted", - "created_at": "string (ISO 8601)", - "updated_at": "string (ISO 8601)" -} -``` - -**Errors:** -- 401: 未认证 -- 404: 请求不存在 -- 409: 请求已被处理 - ---- - -### POST /friends/requests/{id}/decline - -拒绝好友请求(需要认证)。 - -**Response:** 200 OK -```json -{ - "id": "uuid", - "from_user_id": "uuid", - "to_user_id": "uuid", - "content": "string?", - "status": "declined", - "created_at": "string (ISO 8601)", - "updated_at": "string (ISO 8601)" -} -``` - -**Errors:** -- 401: 未认证 -- 404: 请求不存在 -- 409: 请求已被处理 - ---- - -### DELETE /friends/requests/{id} - -取消发出的好友请求(需要认证)。 - -**Response:** 204 No Content - -**Errors:** -- 401: 未认证 -- 404: 请求不存在 - ---- - -### GET /friends - -获取好友列表(需要认证)。 - -**Response:** 200 OK -```json -[ - { - "id": "uuid", - "friend_id": "uuid", - "username": "string", - "avatar_url": "string?", - "bio": "string?", - "created_at": "string (ISO 8601)" - } -] -``` - -**Errors:** -- 401: 未认证 - ---- - -### DELETE /friends/{id} - -删除好友(需要认证)。 - -**Response:** 204 No Content - -**Errors:** -- 401: 未认证 -- 404: 好友关系不存在 - ---- - -## Agent Runtime - -### POST /agent/runs - -创建一次 Agent 异步运行任务(需要认证)。 - -**Request:** -```json -{ - "threadId": "string (UUID, required)", - "runId": "string (required)", - "parentRunId": "string? (optional)", - "state": {}, - "messages": [ - { - "id": "string", - "role": "user", - "content": "string | InputContent[]" - } - ], - "tools": [], - "context": [], - "forwardedProps": {} -} -``` - -**Response:** 202 Accepted -```json -{ - "taskId": "string", - "threadId": "string", - "runId": "string", - "created": false -} -``` - -**Errors:** -- 401: 未认证 -- 403: 非会话 owner -- 422: 请求参数无效 - ---- - -### POST /agent/runs/{thread_id}/resume - -恢复一次等待工具结果的 Agent 运行(需要认证)。 - -**Request:** -```json -{ - "threadId": "string (must match path thread_id)", - "runId": "string", - "parentRunId": "string? (optional)", - "state": {}, - "messages": [ - { - "id": "string", - "role": "tool", - "toolCallId": "string", - "content": "string (JSON string, AG-UI ToolMessage content)" - } - ], - "tools": [], - "context": [], - "forwardedProps": {} -} -``` - -**Response:** 202 Accepted -```json -{ - "taskId": "string", - "threadId": "string", - "runId": "string", - "created": false -} -``` - -**Errors:** -- 401: 未认证 -- 403: 非会话 owner -- 422: 请求参数无效 - ---- - -### GET /agent/runs/{thread_id}/events - -订阅 Agent SSE 事件流(需要认证)。 - -**Headers:** -- `Last-Event-ID` (optional): 断点续传游标,格式 `^\d+-\d+$` - -**Response:** 200 OK -`Content-Type: text/event-stream` - -```text -id: 2-0 -event: RUN_STARTED -data: {"type":"RUN_STARTED","threadId":"...","runId":"..."} - -``` - -**Errors:** -- 401: 未认证 -- 403: 非会话 owner - ---- - -### GET /agent/runs/{thread_id}/history - -按“天”读取指定会话的历史快照(需要认证)。 - -**Query:** -- `before` (optional, `YYYY-MM-DD`): 读取该日期之前的最近一天 - -**Response:** 200 OK -```json -{ - "type": "STATE_SNAPSHOT", - "threadId": "string", - "snapshot": { - "scope": "history_day", - "threadId": "string", - "day": "2026-03-07", - "hasMore": true, - "messages": [] - } -} -``` - -**Errors:** -- 401: 未认证 -- 403: 非会话 owner - ---- - -### GET /agent/history - -读取当前用户历史快照(需要认证)。当未传 `threadId` 时,默认返回最近活跃会话的按天快照。 - -**Query:** -- `threadId` (optional): 指定会话 -- `before` (optional, `YYYY-MM-DD`): 读取该日期之前的最近一天 - -**Response:** 200 OK -```json -{ - "type": "STATE_SNAPSHOT", - "threadId": "string?", - "snapshot": { - "scope": "history_day", - "threadId": "string?", - "day": "2026-03-07", - "hasMore": false, - "messages": [] - } -} -``` - ---- - -## Infra - -### GET /infra/health - -检查基础设施健康状态。 - -**Response:** 200 OK -```json -{ - "status": "healthy" | "unhealthy", - "services": { - "redis": { - "status": "healthy" | "unhealthy", - "latency_ms": 0 - } - } -} -``` - ---- - -### GET /health - -检查服务健康状态。 - -**Response:** 200 OK -```json -{ - "status": "ok" -} -``` - ---- - -## Error Response Format (RFC 7807) - -所有错误响应使用 `application/problem+json` 格式: - -```json -{ - "type": "about:blank", - "title": "Unauthorized", - "status": 401, - "detail": "验证码无效或已过期", - "instance": "/api/v1/auth/verify" -} -``` - -前端应优先读取 `detail` 字段显示给用户。 diff --git a/docs/runtime/runtime-runbook.md b/docs/runtime/runtime-runbook.md deleted file mode 100644 index 7aa08bb..0000000 --- a/docs/runtime/runtime-runbook.md +++ /dev/null @@ -1,263 +0,0 @@ -# Runtime Runbook - -**Date:** 2026-02-25 -**Status:** Active -**Audience:** 运维 / 后端值班 - -## Scope & Preconditions - -本手册用于日常值班、发布前检查、故障处置与回滚。 - -### 前置条件 - -- 已配置 `.env`(仓库根目录)。 -- 主机可用:`docker`、`docker compose`、`tmux`、`uv`。 -- 已拉取最新代码并确认当前分支与目标发布版本一致。 - -### 红线规则 - -- 禁止跳过 bootstrap gate 直接启动 web/worker。 -- 迁移/初始化容器执行时必须带 `--build`,避免旧镜像导致迁移不生效。 - ---- - -## Bootstrap Gate (Mandatory) - -以下流程必须按顺序执行。 - -### Step 1: 启动基础设施 - -```bash -docker compose --env-file .env -f infra/docker/docker-compose.yml up -d redis -``` - -通过标准:`docker compose ... ps` 中 `redis` 容器为 `running`/`healthy`。 - -### Step 2: 执行迁移与初始化 - -#### 生产环境 -```bash -docker compose --env-file .env -f infra/docker/docker-compose.yml run --rm --build init-job uv run python -m core.runtime.cli bootstrap -``` - -#### 开发环境(推荐) -开发阶段推荐使用脚本,直接使用本地代码,无需构建镜像: - -```bash -bash infra/scripts/dev-migrate.sh bootstrap -``` - -可选命令: -- `bash infra/scripts/dev-migrate.sh migrate` - 仅运行迁移 -- `bash infra/scripts/dev-migrate.sh init-data` - 仅初始化数据 - -通过标准:命令退出码为 0,日志中无 migration/init-data 错误。 - -### Step 3: 版本核对(建议) - -```bash -docker compose --env-file .env -f infra/docker/docker-compose.yml exec -T db \ - psql -U postgres -d postgres -c "SELECT version_num FROM public.alembic_version;" -``` - -通过标准:返回 1 行版本号,且与发布预期版本一致。 - ---- - -## Service Start / Stop (tmux) - -### 启动应用进程 - -```bash -bash infra/scripts/app.sh start -``` - -该脚本会在 tmux `social-dev` 会话中拉起: - -- web -- worker-critical -- worker-default -- worker-bulk - -通过标准:`tmux list-windows -t social-dev` 可见上述窗口。 - -### 常用 tmux 命令 - -```bash -tmux list-windows -t social-dev -tmux attach -t social-dev -tmux kill-session -t social-dev -``` - -### 日志文件 - -| 服务 | 日志文件 | -|------|---------| -| Web | `logs/web.log`, `logs/errors/web.error.log` | -| Worker Critical | `logs/worker-critical.log`, `logs/errors/worker-critical.error.log` | -| Worker Default | `logs/worker-default.log`, `logs/errors/worker-default.error.log` | -| Worker Bulk | `logs/worker-bulk.log`, `logs/errors/worker-bulk.error.log` | - ---- - -## Operational Verification - -按优先级分层执行。 - -### L1 必跑(发布前/故障恢复后必须) - -```bash -# 先导入 .env,确保端口与配置一致 -set -a -. ./.env -set +a - -WEB_BASE_URL="http://127.0.0.1:${SOCIAL_WEB__PORT:-5775}" - -# 基础健康(redis/web;数据库使用云 Supabase Postgres) -docker compose --env-file .env -f infra/docker/docker-compose.yml exec -T redis \ - sh -lc 'if [ -n "${REDIS_PASSWORD:-}" ]; then redis-cli -a "${REDIS_PASSWORD}" ping; else redis-cli ping; fi' - -curl -fsS "${WEB_BASE_URL}/health" - -# compose 状态 -docker compose --env-file .env -f infra/docker/docker-compose.yml ps - -# 核心接口 smoke -curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/sessions" \ - -H 'Content-Type: application/json' \ - -d '{"email":"demo@example.com","password":"secret123"}' -``` - -通过标准:redis 健康检查成功,web `/health` 返回 2xx,容器 `running`,核心接口返回预期业务状态码。 - -### L2 可选(Auth/Profile 业务回归) - -```bash -# signup start -curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/verifications" \ - -H 'Content-Type: application/json' \ - -d '{"username":"demo","email":"demo@example.com","password":"secret123"}' - -# signup verify -curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/verify" \ - -H 'Content-Type: application/json' \ - -d '{"type":"signup","email":"demo@example.com","token":"123456"}' - -# signup resend -curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/resend" \ - -H 'Content-Type: application/json' \ - -d '{"type":"signup","email":"demo@example.com"}' -``` - -通过标准:接口返回符合预期的 2xx 或受控业务错误,无 5xx。 - -## Incident Playbook - -### 1) 迁移未生效(常见于旧镜像) - -- 症状:字段/表结构与代码不一致,接口报 schema 错误。 -- 定位:检查 `alembic_version` 与容器镜像构建时间。 -- 修复:重新执行 `init-job --build`,并复核版本号。 - -### 2) Worker 不消费任务 - -- 症状:队列堆积,任务长时间 pending。 -- 定位:检查 `worker-*` tmux 窗口和对应日志文件。 -- 修复:重启 tmux 会话,确认并发配置与队列名(critical/default/bulk)。 - - 说明:Taskiq 路径当前仅消费 `SOCIAL_WORKER__GROUPS__*__CONCURRENCY`,旧 Celery 参数(prefetch/time_limit 等)已废弃。 - -### 2.1) Agent Runtime run/resume 事件不闭环 - -- 症状:`POST /api/v1/agent/runs` 返回 202,但前端事件流没有 `RUN_FINISHED`。 -- 定位步骤: - -```bash -# 1) 检查 taskiq worker 是否消费 agent 任务 -grep -E "tasks\.agent\.run_command|RUN_STARTED|RUN_FINISHED|RUN_ERROR" logs/worker-default.log - -# 2) 检查 API SSE 事件读取(带 Last-Event-ID) -curl -N "${WEB_BASE_URL}/api/v1/agent/runs//events" \ - -H "Authorization: Bearer " \ - -H "Last-Event-ID: 1-0" - -# 3) 检查 Redis 连通(必要时) -docker compose --env-file .env -f infra/docker/docker-compose.yml exec -T redis redis-cli ping -``` - -- 修复建议: - - 若 worker 无消费:重启 `worker-default` 窗口并确认 `core.agent.infrastructure.queue.tasks` 已被 Taskiq worker 加载。 - - 若 worker 有事件但 API 无输出:排查 Redis stream 前缀配置与 session_id 是否一致。 - - 若出现 `RUN_ERROR`:按 error_id 回查后端日志,不在 API/SSE 中暴露敏感上下文。 - -### 3) JWT 或认证异常 - -- 症状:接口持续 401/403。 -- 定位:核对 `.env` 中 Supabase JWT 配置与签发方设置。 -- 修复:修正配置后重启 web 进程并执行 L1/L2 验证。 - -### 4) 基础设施容器异常(db/redis) - -- 症状:web 启动失败、迁移失败、任务队列连接报错。 -- 定位: - -```bash -docker compose --env-file .env -f infra/docker/docker-compose.yml ps -docker compose --env-file .env -f infra/docker/docker-compose.yml logs db --tail=100 -docker compose --env-file .env -f infra/docker/docker-compose.yml logs redis --tail=100 -``` - -- 修复:按依赖顺序重建基础设施后重新 bootstrap。 - -```bash -docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force-recreate redis -bash infra/scripts/dev-migrate.sh bootstrap -``` - -- 复核标准:`redis` 健康检查通过,L1 核心接口 smoke 无 5xx。 - ---- - -## Rollback Procedure - -### 回滚前检查 - -- 确认目标回滚提交或版本号。 -- 确认是否涉及不可逆数据变更。 - -### 回滚执行 - -1. 停止应用进程:`tmux kill-session -t social-dev` -2. 切换代码到目标版本。 -3. 按目标版本要求执行迁移回滚(如有)。 -4. 重新执行 bootstrap gate 与 service 启动。 - -### 回滚后复核 - -- 执行 L1 必跑检查。 -- 记录回滚原因、时间、影响范围和后续修复计划。 - ---- - -## Change Log - -| 日期 | 变更 | -|------|------| -| 2026-02-24 | 创建运行时手册,删除 legacy 脚本,统一使用 gunicorn | -| 2026-02-24 | 清理配置:合并 AppSettings 到 WebSettings,删除 Worker 旧配置 (enabled_queues/queues),统一使用 SOCIAL_WEB__GUNICORN__* 命名 | -| 2026-02-24 | 开发阶段 compose 暂不编排 web/worker,仅保留 redis/db 与 init-job | -| 2026-02-24 | 新增 dev-app-up 脚本:手动基础设施后,一键 bootstrap + tmux 拉起 web/worker | -| 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 | -| 2026-02-25 | Auth 注册切换为 OTP 三段式:signup/start、signup/verify、signup/resend;邮件模板改为纯验证码展示 | -| 2026-02-25 | 清理未使用配置类:删除 WebSettings/GunicornSettings/WorkerSettings/WorkerGroupSettings(脚本仍使用环境变量启动服务) | -| 2026-03-04 | Agent 运行时进入硬切重构:移除旧 Agent Chat 验证章节,待新方案落地后补充 | -| 2026-02-25 | 简化启动方式:dev-app-up -> app-up,分离 bootstrap 与服务启动 | -| 2026-02-25 | 重构为运维分层手册:Bootstrap Gate、分层验证、故障与回滚流程 | -| 2026-02-25 | 新增配置漂移故障条目:修复 Auth 邮件模板失效与 signup 超时场景 | -| 2026-02-27 | 用户搜索支持邮箱精确匹配:query 含 @ 符号时走 auth.users → profiles 两步查询 | -| 2026-02-28 | 邀请码功能:新增 invite_codes 表、profiles.referred_by,注册时可选填邀请码并记录邀请关系 | -| 2026-03-02 | 文档整理:修正 auth 端点名称(/verifications)、补充 profile 路由文档、修复 L2/L3 验证命令 | -| 2026-03-02 | 修正 bootstrap 命令:init-job 需要使用 `uv run python -m core.runtime.cli bootstrap` | -| 2026-03-05 | 新增 Agent Runtime run/resume/events 运维排障流程(Taskiq + Redis + Last-Event-ID) | -| 2026-03-06 | Web 启动从 gunicorn 迁移为纯 uvicorn,移除 `SOCIAL_WEB__GUNICORN__*` 配置,统一使用 `SOCIAL_WEB__WORKERS` | -| 2026-03-09 | 清理本地 Supabase 依赖描述:基础设施启动与巡检统一为 redis/db/web |