From f4b7eb7e09f81d09db41a33fa1f24164224d4d8d Mon Sep 17 00:00:00 2001 From: qzl Date: Mon, 23 Mar 2026 17:57:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=A2=B3=E7=90=86=E8=A7=84?= =?UTF-8?q?=E5=88=99=E4=BD=93=E7=B3=BB=E5=B9=B6=E7=BB=9F=E4=B8=80=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E4=B8=8E=E9=83=A8=E7=BD=B2=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 70 +-- apps/AGENTS.md | 235 ++------ apps/android/app/src/main/AndroidManifest.xml | 2 +- apps/lib/core/router/app_router.dart | 14 +- apps/lib/core/router/app_routes.dart | 2 + .../settings/ui/screens/memory_screen.dart | 37 +- .../ui/screens/user_memory_detail_screen.dart | 15 +- .../ui/screens/user_memory_view_screen.dart | 554 ++++++++++++++++++ .../ui/screens/work_memory_detail_screen.dart | 15 +- .../ui/screens/work_memory_view_screen.dart | 522 +++++++++++++++++ .../ui/widgets/settings_page_scaffold.dart | 8 +- apps/pubspec.yaml | 2 +- backend/AGENTS.md | 327 ++--------- backend/src/core/agentscope/events/store.py | 99 +++- backend/src/core/agentscope/runtime/runner.py | 38 +- .../core/agentscope/tools/custom/calendar.py | 209 +++---- .../core/agentscope/tools/custom/memory.py | 374 ++++++++---- backend/src/core/automation/scheduler.py | 4 +- .../static/automation/memory_extraction.yaml | 9 +- .../config/static/database/llm_catalog.yaml | 11 + backend/src/models/memories.py | 4 - backend/src/schemas/automation/__init__.py | 4 + backend/src/schemas/memories/__init__.py | 1 - backend/src/v1/agent/router.py | 3 +- backend/src/v1/automation_jobs/schemas.py | 77 +++ .../unit/core/agentscope/events/test_store.py | 74 ++- .../core/agentscope/runtime/test_runner.py | 33 ++ .../core/agentscope/test_calendar_tools.py | 118 +++- .../unit/core/agentscope/test_memory_tools.py | 71 ++- deploy/.env.prod.example | 19 +- deploy/README.md | 4 +- deploy/docker-compose.prod.yml | 10 +- deploy/static/releases/manifest.json | 11 + .../2026-03-23-memories-ui-implementation.md | 231 -------- docs/plans/2026-03-23-memory-system-design.md | 78 --- docs/plans/visibility-mask-restructure.md | 151 ----- ...03-17-worker-token-latency-optimization.md | 78 --- .../calendar-reminder-migration-checklist.md | 27 - infra/scripts/app.sh | 4 +- 39 files changed, 2091 insertions(+), 1454 deletions(-) create mode 100644 apps/lib/features/settings/ui/screens/user_memory_view_screen.dart create mode 100644 apps/lib/features/settings/ui/screens/work_memory_view_screen.dart create mode 100644 backend/src/v1/automation_jobs/schemas.py delete mode 100644 docs/plans/2026-03-23-memories-ui-implementation.md delete mode 100644 docs/plans/2026-03-23-memory-system-design.md delete mode 100644 docs/plans/visibility-mask-restructure.md delete mode 100644 docs/todo/2026-03-17-worker-token-latency-optimization.md delete mode 100644 docs/todo/calendar-reminder-migration-checklist.md diff --git a/AGENTS.md b/AGENTS.md index ea97a7c..9fc92f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,62 +1,38 @@ # Project AGENTS Router -Root `AGENTS.md` is a navigation and global-constraint layer only. +Root `AGENTS.md` is routing + cross-domain policy only. Do not place backend/frontend implementation details here. +## Scope + +- Applies to repository root and cross-domain tasks. +- Subdomain rules: `backend/AGENTS.md`, `apps/AGENTS.md`. +- If rules conflict, use the stricter one. + ## Rule Order -Apply rules in this order: - -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` - -If two rules conflict, use the stricter one. +1. System / developer / platform safety instructions +2. Workspace runtime rules (`AGENTS.md` + `rules/*`) +3. This file (routing + project-level constraints) +4. Subdomain rules (backend/apps) ## Mandatory Routing -- 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 Mapping - -| 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` | +- `backend/**` must follow `backend/AGENTS.md`. +- `apps/**` must follow `apps/AGENTS.md`. +- Cross-domain changes must satisfy all relevant subdomain rules. +- `infra/**` follows this file plus `infra/` conventions. ## Project-Wide Constraints -- 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 development branch is `dev`; do not develop directly on `main`. +- Never push unless explicitly requested by the user. +- Keep AGENTS layered and lean: shared rules at root, domain rules in sub-AGENTS. -## Protocol as Source of Truth +## Protocol Source of Truth -`docs/protocols/` is the single source of truth for data formats and protocols. +`docs/protocols/` is the single source of truth for protocol and data format. -- All data schemas, API contracts, and UI schema definitions MUST be documented in `docs/protocols/`. -- Frontend and backend implementations MUST conform to the protocols defined in `docs/protocols/`. -- Before modifying any data format or protocol: - 1. Update the corresponding document in `docs/protocols/` first - 2. Verify the change is backward compatible or plan migration - 3. Then implement the code changes - -This ensures frontend and backend stay synchronized and prevents drift. - -## Skills Index - -- `ag-ui`: AG-UI protocol implementation guidance. -- `agentscope-skill`: AgentScope framework guidance. -- `ui-ux-pro-max`: APP UI design guidelines. - -Skill invocation and process routing are governed by workspace runtime rules. +- Update protocol docs before changing data/API/UI contracts. +- Document compatibility strategy (backward-compatible vs migration). +- Keep frontend/backend implementations aligned with documented protocol. diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 1556179..5b66a4d 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -1,218 +1,57 @@ -# Flutter Mobile Development Constraints +# Apps Domain Rules -This document defines **hard constraints** for Flutter mobile development. Treat all items as **non-negotiable** unless explicitly overridden. +This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. -## 0) Scope and Precedence (MUST) +## Scope & Precedence -- This file applies to all changes under `apps/**`. -- It extends root routing rules in `AGENTS.md` and workspace global runtime rules. -- It also incorporates the visual design language from `apps/rules/visual_design_language.md` as a binding constraint. -- If rules conflict, apply the stricter requirement. -- Keep Flutter-specific constraints in this file; avoid duplicating them in root `AGENTS.md`. +- Inherits root `AGENTS.md` and workspace runtime rules. +- If rules conflict, apply the stricter one. +- Visual language source of truth: `apps/rules/visual_design_language.md`. -## 1) Design Tokens (MUST) +## UI Design System (Must) -- **MUST** use design tokens from `apps/lib/core/theme/design_tokens.dart`: - - Colors: `AppColors.*` - - Spacing: `AppSpacing.*` - - Radius: `AppRadius.*` -- **MUST NOT** hardcode any visual values. -- Design tokens are the single source of truth for all visual values. Any missing visual semantics should be added to tokens, not approximated locally. -- This ensures consistency with the visual design language defined in `apps/rules/visual_design_language.md`. +- Use design tokens only from `apps/lib/core/theme/design_tokens.dart` (`AppColors`, `AppSpacing`, `AppRadius`). +- No hardcoded visual values. +- If semantics are missing, add token definitions first. -## 2) Component Architecture (MUST) +## Reuse & Composition (Must) -- **SHOULD** extract repeated UI patterns into reusable components. -- **SHOULD** prefer existing shared components before creating new ones. -- **SHOULD** place reusable components in `apps/lib/shared/widgets/` following existing naming conventions. -- **MUST NOT** introduce parallel UI systems (e.g., custom button styles, custom loading indicators) that duplicate existing shared components. -- When creating new UI components, ensure they follow the design tokens and visual design language. +- Prefer `apps/lib/shared/widgets/` before adding new components. +- Extract repeated page structures/components; do not duplicate sibling-page scaffolds. +- Detail page top-right actions must use shared action-menu components. +- Destructive confirmations must use project-consistent shared surfaces. -## 2.1) Navigation/Header Reuse Rules (MUST) +## Interaction & Feedback (Must) -- For page groups with clear parent-child relationships (e.g., Settings and its subpages), **MUST** use one shared header pattern: back button + page title. -- **MUST** extract shared page scaffolds/header wrappers instead of duplicating `SafeArea + header + scroll` structures across sibling pages. -- Detail-page right-side actions (edit/delete/share etc.) **MUST** use a shared action-menu component, not per-page ad-hoc button groups. -- Header action menus **MUST NOT** overlap the trigger button area; menu surfaces should open below/right-aligned to the trigger and preserve title readability. +- User feedback: `Toast` / `AppBanner` only. +- Loading indicators: `AppLoadingIndicator` only. +- Form pages should default to keyboard-overlay behavior to avoid full-page layout jumps. -## 2.2) Interaction Surface Reuse Rules (MUST) +## Agent Chat Protocol (Must) -- Repeated state-switch controls (toggle/switch UI) **MUST** be extracted into shared widgets. -- Destructive confirmations (delete/remove) **MUST** use shared project-style confirmation surfaces (e.g., unified action sheet), not platform-default dialog styles. -- **MUST NOT** use raw platform-default popup/dialog/dropdown visuals when they break project visual language; use token-driven shared components instead. +- Agent chat must follow AG-UI over SSE. +- Lifecycle events are mandatory: `RUN_STARTED` and exactly one of `RUN_FINISHED` or `RUN_ERROR`. +- Text streaming flow must be `TEXT_MESSAGE_START -> TEXT_MESSAGE_CONTENT -> TEXT_MESSAGE_END`. -## 3) Layout Mapping & Alignment (MUST) +## High-Risk Modules (Must) -- **MUST** explicitly set `crossAxisAlignment` for every `Row` / `Column` (do not rely on defaults). -- **MUST** preserve layout semantics from root to leaf: - - alignment/justification intent must be explicitly represented in Flutter widgets. -- **MUST NOT** skip necessary container layers if doing so loses layout meaning or makes mapping non-traceable. +### Auth -## 4) Centering & Visual Balance (MUST) +- `AuthBloc` is the single source of truth. +- 401 invalidation must go through global callback chain; no feature-level token clearing or direct login navigation. -- **MUST** evaluate centering within `SafeArea` usable bounds (not full-screen bounds). -- **MUST NOT** rely on `Spacer` / proportional flex as the only centering mechanism for critical content. -- 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. +### Home Message Viewport -## 4.1) Keyboard Overlay Behavior (MUST) +- Home message auto-scroll/anchor restore must be event-driven. +- Preserve viewport during history prepend and when user is reading above bottom. -- For pages with text input, keyboard appearance **MUST** prefer overlay behavior and avoid reflowing the whole page. -- Input pages **MUST** avoid global layout jump when keyboard opens/closes (including subtle shifts caused by safe-area padding changes). -- Default strategy for full-screen pages with fixed hierarchy: `Scaffold.resizeToAvoidBottomInset = false`. -- If a page truly requires keyboard-driven scrolling to keep focused fields reachable, this must be an explicit opt-in and justified by page structure. -- When using `SafeArea` on keyboard-overlay pages, **MUST** stabilize bottom safe-area behavior (for example, maintain bottom view padding) to prevent center recalculation jitter. +### Cache / Repository -## 5) Testing Strategy (MUST) +- Reads/writes that affect consistency must go through repository layer. +- Cache keys and invalidation policy belong to repository, not UI/Bloc. -Follow lightweight testing strategy - prioritize value over coverage: +## Testing Policy -**Write tests for:** -- Model / DTO parsing (json → model) -- Service layer logic (business rules, API call handling) -- High-regression UI interaction flows only (multi-state/multi-step widgets, viewport/scroll decision logic, route return stability) - -**Default skip for UI tests:** -- Simple UI pages, regular buttons, basic layouts -- Pure visual structure/snapshot-like checks without behavior risk -- Low-risk styling and static rendering changes - -**UI test policy:** -- UI tests are opt-in, not default; only keep or add them when failure risk is high and there is clear regression value. -- If a UI test does not protect critical interaction behavior, remove it or avoid adding it. - -## 6) UI Feedback System (MUST) - -- All user-facing feedback **MUST** use the Toast system. - - Transient notifications: `Toast.show(...)` - - Persistent inline form errors: `AppBanner` -- **MUST NOT** create custom SnackBar/Dialog/Banner feedback components. -- **MUST NOT** use raw `ScaffoldMessenger` for feedback messaging. - -## 6.1) Loading Indicator System (MUST) - -- All loading spinners **MUST** use `AppLoadingIndicator` from `apps/lib/shared/widgets/app_loading_indicator.dart`. -- **MUST NOT** use raw `CircularProgressIndicator` directly in feature/page code. -- Use variants consistently: - - page/surface loading: `AppLoadingVariant.surface` - - inline small loading (list/search/section): `AppLoadingVariant.inline` - - button loading: `AppLoadingVariant.button` -- If visual semantics are missing, extend `AppLoadingIndicator` variant mapping first; do not create ad-hoc loading styles in feature files. - -## 7) Agent Chat (AG-UI Protocol) (MUST) - -Agent chat functionality **MUST** follow the AG-UI protocol. **Use the `ag-ui` skill** for protocol reference and implementation guidance. - -- **MUST** use Server-Sent Events (SSE) for streaming. -- **MUST** emit required lifecycle events: - - `RUN_STARTED` is required for every run - - End with exactly one of: `RUN_FINISHED` or `RUN_ERROR` -- **MUST** follow standard text streaming flow: - - `TEXT_MESSAGE_START` → `TEXT_MESSAGE_CONTENT` (delta) → `TEXT_MESSAGE_END` -- **MUST** support the standard AG-UI event type set as defined in the spec. -- **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) Visual Design Language (MUST) - -All UI/UX work **MUST** follow the visual design language defined in `apps/rules/visual_design_language.md`. - -- **MUST** ensure screens feel like a premium personal assistant product, not a wireframe, admin console, or document page. -- **MUST** apply the surface-based design system (background, primary, secondary, interactive surfaces). -- **MUST** follow the motion and interaction feel guidelines (soft, responsive, premium). -- **MUST** achieve visual hierarchy through spacing, surface grouping, radius, depth, density, contrast, scale, and motion—not color alone. -- **MUST** prioritize compact informational delivery in top bars: when the page purpose can be clearly expressed by a concise header title, avoid repeating equivalent explanatory hints in the body. -- **MUST NOT** duplicate page identity text between header and first content block unless the repeated text introduces new decision-critical information. -- **MUST** keep interface copy minimal and action-oriented: every text node must justify its presence by either enabling a decision, reducing error risk, or satisfying compliance requirements. -- **MUST NOT** stack multiple instructional hints around the same control (for example title + subtitle + placeholder + helper text all repeating the same meaning). -- **MUST** follow copy priority for auth and form pages: `Primary action > Required input labels > Error/recovery > Compliance`. Secondary explanatory copy should be removed when users can complete the task without it. -- **MUST** ensure each form field has at most one primary hint source in normal state (prefer placeholder or label, not both with duplicated wording). -- **MUST** follow the screen-level decision rules: - 1. What is the primary focus? - 2. What is the surface hierarchy? - 3. What needs strongest emphasis? - 4. What should be grouped? - 5. What should be lightweight/secondary? - 6. Where should motion reinforce understanding? - 7. How can the result feel more like a premium assistant app and less like a document page? -- **MUST NOT** create UIs that match the anti-patterns listed in the visual design language document: - - plain document page, white slab with blue buttons, spreadsheet-like admin panel - - low-fidelity wireframe, default Flutter demo app, generic template marketplace screen - - full-screen flat white blocks, arbitrary shadow usage, inconsistent card treatments - - raw container stacking without surface semantics - -Before finalizing any UI, mentally verify: -- Does this feel like a product, not a page? -- Is there clear hierarchy? -- Do surfaces feel intentional? -- Does the screen feel calm and premium? -- Is the assistant identity visually present? -- Would this look plausible in a polished shipping app? - -## 9) Auth Global Module Rules (MUST) - -Auth is a global module. All auth/session behavior MUST follow a single state machine. - -- **MUST** treat `AuthBloc` as the single source of truth for authentication state. -- **MUST NOT** implement ad-hoc auth state in feature modules (no parallel flags, no local auth caches). -- **MUST** route all 401 refresh-failure handling through the global callback chain: - `ApiInterceptor -> ApiClient auth failure callback -> AuthBloc(AuthSessionInvalidated)`. -- **MUST NOT** clear tokens directly inside feature/page code. -- **MUST NOT** navigate to login directly from feature code on token errors; rely on router redirect driven by global auth state. -- **MUST** distinguish logout semantics: - - manual logout: revoke server session + clear local session - - auto expiry/logout on refresh failure: clear local session only -- **MUST** ensure startup session recovery has exception fallback and never leaves app stuck in boot/loading state. -- **MUST** add/maintain tests for: - - startup recovery fallback - - concurrent 401 refresh failure singleflight - - session invalidation -> unauthenticated redirect path - -If a new auth-related requirement cannot fit this model, update this section first, then implement code. - -## 10) Home Message Loading & Scroll Rules (MUST) - -Home 首页历史消息加载与滚动策略属于高回归模块,必须遵循以下约束: - -- **MUST** use event-driven viewport decisions for Home message list behavior. - - Use `HomeMessageViewportController` as the decision engine. - - **MUST NOT** drive auto-scroll directly from `items.length` diffs or ad-hoc boolean combinations. -- **MUST** distinguish semantic events at minimum: - - initial history loaded - - history prepend start/finish - - new message appended - - sub-route resume - - refresh completed - - user scroll state changed -- **MUST** preserve reading position when user is away from bottom. - - New messages while reading history should show unread indicator instead of forcing bottom jump. -- **MUST** preserve viewport anchor during history prepend. - - **MUST NOT** mix prepend restore with unconditional bottom auto-scroll. -- **MUST** use `returnToHomePreserveState` for business-subroute returns to Home (calendar/todo/message-related flows). - - **MUST NOT** introduce new direct business-route `go('/home')` shortcuts. - - Auth entry flows (login/register success) are allowed to navigate to Home directly. -- **MUST** add or update tests when touching Home message loading/scroll behavior: - - controller-level state transition tests - - widget-level unread indicator and scroll behavior tests - - route-return stability tests when navigation behavior changes - -## 11) Cache & Repository Rules (MUST) - -前端缓存与数据访问属于高回归区域,必须遵循以下约束: - -- **MUST** route feature data reads/writes through repository layer when cache, invalidation, or optimistic update is involved. - - Feature/UI code **MUST NOT** call raw `*Api` methods directly for mutation paths that affect list/detail consistency. - - Exceptions are allowed only for bootstrapping or truly stateless read operations, and must be documented in code review notes. -- **MUST** keep cache key ownership centralized in repository classes. - - UI/Bloc/Cubit **MUST NOT** hardcode cache keys or perform ad-hoc cache writes. -- **MUST** define cache invalidation at mutation boundaries (create/update/delete/archive/complete/reorder). - - Mutation success must either update cache atomically or invalidate and trigger deterministic refresh. -- **MUST** preserve route-return consistency for data freshness. - - Pages that mutate entity data must return an explicit changed signal to caller routes. - - Caller list pages must consume that signal and refresh using repository path. -- **MUST** ensure list item widgets that carry local interaction state use stable identity keys (e.g. `ValueKey(entity.id)`) to prevent state leakage across reused cells. -- **MUST** add/maintain regression tests when changing cache/repository behavior: - - repository tests for optimistic update + rollback + invalidation - - route-return refresh tests for list/detail/edit flows - - widget tests for stable keyed interaction state where applicable +- Prioritize tests for model parsing, service logic, and high-regression interaction flows. +- Simple static UI changes may skip tests. +- Auth/Home/Cache changes must include targeted regression tests. diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 98a8f89..15f1bff 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ const UserMemoryDetailScreen(), + builder: (context, state) => const UserMemoryViewScreen(), ), GoRoute( path: AppRoutes.settingsMemoryWork, + builder: (context, state) => const WorkMemoryViewScreen(), + ), + GoRoute( + path: AppRoutes.settingsMemoryUserEdit, + builder: (context, state) => const UserMemoryDetailScreen(), + ), + GoRoute( + path: AppRoutes.settingsMemoryWorkEdit, builder: (context, state) => const WorkMemoryDetailScreen(), ), GoRoute( diff --git a/apps/lib/core/router/app_routes.dart b/apps/lib/core/router/app_routes.dart index cf9f7b2..73fd80b 100644 --- a/apps/lib/core/router/app_routes.dart +++ b/apps/lib/core/router/app_routes.dart @@ -32,5 +32,7 @@ class AppRoutes { static const settingsMemory = '/settings/memory'; static const settingsMemoryUser = '/settings/memory/user'; static const settingsMemoryWork = '/settings/memory/work'; + static const settingsMemoryUserEdit = '/settings/memory/user/edit'; + static const settingsMemoryWorkEdit = '/settings/memory/work/edit'; static const settingsEditProfile = '/edit-profile'; } diff --git a/apps/lib/features/settings/ui/screens/memory_screen.dart b/apps/lib/features/settings/ui/screens/memory_screen.dart index 3c67433..8bae90a 100644 --- a/apps/lib/features/settings/ui/screens/memory_screen.dart +++ b/apps/lib/features/settings/ui/screens/memory_screen.dart @@ -5,8 +5,6 @@ import 'package:social_app/core/theme/design_tokens.dart'; import 'package:social_app/core/router/app_routes.dart'; import 'package:social_app/shared/widgets/app_loading_indicator.dart'; import 'package:social_app/shared/widgets/app_pressable.dart'; -import 'package:social_app/shared/widgets/toast/toast.dart'; -import 'package:social_app/shared/widgets/toast/toast_type.dart'; import '../widgets/settings_page_scaffold.dart'; import '../../data/models/memory_models.dart'; import '../../data/services/memory_service.dart'; @@ -58,7 +56,6 @@ class _MemoryScreenState extends State { return SettingsPageScaffold( title: '我的记忆', onBack: () => context.pop(), - footer: _buildFooter(), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -393,7 +390,7 @@ class _MemoryScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - '工作Profile', + '工作画像', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -486,36 +483,4 @@ class _MemoryScreenState extends State { }), ); } - - Widget? _buildFooter() { - return AppPressable( - onTap: () { - Toast.show(context, '记忆会随着你的使用自动完善', type: ToastType.info); - }, - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppColors.surfaceInfoLight, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.borderQuaternary), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.info_outline, size: 16, color: AppColors.blue500), - const SizedBox(width: AppSpacing.sm), - const Text( - '点击卡片查看或编辑详情', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.blue600, - ), - ), - ], - ), - ), - ); - } } diff --git a/apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart b/apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart index fc6174a..9a7b12b 100644 --- a/apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart +++ b/apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart @@ -89,7 +89,7 @@ class _UserMemoryDetailScreenState extends State { @override Widget build(BuildContext context) { return SettingsPageScaffold( - title: '个人偏好', + title: '编辑个人偏好', onBack: () => context.pop(), footer: _hasChanges ? _buildSaveButton() : null, body: Column( @@ -182,7 +182,7 @@ class _UserMemoryDetailScreenState extends State { borderRadius: BorderRadius.circular(AppRadius.lg), boxShadow: [ BoxShadow( - color: const Color(0x4D60A5FA), + color: AppColors.blue500.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2), ), @@ -190,16 +190,7 @@ class _UserMemoryDetailScreenState extends State { ), child: Center( child: _isSaving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - AppColors.white, - ), - ), - ) + ? const AppLoadingIndicator(variant: AppLoadingVariant.button) : const Text( '保存更改', style: TextStyle( diff --git a/apps/lib/features/settings/ui/screens/user_memory_view_screen.dart b/apps/lib/features/settings/ui/screens/user_memory_view_screen.dart new file mode 100644 index 0000000..5221e72 --- /dev/null +++ b/apps/lib/features/settings/ui/screens/user_memory_view_screen.dart @@ -0,0 +1,554 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/core/router/app_routes.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/shared/widgets/app_loading_indicator.dart'; +import 'package:social_app/shared/widgets/app_pressable.dart'; +import 'package:social_app/shared/widgets/detail_header_action_menu.dart'; + +import '../../data/models/memory_models.dart'; +import '../../data/services/memory_service.dart'; +import '../widgets/settings_page_scaffold.dart'; + +enum _UserMemoryHeaderAction { edit } + +class UserMemoryViewScreen extends StatefulWidget { + const UserMemoryViewScreen({super.key}); + + @override + State createState() => _UserMemoryViewScreenState(); +} + +class _UserMemoryViewScreenState extends State { + final MemoryService _memoryService = sl(); + UserMemoryContent? _memory; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadMemory(); + } + + Future _loadMemory() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + try { + final memory = await _memoryService.getUserMemory(); + if (!mounted) return; + setState(() { + _memory = memory; + _isLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _error = '加载失败'; + _isLoading = false; + }); + } + } + + void _onHeaderAction(_UserMemoryHeaderAction action) { + switch (action) { + case _UserMemoryHeaderAction.edit: + context.push(AppRoutes.settingsMemoryUserEdit); + } + } + + @override + Widget build(BuildContext context) { + return SettingsPageScaffold( + title: '个人偏好', + onBack: () => context.pop(), + trailing: DetailHeaderActionMenu<_UserMemoryHeaderAction>( + items: const [ + DetailHeaderActionItem<_UserMemoryHeaderAction>( + value: _UserMemoryHeaderAction.edit, + label: '编辑', + icon: Icons.edit_outlined, + ), + ], + onSelected: _onHeaderAction, + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isLoading) ...[ + const SizedBox(height: AppSpacing.xxl * 2), + const Center(child: AppLoadingIndicator(size: 32)), + ] else if (_error != null) ...[ + const SizedBox(height: AppSpacing.xxl * 2), + _buildErrorState(), + ] else + _buildContent(_memory ?? UserMemoryContent()), + ], + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: AppColors.slate300), + const SizedBox(height: AppSpacing.md), + Text(_error ?? '加载失败', style: TextStyle(color: AppColors.slate500)), + const SizedBox(height: AppSpacing.lg), + AppPressable( + onTap: _loadMemory, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.blue100), + ), + child: const Text( + '重新加载', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.blue600, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildContent(UserMemoryContent memory) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSectionCard( + title: '基本信息', + icon: Icons.person_outline, + children: [ + _buildInfoRow(Icons.work_outline, '职业', _text(memory.occupation)), + _buildInfoRow(Icons.public_outlined, '时区', _text(memory.timezone)), + _buildInfoRow( + Icons.translate_outlined, + '主要语言', + _text(memory.primaryLanguage), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '偏好设置', + icon: Icons.tune, + children: [ + _buildInfoRow( + Icons.chat_bubble_outline, + '沟通风格', + _text(memory.preferences.communicationStyle), + ), + _buildInfoRow( + Icons.place_outlined, + '位置偏好', + _text(memory.preferences.locationPreference), + ), + _buildInfoRow( + Icons.balcony_outlined, + '工作生活方式', + _text(memory.preferences.workLifestyle), + ), + _buildInfoRow( + Icons.language_outlined, + '语言偏好', + _listText(memory.preferences.languagePreference), + ), + _buildInfoRow( + Icons.notifications_outlined, + '通知偏好', + _listText(memory.preferences.notificationPreference), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '日程偏好', + icon: Icons.schedule_outlined, + children: [ + _buildInfoRow( + Icons.timer_outlined, + '会议缓冲时间', + _minutesText(memory.schedulingPreferences.meetingBufferMinutes), + ), + _buildInfoRow( + Icons.format_list_numbered, + '每日最多会议', + _numberText(memory.schedulingPreferences.maxMeetingsPerDay), + ), + _buildInfoRow( + Icons.timelapse_outlined, + '偏好会议时长', + _intListText( + memory.schedulingPreferences.preferredMeetingDurationMinutes, + suffix: '分钟', + ), + ), + _buildInfoRow( + Icons.note_outlined, + '备注', + _text(memory.schedulingPreferences.notes), + multiline: true, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '联系人', + icon: Icons.people_outline, + children: [_buildPeople(memory.people)], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '地点', + icon: Icons.place_outlined, + children: [_buildPlaces(memory.places)], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '兴趣', + icon: Icons.interests_outlined, + children: [_buildTags(memory.interests)], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '回避话题', + icon: Icons.not_interested_outlined, + children: [_buildTags(memory.avoidTopics)], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '自定义规则', + icon: Icons.rule_folder_outlined, + children: [_buildTags(memory.customRules)], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '周期习惯', + icon: Icons.repeat, + children: [_buildRoutines(memory.recurringRoutines)], + ), + const SizedBox(height: AppSpacing.xxl), + ], + ); + } + + Widget _buildSectionCard({ + required String title, + required IconData icon, + required List children, + }) { + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon(icon, size: 16, color: AppColors.blue600), + ), + const SizedBox(width: AppSpacing.sm), + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + ...children, + ], + ), + ); + } + + Widget _buildInfoRow( + IconData icon, + String label, + String value, { + bool multiline = false, + }) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + crossAxisAlignment: multiline + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: AppColors.slate500), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.slate500, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.slate800, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPeople(List people) { + if (people.isEmpty) { + return _buildEmptyTip('暂无联系人'); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: people.map((person) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderTertiary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow(Icons.badge_outlined, '姓名', _text(person.name)), + _buildInfoRow( + Icons.account_tree_outlined, + '关系', + _text(person.relationship), + ), + _buildInfoRow( + Icons.person_pin_outlined, + '角色', + _text(person.role), + ), + _buildInfoRow( + Icons.phone_outlined, + '联系方式', + _text(person.preferredContactChannel), + ), + _buildInfoRow( + Icons.note_outlined, + '备注', + _text(person.notes), + multiline: true, + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildPlaces(List places) { + if (places.isEmpty) { + return _buildEmptyTip('暂无地点'); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: places.map((place) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderTertiary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow(Icons.place_outlined, '名称', _text(place.name)), + _buildInfoRow( + Icons.category_outlined, + '类别', + _text(place.category), + ), + _buildInfoRow( + Icons.favorite_border, + '偏好', + _text(place.preference), + ), + _buildInfoRow(Icons.map_outlined, '地址', _text(place.address)), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildRoutines(List routines) { + if (routines.isEmpty) { + return _buildEmptyTip('暂无周期习惯'); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: routines.map((routine) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderTertiary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow(Icons.title_outlined, '名称', _text(routine.name)), + _buildInfoRow( + Icons.subject_outlined, + '描述', + _text(routine.description), + multiline: true, + ), + _buildInfoRow(Icons.repeat_one, '周期', _text(routine.cadence)), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildTags(List tags) { + if (tags.isEmpty) { + return _buildEmptyTip('暂无数据'); + } + return Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: tags.map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.blue100), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.label_outline, size: 14, color: AppColors.blue500), + const SizedBox(width: AppSpacing.xs), + Text( + tag, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.blue600, + ), + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildEmptyTip(String text) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderTertiary), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.inbox_outlined, size: 16, color: AppColors.slate400), + const SizedBox(width: AppSpacing.sm), + Text( + text, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + ], + ), + ); + } + + String _text(String? value) { + final raw = value?.trim() ?? ''; + return raw.isEmpty ? '未设置' : raw; + } + + String _listText(List values) { + if (values.isEmpty) return '未设置'; + return values.join('、'); + } + + String _intListText(List values, {required String suffix}) { + if (values.isEmpty) return '未设置'; + return values.map((value) => '$value$suffix').join('、'); + } + + String _minutesText(int? value) { + if (value == null) return '未设置'; + return '$value 分钟'; + } + + String _numberText(int? value) { + if (value == null) return '未设置'; + return '$value'; + } +} diff --git a/apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart b/apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart index cb2c5b1..da520be 100644 --- a/apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart +++ b/apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart @@ -89,7 +89,7 @@ class _WorkMemoryDetailScreenState extends State { @override Widget build(BuildContext context) { return SettingsPageScaffold( - title: '工作Profile', + title: '编辑工作画像', onBack: () => context.pop(), footer: _hasChanges ? _buildSaveButton() : null, body: Column( @@ -182,7 +182,7 @@ class _WorkMemoryDetailScreenState extends State { borderRadius: BorderRadius.circular(AppRadius.lg), boxShadow: [ BoxShadow( - color: const Color(0x4D60A5FA), + color: AppColors.blue500.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2), ), @@ -190,16 +190,7 @@ class _WorkMemoryDetailScreenState extends State { ), child: Center( child: _isSaving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - AppColors.white, - ), - ), - ) + ? const AppLoadingIndicator(variant: AppLoadingVariant.button) : const Text( '保存更改', style: TextStyle( diff --git a/apps/lib/features/settings/ui/screens/work_memory_view_screen.dart b/apps/lib/features/settings/ui/screens/work_memory_view_screen.dart new file mode 100644 index 0000000..84bf490 --- /dev/null +++ b/apps/lib/features/settings/ui/screens/work_memory_view_screen.dart @@ -0,0 +1,522 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/core/router/app_routes.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/shared/widgets/app_loading_indicator.dart'; +import 'package:social_app/shared/widgets/app_pressable.dart'; +import 'package:social_app/shared/widgets/detail_header_action_menu.dart'; + +import '../../data/models/memory_models.dart'; +import '../../data/services/memory_service.dart'; +import '../widgets/settings_page_scaffold.dart'; + +enum _WorkMemoryHeaderAction { edit } + +class WorkMemoryViewScreen extends StatefulWidget { + const WorkMemoryViewScreen({super.key}); + + @override + State createState() => _WorkMemoryViewScreenState(); +} + +class _WorkMemoryViewScreenState extends State { + final MemoryService _memoryService = sl(); + WorkProfileContent? _memory; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadMemory(); + } + + Future _loadMemory() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + try { + final memory = await _memoryService.getWorkMemory(); + if (!mounted) return; + setState(() { + _memory = memory; + _isLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _error = '加载失败'; + _isLoading = false; + }); + } + } + + void _onHeaderAction(_WorkMemoryHeaderAction action) { + switch (action) { + case _WorkMemoryHeaderAction.edit: + context.push(AppRoutes.settingsMemoryWorkEdit); + } + } + + @override + Widget build(BuildContext context) { + return SettingsPageScaffold( + title: '工作画像', + onBack: () => context.pop(), + trailing: DetailHeaderActionMenu<_WorkMemoryHeaderAction>( + items: const [ + DetailHeaderActionItem<_WorkMemoryHeaderAction>( + value: _WorkMemoryHeaderAction.edit, + label: '编辑', + icon: Icons.edit_outlined, + ), + ], + onSelected: _onHeaderAction, + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isLoading) ...[ + const SizedBox(height: AppSpacing.xxl * 2), + const Center(child: AppLoadingIndicator(size: 32)), + ] else if (_error != null) ...[ + const SizedBox(height: AppSpacing.xxl * 2), + _buildErrorState(), + ] else + _buildContent(_memory ?? WorkProfileContent()), + ], + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: AppColors.slate300), + const SizedBox(height: AppSpacing.md), + Text(_error ?? '加载失败', style: TextStyle(color: AppColors.slate500)), + const SizedBox(height: AppSpacing.lg), + AppPressable( + onTap: _loadMemory, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.blue100), + ), + child: const Text( + '重新加载', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.blue600, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildContent(WorkProfileContent memory) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSectionCard( + title: '基本信息', + icon: Icons.work_outline, + children: [ + _buildInfoRow(Icons.badge_outlined, '职业', _text(memory.occupation)), + ], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '专长', + icon: Icons.psychology_outlined, + children: [_buildTags(memory.expertise)], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '偏好工具', + icon: Icons.build_outlined, + children: [_buildTags(memory.preferredTools)], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '当前项目', + icon: Icons.folder_outlined, + children: [_buildProjects(memory.currentProjects)], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '团队成员', + icon: Icons.groups_outlined, + children: [_buildTeamMembers(memory.teamMembers)], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '工作习惯', + icon: Icons.schedule_outlined, + children: [ + _buildInfoRow( + Icons.timelapse_outlined, + '可用时段', + _timeWindowSummary(memory.workHabits.availableHours), + ), + _buildInfoRow( + Icons.flash_on_outlined, + '深度工作时段', + _timeWindowSummary(memory.workHabits.deepWorkBlocks), + ), + _buildInfoRow( + Icons.meeting_room_outlined, + '偏好会议时段', + _timeWindowSummary(memory.workHabits.preferredMeetingWindows), + ), + _buildInfoRow( + Icons.do_not_disturb_alt_outlined, + '免打扰时段', + _timeWindowSummary(memory.workHabits.noMeetingWindows), + ), + _buildInfoRow( + Icons.timer_outlined, + '偏好会议时长', + _intListText( + memory.workHabits.preferredMeetingDurationMinutes, + suffix: '分钟', + ), + ), + _buildInfoRow( + Icons.notifications_outlined, + '通知渠道', + _text(memory.workHabits.notificationChannel), + ), + _buildInfoRow( + Icons.note_outlined, + '备注', + _text(memory.workHabits.notes), + multiline: true, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '团队背景', + icon: Icons.business_outlined, + children: [ + _buildInfoRow( + Icons.apartment_outlined, + '团队背景描述', + _text(memory.teamContext), + multiline: true, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + _buildSectionCard( + title: '工作规则', + icon: Icons.rule_outlined, + children: [_buildTags(memory.workRules)], + ), + const SizedBox(height: AppSpacing.xxl), + ], + ); + } + + Widget _buildSectionCard({ + required String title, + required IconData icon, + required List children, + }) { + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.violet500.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon(icon, size: 16, color: AppColors.violet600), + ), + const SizedBox(width: AppSpacing.sm), + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + ...children, + ], + ), + ); + } + + Widget _buildInfoRow( + IconData icon, + String label, + String value, { + bool multiline = false, + }) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + crossAxisAlignment: multiline + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: AppColors.slate500), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.slate500, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.slate800, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildProjects(List projects) { + if (projects.isEmpty) { + return _buildEmptyTip('暂无项目'); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: projects.map((project) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderTertiary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow(Icons.title_outlined, '项目名称', _text(project.name)), + _buildInfoRow( + Icons.subject_outlined, + '描述', + _text(project.description), + multiline: true, + ), + _buildInfoRow(Icons.flag_outlined, '状态', _text(project.status)), + _buildInfoRow( + Icons.priority_high_outlined, + '优先级', + _text(project.priority), + ), + _buildInfoRow( + Icons.event_outlined, + '截止日期', + project.deadline == null + ? '未设置' + : project.deadline!.toIso8601String().split('T').first, + ), + _buildInfoRow( + Icons.group_add_outlined, + '协作人', + _listText(project.collaborators), + ), + _buildInfoRow( + Icons.emoji_events_outlined, + '关键里程碑', + project.keyMilestones.isEmpty + ? '未设置' + : '${project.keyMilestones.length} 项', + ), + _buildInfoRow( + Icons.note_alt_outlined, + '备注', + _text(project.notes), + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildTeamMembers(List members) { + if (members.isEmpty) { + return _buildEmptyTip('暂无团队成员'); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: members.map((member) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderTertiary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow(Icons.badge_outlined, '姓名', _text(member.name)), + _buildInfoRow( + Icons.person_pin_outlined, + '角色', + _text(member.role), + ), + _buildInfoRow( + Icons.account_tree_outlined, + '关系', + _text(member.relationship), + ), + _buildInfoRow( + Icons.phone_outlined, + '联系方式', + _text(member.preferredContactChannel), + ), + _buildInfoRow(Icons.note_outlined, '备注', _text(member.notes)), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildTags(List tags) { + if (tags.isEmpty) { + return _buildEmptyTip('暂无数据'); + } + return Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: tags.map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.violet500.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.violet500.withValues(alpha: 0.25), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.label_outline, size: 14, color: AppColors.violet500), + const SizedBox(width: AppSpacing.xs), + Text( + tag, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.violet600, + ), + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildEmptyTip(String text) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderTertiary), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.inbox_outlined, size: 16, color: AppColors.slate400), + const SizedBox(width: AppSpacing.sm), + Text( + text, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + ], + ), + ); + } + + String _text(String? value) { + final raw = value?.trim() ?? ''; + return raw.isEmpty ? '未设置' : raw; + } + + String _listText(List values) { + if (values.isEmpty) return '未设置'; + return values.join('、'); + } + + String _intListText(List values, {required String suffix}) { + if (values.isEmpty) return '未设置'; + return values.map((value) => '$value$suffix').join('、'); + } + + String _timeWindowSummary(List windows) { + if (windows.isEmpty) return '未设置'; + return '${windows.length} 个时段'; + } +} diff --git a/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart b/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart index b8d9284..ec74760 100644 --- a/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart +++ b/apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart @@ -10,6 +10,7 @@ class SettingsPageScaffold extends StatelessWidget { required this.body, this.footer, this.onBack, + this.trailing, this.resizeOnKeyboard = true, this.maintainBottomViewPadding = false, }); @@ -18,6 +19,7 @@ class SettingsPageScaffold extends StatelessWidget { final Widget body; final Widget? footer; final VoidCallback? onBack; + final Widget? trailing; final bool resizeOnKeyboard; final bool maintainBottomViewPadding; @@ -31,7 +33,11 @@ class SettingsPageScaffold extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - BackTitlePageHeader(title: title, onBack: onBack), + BackTitlePageHeader( + title: title, + onBack: onBack, + trailing: trailing, + ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB( diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index fc24ad2..1dd8b99 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -1,7 +1,7 @@ name: social_app description: "Social App - A Flutter mobile application" publish_to: 'none' -version: 0.1.0+3 +version: 0.1.1+4 environment: sdk: ^3.10.7 diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 07a05db..f5ea22f 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -1,302 +1,69 @@ -# Backend Development Rules +# Backend Domain Rules -This document defines Python/FastAPI backend development constraints. +This file governs `backend/**` only. Keep it minimal, enforceable, and non-duplicative. -## Scope and Precedence +## Scope & 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`. +- Inherits root `AGENTS.md` and workspace runtime rules. +- If rules conflict, apply the stricter one. +- Keep backend-only constraints here; do not duplicate root routing logic. -## Python Environment +## Runtime & Commands -**MUST use uv for dependency management and virtual environment execution.** +- Python commands must use `uv` (`uv run`, `uv add`). +- Backend startup/shutdown must use `./infra/scripts/app.sh`. +- Check runtime logs from `./logs/*.log`. -- All Python commands: `uv run ` -- Add dependencies: `uv add ` -- All dependencies declared in `pyproject.toml` +## Code Quality Baseline -## Code Quality Checks +- Do not bypass lint/type gates (`ruff`, `basedpyright`). +- Use project logging (`core.logging`), never `print()` in runtime code. +- HTTP errors must follow RFC 7807 (`application/problem+json`). -**Git pre-commit hook enforces code quality before commit.** +## Configuration & Secrets -Pre-commit hook automatically runs on backend/ directory: -- `ruff check` - code style and linting -- `basedpyright` - type checking with error level +- Read env only through `core.config.settings` (`Settings` / `config`). +- Do not use `os.getenv`/manual env parsing in backend runtime. +- Never hardcode keys/tokens/passwords. -If any error detected, commit is rejected. Fix errors before committing. -Do not bypass or weaken checks (no ignores, disables, or config relaxations). Resolve the underlying issues. +## Architecture Rules -## Logging +- Use `schema -> repository -> service` layering. +- Repository: CRUD + query composition only; no auth decisions, no transaction boundary. +- Service: authz + business logic + transaction boundary. +- `owner_id` must come from verified JWT (`sub`), never from client payload. -**MUST use project logger for all runtime logging.** +## Schema & Contract Rules -- Use project logger from `backend/src/core/logging/*` -- Prohibit: print(), logging.info/warning/error directly -- Required: structured logging with context -- Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL +- Schema-first for new/changed data contracts. +- Strong typing required at boundaries (Pydantic/dataclass); avoid weak untyped payload contracts. +- Protocol/data contract changes must stay aligned with `docs/protocols/`. -## HTTP API Standards +## Database Rules -**MUST follow RESTful conventions and RFC 7807 for error responses.** +- Supabase Auth is identity source; backend enforces business authorization. +- Use service-role DB access only in backend. +- Soft delete uses `deleted_at`; reads must exclude deleted records by default. +- Alembic is the only schema migration source of truth. -- Errors must use `application/problem+json` with RFC 7807 fields -- No custom response envelopes for HTTP APIs -- Request and response validation must use Pydantic models +## Agent Runtime & Tools -## Environment Variables +- AG-UI protocol is mandatory for agent loop behavior. +- `ToolAgentOutput.result` is the canonical tool result field. +- Tool results must be machine-oriented and include IDs/outcomes needed for chaining. -**Backend env access MUST go through** `backend/src/core/config/settings.py`. +## Tool Schema Rules for Small Models (e.g., qwen3.5-flash) -- Only use `Settings()` / `config` from `core.config.settings` -- Do not call `os.environ`, `os.getenv`, `dotenv`, or manual parsing in backend runtime code -- Tests can set env vars via `monkeypatch.setenv`, and should read values via `Settings()` unless the test is explicitly validating env plumbing -- Canonical principle: one source of truth per setting; no duplicate/derived env vars in backend code - -## TDD Workflow - -### Coverage Requirements - -- Minimum coverage: 80% -- Required test types: - - Unit: isolated functions, utilities, components - - Integration: API endpoints, database operations - - E2E: critical user flows (Playwright) - -### Limited Exceptions - -- Docs-only changes (README, comments, formatting) may skip integration/E2E -- Non-runtime config changes may skip E2E if no behavior changes -- Any runtime code change requires unit + integration + E2E -- If an exception is used, record the reason in the PR/test notes - -### Mandatory TDD Workflow - -1. Write tests (RED) - they must fail -2. Run tests - confirm failure -3. Implement minimal code (GREEN) - only to pass -4. Run tests - confirm success -5. Refactor (IMPROVE) -6. Verify coverage - target 80%+ - -### Enforcement - -- Must use the `tdd-guide` agent for new features -- Do not write implementation before tests -- Do not lower coverage requirements -- Must include unit, integration, and E2E tests - -## Code Style - -### Immutability - -**ALWAYS create new objects, NEVER mutate.** - -```python -# WRONG: Mutation -def update_user(user, name): - user["name"] = name - return user - -# CORRECT: Immutability -def update_user(user, name): - return {**user, "name": name} -``` - -### File Organization - -- Many small files over few large files -- 200-400 lines typical, 800 max per file -- Extract utilities from large components - -### Error Handling - -Always handle errors comprehensively: - -```python -try: - result = risky_operation() - return result -except Exception as exc: - logger.exception("Operation failed") - raise RuntimeError("Detailed user-friendly message") from exc -``` - -## Security - -### Mandatory Security Checks - -Before ANY commit: -- [ ] No hardcoded secrets (API keys, passwords, tokens) -- [ ] All user inputs validated (use Pydantic) -- [ ] SQL injection prevention (parameterized queries) -- [ ] Authentication/authorization verified - -### Secret Management - -```python -# NEVER: Hardcoded secrets -api_key = "sk-proj-xxxxx" - -# 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 in settings") -``` - -## Database Development Rules - -### Architecture - -- **Supabase**: authentication (JWT source of truth) -- **Backend**: business authorization (service layer) -- **SQLAlchemy ORM**: data access layer (async + asyncpg, service_role connection) - -### Code Organization - -Use `schemas / repository / service` pattern: -- `schemas.py` — Pydantic models -- `repository.py` — CRUD only, no auth, no commit (only flush), must receive session (never create session/engine) -- `service.py` — authorization + business logic + transaction boundary (must commit/rollback) -- `dependencies.py` — DI (`get_db`, `get_current_user`) - -### Schema-First and Strong Typing (Mandatory) - -**Data model constraints are the first priority. Define schemas before implementation.** - -- Any backend feature that introduces or changes data structures MUST define/update strong-typed schemas first. -- All request/response/domain/runtime contracts MUST use explicit Pydantic models or typed dataclasses. -- Prohibit weak typing in data contracts: `Any`, untyped `dict`, untyped `list`, `object` placeholders. -- Prohibit using raw `dict[str, object]` as the canonical contract for pipeline/stage/config/domain payloads. -- External library boundaries may accept weakly typed input only at adapter edges; data MUST be converted immediately into local strong-typed schemas before entering service/domain layers. -- New model placement rules: - - Cross-module runtime/domain contracts: `backend/src/schemas/**` - - HTTP request/response contracts: `backend/src/v1/**/schemas.py` - - ORM persistence models: `backend/src/models/**` - -### Auth & Data Access - -- Backend must verify JWT signature and expiration (not just decode) -- Extract `user_id` from JWT `sub` claim -- Backend connects with **service_role** (bypasses RLS) -- `owner_id` always derived from JWT, never from client -- Scope queries by owner/org; public access must be explicit -- service_role key is backend-only; never expose credentials -- Prohibit calling Supabase Admin API (service_role key) from repository/service layers - -### Soft Delete - -**Soft delete marks data as invisible, not cascade delete.** - -- Use `deleted_at: datetime | None` column (via `SoftDeleteMixin`) -- **Query filtering**: Repository `_apply_soft_delete_filter()` auto-excludes deleted records -- **No automatic cascade**: Related data stays intact; visibility controlled by JOIN filtering -- **Cascade only for strong dependencies**: When parent deletion must invalidate children, implement in Service layer explicitly -- **Recovery**: Only restore the record itself; related data visibility restored automatically via queries -- **Unique constraints**: Use partial indexes excluding `deleted_at IS NOT NULL` to allow re-creation - -```python -# Partial unique index in migration -op.execute(""" - CREATE UNIQUE INDEX ux_user_email - ON users(email) - WHERE deleted_at IS NULL -""") -``` - -### Migrations - -- **Alembic is the single source of truth** for schema migrations -- ORM model changes → `alembic revision --autogenerate` -- Raw SQL (policies, triggers, functions) → `op.execute()` -- Migrations must be reversible; no reliance on generated IDs - -### Enum Storage Convention - -**Store enum names (strings), not integer values.** - -- Use `VARCHAR(20)` + `CHECK` constraint in database -- Use Python `Enum` class with `str` base in code - -```python -class AgentType(str, Enum): - INTENT_RECOGNITION = "INTENT_RECOGNITION" - TASK_EXECUTION = "TASK_EXECUTION" - RESULT_REPORTING = "RESULT_REPORTING" -``` - -### RLS Policy - -- Backend does not rely on RLS for correctness (uses service_role), but RLS is mandatory as a defensive boundary for tables in PostgREST-exposed schemas. -- **Mandatory default**: any new business table in `public` must enable RLS in the same Alembic migration. -- The same migration must create policies covering `SELECT/INSERT/UPDATE/DELETE` (minimum requirement). -- Recommended default policy set for `anon, authenticated`: deny all operations first, then open explicit access only when required. -- `alembic_version` must not be exposed to `anon` or `authenticated`. - -#### Exemption Rule (strict) - -- Exemptions are allowed only when a new `public` table is guaranteed not to be exposed to PostgREST clients. -- Exemptions must be explicit in the migration file with rationale and verification notes. -- If exposure is uncertain, do not exempt: enable defensive RLS by default. - -#### Migration Checklist - -- [ ] New `public` business table has `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` in migration -- [ ] Policies for `SELECT/INSERT/UPDATE/DELETE` are present in migration -- [ ] Policy target roles are explicit (`anon`, `authenticated`, or both) -- [ ] 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. - -## Custom Tool Result Contract - -Custom tool `ToolAgentOutput` MUST follow these rules: - -- Use field name `result` only. Do not introduce or keep `result_summary` compatibility aliases. -- `metadata.tool_agent_output` is the canonical source for runtime observation and history replay. -- `tool_call_args` stores input snapshot only; avoid mixing execution output into `tool_call_args`. -- `result` stores output facts only; do not repeat input parameters already present in `tool_call_args`. -- `result` is for downstream agent reasoning and tool chaining, not for end-user presentation. -- For list/read tools, include multiple candidate records when needed (at least top matches) with stable identifiers and scheduling-critical fields. -- For write tools, include per-item operation outcomes and affected resource identifiers in `result`. -- Keep `result` concise, deterministic, and machine-oriented; avoid decorative wording and UI-style formatting. - -## Multi-Agent Orchestration (AgentScope Framework) - -Multi-agent orchestration MUST use the AgentScope framework. **Use the `agentscope-skill`** for framework reference and implementation guidance. - -### Core Principles - -- Use AgentScope for orchestrating multiple agents working together -- Define clear agent roles, stage responsibilities, and pipeline boundaries -- Leverage AgentScope built-in workflow and tool middleware mechanisms -- Follow AgentScope best practices for agent configuration - -### Key Components - -- **Agents**: Autonomous units with specific roles and goals -- **Tasks**: Stage-specific prompts and execution goals -- **Pipelines**: Ordered orchestration flow between agents -- **Tools**: Capabilities available to agents -- **Flows**: Workflow orchestration and state management +- Prefer `operations: list[OperationModel]` over parallel arrays. +- Validate tool args with strict Pydantic models (`extra="forbid"`). +- Keep payloads JSON-native (objects/lists), shallow, and deterministic. +- Make action-specific required fields explicit and fail with structured errors. +- Return per-item outcomes (`success/failed`, identifiers, partial status) for self-correction. +- Avoid broad entry-point coercion fallbacks; fix schema/prompt alignment first. +- Do not pass provider request fields with `None` values (avoid upstream 400 blocking tool calls). ## Testing -### Real Database Tests - -Tests requiring real Supabase operations MUST use environment variables: -- Define `TestSettings` in `settings.py` with nested configuration -- Access via `settings.test.email` / `settings.test.password` -- NEVER hardcode credentials in code +- Follow TDD for feature/bugfix work when practical. +- Prioritize regression tests for changed logic/contracts. +- Real DB tests must use `settings.test.*`; never hardcode test credentials. diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py index 72de0bd..23eed2b 100644 --- a/backend/src/core/agentscope/events/store.py +++ b/backend/src/core/agentscope/events/store.py @@ -9,7 +9,7 @@ from core.logging import get_logger from models.agent_chat_message import AgentChatMessageRole from models.agent_chat_session import AgentChatSessionStatus from schemas.agent.system_agent import AgentType -from schemas.agent.runtime_models import AgentOutput, ToolAgentOutput +from schemas.agent.runtime_models import AgentOutput, RouterAgentOutput, ToolAgentOutput from schemas.agent.visibility import SystemVisibilityBit, bit_mask from schemas.messages.chat_message import AgentChatMessageMetadata @@ -79,6 +79,14 @@ class SqlAlchemyEventStore: session_repo=session_repo, message_repo=message_repo, ) + elif event_type == "STEP_FINISHED": + await self._persist_router_step_output( + event=event, + session_id=session_id, + chat_session=chat_session, + session_repo=session_repo, + message_repo=message_repo, + ) elif event_type == "TOOL_CALL_RESULT": await self._persist_tool_call_result( event=event, @@ -199,6 +207,95 @@ class SqlAlchemyEventStore: cost_delta=cost, ) + async def _persist_router_step_output( + self, + *, + event: dict[str, Any], + session_id: UUID, + chat_session: Any, + session_repo: SessionRepository, + message_repo: MessageRepository, + ) -> None: + step_name = self._event_value(event, "stepName") + if not isinstance(step_name, str) or step_name.strip().lower() != "router": + return + + run_id = self._event_value(event, "runId") + run_id_value = run_id if isinstance(run_id, str) and run_id else None + if run_id_value is None: + return + + persist_payload = event.get("_router_persist") + if not isinstance(persist_payload, dict): + return + + router_output_raw = persist_payload.get("router_output") + response_metadata_raw = persist_payload.get("response_metadata") + if not isinstance(router_output_raw, dict): + return + + response_metadata = ( + response_metadata_raw if isinstance(response_metadata_raw, dict) else {} + ) + model_code_raw = response_metadata.get("model") + model_code = model_code_raw if isinstance(model_code_raw, str) else None + input_tokens = self._to_int(response_metadata.get("inputTokens")) + output_tokens = self._to_int(response_metadata.get("outputTokens")) + token_delta = input_tokens + output_tokens + cost = self._to_decimal(response_metadata.get("cost")) + latency_ms = self._to_int_or_none(response_metadata.get("latencyMs")) + + try: + router_output = RouterAgentOutput.model_validate(router_output_raw) + metadata_model = AgentChatMessageMetadata( + run_id=run_id_value, + agent_type=AgentType.ROUTER, + router_agent_output=router_output, + ) + except Exception: + self._logger.warning( + "invalid router metadata payload", + run_id=run_id_value, + ) + return + + content = "" + + locked_session = await session_repo.lock_session_for_update( + session_id=session_id + ) + if locked_session is None: + return + seq = int(getattr(locked_session, "message_count", 0) or 0) + 1 + await message_repo.append_message( + session_id=session_id, + seq=seq, + role=AgentChatMessageRole.ASSISTANT, + content=content, + model_code=model_code, + metadata=metadata_model.model_dump(mode="json", exclude_none=True), + input_tokens=input_tokens, + output_tokens=output_tokens, + cost=cost, + latency_ms=latency_ms, + visibility_mask=0, + ) + + current_status = getattr(chat_session, "status", AgentChatSessionStatus.RUNNING) + status = ( + current_status + if isinstance(current_status, AgentChatSessionStatus) + else AgentChatSessionStatus.RUNNING + ) + await self._update_session_state( + session_repo=session_repo, + chat_session=chat_session, + status=status, + message_delta=1, + token_delta=token_delta, + cost_delta=cost, + ) + async def _persist_tool_call_result( self, *, diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index e30a874..1fa0092 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -16,7 +16,7 @@ from core.agentscope.schemas.agui_input import extract_latest_user_payload from core.agentscope.runtime.json_react_agent import JsonReActAgent from core.agentscope.runtime.model_tracking import TrackingChatModel from core.agentscope.runtime.stage_emitter import PipelineStageEmitter -from core.agentscope.tools.tool_config import AgentTool +from core.agentscope.tools.tool_config import AgentTool, resolve_tool_function_names from core.agentscope.tools.toolkit import build_toolkit from core.agentscope.utils import ( finalize_json_response, @@ -123,7 +123,11 @@ class AgentScopeRunner: owner_id: UUID, enabled_tools: list[AgentTool], ) -> Any: - tool_names = [t.value for t in enabled_tools] if enabled_tools else [] + tool_names = ( + sorted(resolve_tool_function_names(set(enabled_tools))) + if enabled_tools + else [] + ) return build_toolkit( session=session, owner_id=owner_id, @@ -189,6 +193,14 @@ class AgentScopeRunner: run_input=run_input, step_name=AgentType.ROUTER.value, event_type="STEP_FINISHED", + extra_event={ + "_router_persist": { + "router_output": router_output.model_dump( + mode="json", exclude_none=True + ), + "response_metadata": router_result.response_metadata, + } + }, ) return router_output @@ -382,11 +394,13 @@ class AgentScopeRunner: self, *, stage_config: SystemAgentRuntimeConfig ) -> TrackingChatModel: generate_kwargs: dict[str, Any] = { - "temperature": stage_config.llm_config.temperature, - "max_tokens": stage_config.llm_config.max_tokens, "timeout": stage_config.llm_config.timeout_seconds, "extra_body": {"enable_thinking": False}, } + if stage_config.llm_config.temperature is not None: + generate_kwargs["temperature"] = stage_config.llm_config.temperature + if stage_config.llm_config.max_tokens is not None: + generate_kwargs["max_tokens"] = stage_config.llm_config.max_tokens model = OpenAIChatModel( model_name=stage_config.model_code, @@ -423,15 +437,19 @@ class AgentScopeRunner: run_input: RunAgentInput, step_name: str, event_type: str, + extra_event: dict[str, Any] | None = None, ) -> None: + payload: dict[str, Any] = { + "type": event_type, + "threadId": run_input.thread_id, + "runId": run_input.run_id, + "stepName": step_name, + } + if extra_event: + payload.update(extra_event) await pipeline.emit( session_id=run_input.thread_id, - event={ - "type": event_type, - "threadId": run_input.thread_id, - "runId": run_input.run_id, - "stepName": step_name, - }, + event=payload, ) def _resolve_runtime_client_time( diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py index 04e6adf..43cbec6 100644 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ b/backend/src/core/agentscope/tools/custom/calendar.py @@ -52,6 +52,50 @@ class CalendarShareInvitee(BaseModel): ) +class CalendarWriteOperation(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["create", "update", "delete"] = Field( + description="Action type for this operation item." + ) + event_id: str | None = Field( + default=None, + description="Event id required for update/delete.", + ) + title: str | None = Field(default=None, description="Event title.") + description: str | None = Field(default=None, description="Event description.") + start_at: str | None = Field( + default=None, + description="Start time in ISO 8601 with timezone offset.", + ) + end_at: str | None = Field( + default=None, + description="End time in ISO 8601 with timezone offset.", + ) + event_timezone: str | None = Field( + default=None, + description="IANA timezone for the event.", + ) + location: str | None = Field(default=None, description="Event location.") + color: str | None = Field(default=None, description="Event color.") + reminder_minutes: int | None = Field( + default=None, + ge=0, + le=10080, + description="Reminder minutes before event start.", + ) + status: Literal["active", "completed", "canceled", "archived"] | None = Field( + default=None, + description="Optional status for update action.", + ) + + +class CalendarWriteBatchArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + operations: list[CalendarWriteOperation] = Field(min_length=1, max_length=20) + + def _validate_runtime_context( *, tool_name: str, @@ -178,125 +222,48 @@ async def calendar_read( async def calendar_write( operations: Annotated[ - list[Literal["create", "update", "delete"]], + list[CalendarWriteOperation], Field( description=( - "Batch operations list. Each item must be create, update, or delete." + "Batch operation objects. Each item includes action and its fields. " + "Use create/update/delete in a single call." ), min_length=1, max_length=20, ), ], - event_ids: Annotated[ - list[str | None] | None, - Field( - description=( - "Optional event id list aligned with operations. " - "Required for update/delete item." - ) - ), - ] = None, - titles: Annotated[ - list[str | None] | None, - Field(description="Optional title list aligned with operations."), - ] = None, - descriptions: Annotated[ - list[str | None] | None, - Field(description="Optional description list aligned with operations."), - ] = None, - start_ats: Annotated[ - list[str | None] | None, - Field( - description=( - "Optional start time list aligned with operations, ISO 8601 with timezone." - ) - ), - ] = None, - end_ats: Annotated[ - list[str | None] | None, - Field( - description=( - "Optional end time list aligned with operations, ISO 8601 with timezone." - ) - ), - ] = None, - event_timezones: Annotated[ - list[str | None] | None, - Field( - description=( - "Optional event timezone list aligned with operations, IANA timezone." - ) - ), - ] = None, - locations: Annotated[ - list[str | None] | None, - Field(description="Optional location list aligned with operations."), - ] = None, - colors: Annotated[ - list[str | None] | None, - Field(description="Optional color list aligned with operations."), - ] = None, - reminder_minutes_list: Annotated[ - list[int | None] | None, - Field( - description=( - "Optional reminder minutes list aligned with operations, value range 0-10080." - ) - ), - ] = None, - statuses: Annotated[ - list[Literal["active", "completed", "canceled", "archived"] | None] | None, - Field(description="Optional status list aligned with operations."), - ] = None, session: Any = None, owner_id: Any = None, ) -> ToolResponse: - """Batch create/update/delete calendar events using aligned list parameters. + """Batch create/update/delete calendar events using operation objects. Args: - operations: Operation list. Length defines batch size. - event_ids: Optional event id list aligned with operations. - titles: Optional title list aligned with operations. - descriptions: Optional description list aligned with operations. - start_ats: Optional start time list aligned with operations. - end_ats: Optional end time list aligned with operations. - event_timezones: Optional event timezone list aligned with operations. - locations: Optional location list aligned with operations. - colors: Optional color list aligned with operations. - reminder_minutes_list: Optional reminder minute list aligned with operations. - statuses: Optional status list aligned with operations. - - Constraints: - - All provided list parameters must have the same length as operations. - - create item requires start_ats[i] and event_timezones[i]. - - update/delete item requires event_ids[i]. - - start/end datetime must include timezone offset. + operations: Batch operation objects. + - create requires start_at and event_timezone. + - update/delete requires event_id. + - datetime fields must include timezone offset. Returns: ToolResponse with serialized ToolAgentOutput payload. """ tool_name = "calendar_write" + try: + parsed_batch = CalendarWriteBatchArgs.model_validate({"operations": operations}) + except Exception as exc: # noqa: BLE001 + code, message, retryable = map_calendar_exception(exc) + return calendar_error_output( + tool_name=tool_name, + tool_call_args={"operations": operations}, + code=code, + message=message, + retryable=retryable, + ) - def _align_list(name: str, values: list[Any] | None, size: int) -> list[Any | None]: - if values is None: - return [None] * size - if len(values) != size: - raise ValueError(f"{name} 长度必须与 operations 一致") - return list(values) - - batch_size = len(operations) tool_call_args = { - "operations": operations, - "event_ids": event_ids, - "titles": titles, - "descriptions": descriptions, - "start_ats": start_ats, - "end_ats": end_ats, - "event_timezones": event_timezones, - "locations": locations, - "colors": colors, - "reminder_minutes_list": reminder_minutes_list, - "statuses": statuses, + "operations": [ + operation.model_dump(mode="json", exclude_none=True) + for operation in parsed_batch.operations + ] } runtime_error = _validate_runtime_context( tool_name=tool_name, @@ -311,40 +278,26 @@ async def calendar_write( service = create_schedule_service( cast(AsyncSession, session), cast(UUID, owner_id) ) - aligned_event_ids = _align_list("event_ids", event_ids, batch_size) - aligned_titles = _align_list("titles", titles, batch_size) - aligned_descriptions = _align_list("descriptions", descriptions, batch_size) - aligned_start_ats = _align_list("start_ats", start_ats, batch_size) - aligned_end_ats = _align_list("end_ats", end_ats, batch_size) - aligned_event_timezones = _align_list( - "event_timezones", event_timezones, batch_size - ) - aligned_locations = _align_list("locations", locations, batch_size) - aligned_colors = _align_list("colors", colors, batch_size) - aligned_reminders = _align_list( - "reminder_minutes_list", reminder_minutes_list, batch_size - ) - aligned_statuses = _align_list("statuses", statuses, batch_size) success_count = 0 failed_count = 0 success_event_ids: list[str] = [] result_items: list[dict[str, Any]] = [] - for idx, operation in enumerate(operations): - event_id = aligned_event_ids[idx] - title = aligned_titles[idx] - description = aligned_descriptions[idx] - start_at = aligned_start_ats[idx] - end_at = aligned_end_ats[idx] - event_timezone = aligned_event_timezones[idx] - location = aligned_locations[idx] - color = aligned_colors[idx] - reminder_minutes = aligned_reminders[idx] - status = aligned_statuses[idx] + for operation in parsed_batch.operations: + event_id = operation.event_id + title = operation.title + description = operation.description + start_at = operation.start_at + end_at = operation.end_at + event_timezone = operation.event_timezone + location = operation.location + color = operation.color + reminder_minutes = operation.reminder_minutes + status = operation.status try: - if operation == "create": + if operation.action == "create": if start_at is None or not start_at.strip(): raise ValueError( "创建日程需要提供 start_at,且必须包含时区偏移" @@ -385,7 +338,7 @@ async def calendar_write( success_event_ids.append(str(created.id)) continue - if operation == "update": + if operation.action == "update": if event_id is None or not event_id.strip(): raise ValueError("更新日程需要提供 event_id") parsed_event_id = UUID(event_id) @@ -429,7 +382,7 @@ async def calendar_write( success_event_ids.append(str(updated.id)) continue - if operation == "delete": + if operation.action == "delete": if event_id is None or not event_id.strip(): raise ValueError("删除日程需要提供 event_id") await service.delete(UUID(event_id)) diff --git a/backend/src/core/agentscope/tools/custom/memory.py b/backend/src/core/agentscope/tools/custom/memory.py index 80c7bef..87c0631 100644 --- a/backend/src/core/agentscope/tools/custom/memory.py +++ b/backend/src/core/agentscope/tools/custom/memory.py @@ -16,7 +16,7 @@ from core.agentscope.tools.utils.tool_response_builder import ( build_tool_response, ) from models.memories import MemoryType -from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus +from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent @@ -38,6 +38,12 @@ class MemoryWriteArgs(BaseModel): return self +class MemoryWriteBatchArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + operations: list[MemoryWriteArgs] = Field(min_length=1, max_length=20) + + class MemoryForgetArgs(BaseModel): model_config = ConfigDict(extra="forbid") @@ -70,6 +76,12 @@ class MemoryForgetArgs(BaseModel): return self +class MemoryForgetBatchArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + operations: list[MemoryForgetArgs] = Field(min_length=1, max_length=20) + + def _memory_error_output( *, tool_name: str, @@ -149,28 +161,45 @@ def _delete_nested_path(payload: dict[str, Any], keys: list[str]) -> bool: return False +def _compact_result_items(items: list[dict[str, object]]) -> str: + return ",".join( + "{" + ",".join(f"{key}={value}" for key, value in item.items()) + "}" + for item in items + ) + + async def memory_write( - memory_type: Annotated[ - str, - Field(description="Memory type: user or work."), - ] = "user", - user_content: Annotated[ - UserMemoryContent | None, - Field(description="Patch payload for user memory content."), - ] = None, - work_content: Annotated[ - WorkProfileContent | None, - Field(description="Patch payload for work memory content."), - ] = None, + operations: Annotated[ + list[MemoryWriteArgs], + Field( + description=( + "Batch memory write operations. Each item must include memory_type and " + "the matching content object (user_content or work_content)." + ), + min_length=1, + max_length=20, + ), + ], session: Any = None, owner_id: Any = None, ) -> ToolResponse: + """Merge structured facts into user/work memory. + + Args: + memory_type: Target memory domain, either ``user`` or ``work``. + user_content: Partial user-memory payload when ``memory_type='user'``. + work_content: Partial work-memory payload when ``memory_type='work'``. + + Runtime: + ``session`` and ``owner_id`` are injected by toolkit preset kwargs. + + Returns: + ToolResponse wrapping ToolAgentOutput. + - success: ``result`` contains a compact status summary. + - failure: ``error`` contains structured code/message/retryable metadata. + """ tool_name = "memory_write" - tool_call_args: dict[str, Any] = { - "memory_type": memory_type, - "user_content": user_content, - "work_content": work_content, - } + tool_call_args: dict[str, Any] = {"operations": operations} runtime_error = _validate_runtime_context( tool_name=tool_name, tool_call_args=tool_call_args, @@ -181,52 +210,117 @@ async def memory_write( return runtime_error try: - parsed_args = MemoryWriteArgs.model_validate(tool_call_args) + parsed_batch = MemoryWriteBatchArgs.model_validate(tool_call_args) service = create_memories_service( session=cast(AsyncSession, session), owner_id=cast(UUID, owner_id), ) - existing = await service.get_memory_model(memory_type=parsed_args.memory_type) + success_count = 0 + failed_count = 0 + updated_types: list[str] = [] + failed_operations: list[dict[str, object]] = [] + result_items: list[dict[str, object]] = [] + for idx, op in enumerate(parsed_batch.operations): + try: + existing = await service.get_memory_model(memory_type=op.memory_type) + if op.memory_type == MemoryType.USER: + base_model = ( + UserMemoryContent.model_validate(existing.content) + if existing is not None + else UserMemoryContent() + ) + patch_model = cast(UserMemoryContent, op.user_content) + merged = _deep_merge_dict( + base_model.model_dump(), + patch_model.model_dump(exclude_unset=True), + ) + validated = UserMemoryContent.model_validate(merged) + updated = await service.update_user_memory(content=validated) + else: + base_model = ( + WorkProfileContent.model_validate(existing.content) + if existing is not None + else WorkProfileContent() + ) + patch_model = cast(WorkProfileContent, op.work_content) + merged = _deep_merge_dict( + base_model.model_dump(), + patch_model.model_dump(exclude_unset=True), + ) + validated = WorkProfileContent.model_validate(merged) + updated = await service.update_work_memory(content=validated) - if parsed_args.memory_type == MemoryType.USER: - base_model = ( - UserMemoryContent.model_validate(existing.content) - if existing is not None - else UserMemoryContent() - ) - patch_model = cast(UserMemoryContent, parsed_args.user_content) - merged = _deep_merge_dict( - base_model.model_dump(), - patch_model.model_dump(exclude_unset=True), - ) - validated = UserMemoryContent.model_validate(merged) - await service.update_user_memory( - content=validated, - ) - else: - base_model = ( - WorkProfileContent.model_validate(existing.content) - if existing is not None - else WorkProfileContent() - ) - patch_model = cast(WorkProfileContent, parsed_args.work_content) - merged = _deep_merge_dict( - base_model.model_dump(), - patch_model.model_dump(exclude_unset=True), - ) - validated = WorkProfileContent.model_validate(merged) - await service.update_work_memory( - content=validated, - ) + success_count += 1 + updated_types.append(op.memory_type.value) + memory_id = str( + getattr(updated, "id", None) + or (getattr(existing, "id", None) if existing is not None else "") + or "" + ) + result_items.append( + { + "idx": idx, + "memoryType": op.memory_type.value, + "status": "success", + "memoryId": memory_id, + } + ) + except Exception as exc: # noqa: BLE001 + failed_count += 1 + code, message, retryable = map_memory_exception(exc) + failed_operations.append( + { + "memory_type": op.memory_type.value, + "code": code, + "message": message, + "retryable": retryable, + } + ) + result_items.append( + { + "idx": idx, + "memoryType": op.memory_type.value, + "status": "failure", + "code": code, + } + ) - summary = f"status=success memory_type={parsed_args.memory_type.value}" + status = ( + ToolStatus.SUCCESS + if failed_count == 0 + else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL) + ) + status_text = ( + "success" + if status == ToolStatus.SUCCESS + else ("failure" if status == ToolStatus.FAILURE else "partial") + ) + + summary = ( + f"status={status_text} " + f"success={success_count} failed={failed_count} " + f"updated_types=[{','.join(updated_types)}]" + ) + compact_items = _compact_result_items(result_items) + if compact_items: + summary = f"{summary} items=[{compact_items}]" + error_info: ErrorInfo | None = None + if failed_operations: + first = failed_operations[0] + error_info = ErrorInfo( + code=str(first.get("code") or "MEMORY_BATCH_FAILED"), + message=str(first.get("message") or "memory batch write failed"), + retryable=bool(first.get("retryable") is True), + details={"failed_operations": failed_operations}, + ) return build_tool_response( ToolAgentOutput( tool_name=tool_name, tool_call_id=get_current_tool_call_id(tool_name=tool_name), tool_call_args=tool_call_args, - status=ToolStatus.SUCCESS, + status=status, result=summary, + error=error_info, ) ) except Exception as exc: # noqa: BLE001 @@ -241,22 +335,38 @@ async def memory_write( async def memory_forget( - memory_type: Annotated[ - str, - Field(description="Memory type: user or work."), - ] = "user", - forget_paths: Annotated[ - list[str] | None, - Field(description="Dot paths to remove from content."), - ] = None, + operations: Annotated[ + list[MemoryForgetArgs], + Field( + description=( + "Batch memory forget operations. Each item must include memory_type and " + "forget_paths." + ), + min_length=1, + max_length=20, + ), + ], session: Any = None, owner_id: Any = None, ) -> ToolResponse: + """Forget selected paths from user/work memory content. + + Args: + memory_type: Target memory domain, either ``user`` or ``work``. + forget_paths: Dot-path list to remove from memory content. + + Notes: + - Path root must belong to the target memory schema. + - The tool is idempotent; missing paths are skipped safely. + + Runtime: + ``session`` and ``owner_id`` are injected by toolkit preset kwargs. + + Returns: + ToolResponse wrapping ToolAgentOutput with compact execution summary. + """ tool_name = "memory_forget" - tool_call_args: dict[str, Any] = { - "memory_type": memory_type, - "forget_paths": forget_paths or [], - } + tool_call_args: dict[str, Any] = {"operations": operations} runtime_error = _validate_runtime_context( tool_name=tool_name, tool_call_args=tool_call_args, @@ -267,56 +377,120 @@ async def memory_forget( return runtime_error try: - parsed_args = MemoryForgetArgs.model_validate(tool_call_args) + parsed_batch = MemoryForgetBatchArgs.model_validate(tool_call_args) service = create_memories_service( session=cast(AsyncSession, session), owner_id=cast(UUID, owner_id), ) - existing = await service.get_memory_model(memory_type=parsed_args.memory_type) - if existing is None: - summary = f"status=success memory_type={parsed_args.memory_type.value} forgotten=0" - return build_tool_response( - ToolAgentOutput( - tool_name=tool_name, - tool_call_id=get_current_tool_call_id(tool_name=tool_name), - tool_call_args=tool_call_args, - status=ToolStatus.SUCCESS, - result=summary, - ) - ) + success_count = 0 + failed_count = 0 + forgotten_total = 0 + processed_types: list[str] = [] + failed_operations: list[dict[str, object]] = [] + result_items: list[dict[str, object]] = [] + for idx, op in enumerate(parsed_batch.operations): + try: + existing = await service.get_memory_model(memory_type=op.memory_type) + if existing is None: + success_count += 1 + processed_types.append(op.memory_type.value) + result_items.append( + { + "idx": idx, + "memoryType": op.memory_type.value, + "status": "success", + "forgotten": 0, + "memoryId": "", + } + ) + continue - if parsed_args.memory_type == MemoryType.USER: - base_model = UserMemoryContent.model_validate(existing.content) - updated_dict, removed_paths = _remove_content_paths( - base_model.model_dump(), - parsed_args.forget_paths, - ) - validated = UserMemoryContent.model_validate(updated_dict) - await service.update_user_memory( - content=validated, - ) - else: - base_model = WorkProfileContent.model_validate(existing.content) - updated_dict, removed_paths = _remove_content_paths( - base_model.model_dump(), - parsed_args.forget_paths, - ) - validated = WorkProfileContent.model_validate(updated_dict) - await service.update_work_memory( - content=validated, - ) + if op.memory_type == MemoryType.USER: + base_model = UserMemoryContent.model_validate(existing.content) + updated_dict, removed_paths = _remove_content_paths( + base_model.model_dump(), + op.forget_paths, + ) + validated = UserMemoryContent.model_validate(updated_dict) + await service.update_user_memory(content=validated) + else: + base_model = WorkProfileContent.model_validate(existing.content) + updated_dict, removed_paths = _remove_content_paths( + base_model.model_dump(), + op.forget_paths, + ) + validated = WorkProfileContent.model_validate(updated_dict) + await service.update_work_memory(content=validated) + + forgotten_total += len(removed_paths) + success_count += 1 + processed_types.append(op.memory_type.value) + result_items.append( + { + "idx": idx, + "memoryType": op.memory_type.value, + "status": "success", + "forgotten": len(removed_paths), + "memoryId": str(getattr(existing, "id", "") or ""), + } + ) + except Exception as exc: # noqa: BLE001 + failed_count += 1 + code, message, retryable = map_memory_exception(exc) + failed_operations.append( + { + "memory_type": op.memory_type.value, + "code": code, + "message": message, + "retryable": retryable, + } + ) + result_items.append( + { + "idx": idx, + "memoryType": op.memory_type.value, + "status": "failure", + "code": code, + } + ) + + status = ( + ToolStatus.SUCCESS + if failed_count == 0 + else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL) + ) + status_text = ( + "success" + if status == ToolStatus.SUCCESS + else ("failure" if status == ToolStatus.FAILURE else "partial") + ) summary = ( - f"status=success memory_type={parsed_args.memory_type.value} forgotten={len(removed_paths)} " - f"skipped=0" + f"status={status_text} " + f"success={success_count} failed={failed_count} " + f"forgotten={forgotten_total} " + f"processed_types=[{','.join(processed_types)}]" ) + compact_items = _compact_result_items(result_items) + if compact_items: + summary = f"{summary} items=[{compact_items}]" + error_info: ErrorInfo | None = None + if failed_operations: + first = failed_operations[0] + error_info = ErrorInfo( + code=str(first.get("code") or "MEMORY_BATCH_FAILED"), + message=str(first.get("message") or "memory batch forget failed"), + retryable=bool(first.get("retryable") is True), + details={"failed_operations": failed_operations}, + ) return build_tool_response( ToolAgentOutput( tool_name=tool_name, tool_call_id=get_current_tool_call_id(tool_name=tool_name), tool_call_args=tool_call_args, - status=ToolStatus.SUCCESS, + status=status, result=summary, + error=error_info, ) ) except Exception as exc: # noqa: BLE001 diff --git a/backend/src/core/automation/scheduler.py b/backend/src/core/automation/scheduler.py index 6036f5e..69b70cd 100644 --- a/backend/src/core/automation/scheduler.py +++ b/backend/src/core/automation/scheduler.py @@ -83,8 +83,10 @@ async def _dispatch_automation_run( "content": input_text, } ], + "tools": [], + "context": [], "forwardedProps": { - "runtimeMode": RuntimeMode.AUTOMATION.value, + "runtime_mode": RuntimeMode.AUTOMATION.value, }, } diff --git a/backend/src/core/config/static/automation/memory_extraction.yaml b/backend/src/core/config/static/automation/memory_extraction.yaml index 1a11dca..4cda686 100644 --- a/backend/src/core/config/static/automation/memory_extraction.yaml +++ b/backend/src/core/config/static/automation/memory_extraction.yaml @@ -1,4 +1,11 @@ -input_template: 请基于最近两天用户聊天上下文提取用户记忆;如果已有记忆内容变化请更新;如果记忆已失效请执行遗忘。 +input_template: | + 你正在执行自动化记忆提取任务。必须只使用 memory_forget 与 memory_write,不要执行任何 calendar 或 user_lookup 工具。 + 步骤1:基于最近两天聊天上下文,抽取“有证据支持”的用户长期偏好变化,禁止编造。 + 步骤2:对已失效或被用户明确否定的信息,调用 memory_forget 执行遗忘。 + 步骤3:对新增或变化的信息,调用 memory_write 执行写入。 + 步骤4:两类工具都必须使用批量参数 operations(对象数组),并保证参数是结构化 JSON,不要把数组或对象写成字符串。 + 步骤5:只写入被证据覆盖的最小字段集;无证据字段不要写。 + 输出要求:仅基于工具结果给出一句执行摘要(包含 success/failed 计数)。 enabled_tools: - memory.write - memory.forget diff --git a/backend/src/core/config/static/database/llm_catalog.yaml b/backend/src/core/config/static/database/llm_catalog.yaml index 92d8055..a389d04 100644 --- a/backend/src/core/config/static/database/llm_catalog.yaml +++ b/backend/src/core/config/static/database/llm_catalog.yaml @@ -61,3 +61,14 @@ llms: input_cost_per_token: 0.000002 output_cost_per_token: 0.000003 cache_hit_cost_per_token: 0.0000002 + + - model_code: qwen3.5-27b + factory_name: dashscope + litellm_model: dashscope/qwen3.5-27b + pricing_tiers: + - max_prompt_tokens: 128000 + input_cost_per_token: 0.0000006 + output_cost_per_token: 0.0000048 + - max_prompt_tokens: 256000 + input_cost_per_token: 0.0000018 + output_cost_per_token: 0.0000144 diff --git a/backend/src/models/memories.py b/backend/src/models/memories.py index 2aeae8a..afb8f8d 100644 --- a/backend/src/models/memories.py +++ b/backend/src/models/memories.py @@ -32,10 +32,6 @@ class Memory(TimestampMixin, Base): UUID(as_uuid=True), nullable=False, ) - agent_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), - nullable=True, - ) memory_type: Mapped[MemoryType] = mapped_column( String(20), nullable=False, diff --git a/backend/src/schemas/automation/__init__.py b/backend/src/schemas/automation/__init__.py index a520783..cb57fb9 100644 --- a/backend/src/schemas/automation/__init__.py +++ b/backend/src/schemas/automation/__init__.py @@ -59,6 +59,10 @@ class AutomationJob(BaseModel): created_at: datetime updated_at: datetime + @property + def is_system(self) -> bool: + return self.bootstrap_key is not None + @classmethod def from_orm(cls, obj: OrmAutomationJob) -> "AutomationJob": return cls( diff --git a/backend/src/schemas/memories/__init__.py b/backend/src/schemas/memories/__init__.py index 05ee45a..d4ccdf8 100644 --- a/backend/src/schemas/memories/__init__.py +++ b/backend/src/schemas/memories/__init__.py @@ -34,7 +34,6 @@ class MemoryModel(BaseModel): id: UUID owner_id: UUID - agent_id: UUID | None = None memory_type: Literal["user", "work"] content: UserMemoryContent | WorkProfileContent status: MemoryStatus diff --git a/backend/src/v1/agent/router.py b/backend/src/v1/agent/router.py index a2f57e2..3879008 100644 --- a/backend/src/v1/agent/router.py +++ b/backend/src/v1/agent/router.py @@ -16,6 +16,7 @@ from core.agentscope.schemas.agui_input import ( ) from core.auth.models import CurrentUser from core.logging import get_logger +from redis.exceptions import TimeoutError as RedisTimeoutError from fastapi import ( APIRouter, Depends, @@ -180,7 +181,7 @@ async def stream_events( last_event_id=cursor, current_user=current_user, ) - except TimeoutError: + except (TimeoutError, RedisTimeoutError): idle_polls += 1 yield ": keep-alive\n\n" await asyncio.sleep(0.2) diff --git a/backend/src/v1/automation_jobs/schemas.py b/backend/src/v1/automation_jobs/schemas.py new file mode 100644 index 0000000..1cef382 --- /dev/null +++ b/backend/src/v1/automation_jobs/schemas.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from datetime import datetime, time +from typing import Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from models.automation_jobs import AutomationJob as OrmAutomationJob +from models.automation_jobs import AutomationJobStatus, ScheduleType +from schemas.automation import ( + AutomationJobConfig, +) + + +class AutomationJobResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: UUID + owner_id: UUID + bootstrap_key: str | None = None + title: str + schedule_type: ScheduleType + run_at: time + timezone: str + status: AutomationJobStatus + is_system: bool + config: AutomationJobConfig + next_run_at: datetime + last_run_at: datetime | None = None + created_at: datetime + updated_at: datetime + + @classmethod + def from_orm(cls, obj: OrmAutomationJob) -> Self: + return cls( + id=obj.id, + owner_id=obj.owner_id, + bootstrap_key=obj.bootstrap_key, + title=obj.title, + schedule_type=obj.schedule_type, + run_at=obj.run_at.time(), + timezone=obj.timezone, + status=obj.status, + is_system=obj.bootstrap_key is not None, + config=AutomationJobConfig.model_validate(obj.config or {}), + next_run_at=obj.next_run_at, + last_run_at=obj.last_run_at, + created_at=obj.created_at, + updated_at=obj.updated_at, + ) + + +class AutomationJobCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str = Field(..., min_length=1, max_length=255) + schedule_type: ScheduleType + run_at: time = Field(..., description="Local time in HH:MM:SS format") + timezone: str = Field(..., min_length=1, max_length=50) + status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE) + config: AutomationJobConfig + + +class AutomationJobUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str | None = Field(None, min_length=1, max_length=255) + schedule_type: ScheduleType | None = None + run_at: time | None = None + timezone: str | None = Field(None, min_length=1, max_length=50) + status: AutomationJobStatus | None = None + config: AutomationJobConfig | None = None + + +class AutomationJobListResponse(BaseModel): + items: list[AutomationJobResponse] diff --git a/backend/tests/unit/core/agentscope/events/test_store.py b/backend/tests/unit/core/agentscope/events/test_store.py index c2fbe84..b2f81ce 100644 --- a/backend/tests/unit/core/agentscope/events/test_store.py +++ b/backend/tests/unit/core/agentscope/events/test_store.py @@ -59,16 +59,6 @@ def _patch_repositories( monkeypatch.setattr(store_module, "MessageRepository", _FakeMessageRepository) monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus) - async def _fake_stage_bit_map(self, *, session: object) -> dict[str, int]: - del self, session - return {"router": 16, "worker": 17, "memory": 18} - - monkeypatch.setattr( - store_module.SqlAlchemyEventStore, - "_load_stage_visibility_bit_map", - _fake_stage_bit_map, - ) - @pytest.mark.asyncio async def test_store_persists_worker_output_with_answer_as_content( @@ -113,7 +103,7 @@ async def test_store_persists_worker_output_with_answer_as_content( assert metadata["agent_output"]["answer"] == "worker-answer" assert metadata["agent_output"]["ui_hints"]["intent"] == "message" assert append_kwargs["cost"] == Decimal("0.123") - assert append_kwargs["visibility_mask"] == ((1 << 0) | (1 << 17)) + assert append_kwargs["visibility_mask"] == ((1 << 0) | (1 << 1)) assert captured["message_delta"] == 1 assert captured["token_delta"] == 8 @@ -153,3 +143,65 @@ async def test_store_persists_tool_output_with_summary_as_content( == "status=success batch=1 success=1 failed=0 ids=[event-1]" ) assert append_kwargs["visibility_mask"] == (1 << 0) + + +@pytest.mark.asyncio +async def test_store_persists_router_step_output_for_cost_tracking( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + fake_chat_session = SimpleNamespace(state_snapshot={}, message_count=10) + _patch_repositories(monkeypatch, captured, fake_chat_session) + + store = store_module.SqlAlchemyEventStore(session_factory=lambda: _FakeSessionCtx()) + await store.persist( + { + "type": "STEP_FINISHED", + "threadId": "00000000-0000-0000-0000-000000000001", + "runId": "run-router-1", + "stepName": "router", + "_router_persist": { + "router_output": { + "normalized_task_input": { + "user_text": "安排明天会议", + "context_summary": "", + }, + "key_entities": [], + "constraints": [], + "task_typing": {"primary": "scheduling"}, + "execution_mode": "tool_assisted", + "result_typing": {"primary": "execution_report"}, + "ui": { + "ui_mode": "none", + "ui_decision_reason": "单任务", + }, + }, + "response_metadata": { + "model": "doubao-seed-1-6-250615", + "inputTokens": 12, + "outputTokens": 8, + "cost": "0.01", + "latencyMs": 320, + }, + }, + } + ) + + append_kwargs = cast(dict[str, Any], captured["append_kwargs"]) + assert append_kwargs["seq"] == 11 + assert append_kwargs["content"] == "" + assert append_kwargs["model_code"] == "doubao-seed-1-6-250615" + assert append_kwargs["input_tokens"] == 12 + assert append_kwargs["output_tokens"] == 8 + assert append_kwargs["latency_ms"] == 320 + assert append_kwargs["cost"] == Decimal("0.01") + assert append_kwargs["visibility_mask"] == 0 + + metadata = cast(dict[str, Any], append_kwargs["metadata"]) + assert sorted(metadata.keys()) == ["agent_type", "router_agent_output", "run_id"] + assert metadata["agent_type"] == "router" + assert metadata["router_agent_output"]["execution_mode"] == "tool_assisted" + + assert captured["message_delta"] == 1 + assert captured["token_delta"] == 20 + assert captured["cost_delta"] == Decimal("0.01") diff --git a/backend/tests/unit/core/agentscope/runtime/test_runner.py b/backend/tests/unit/core/agentscope/runtime/test_runner.py index cd3c743..7994284 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_runner.py +++ b/backend/tests/unit/core/agentscope/runtime/test_runner.py @@ -114,6 +114,39 @@ def test_build_router_messages_skips_injection_when_context_last_is_user() -> No assert msg.content == existing_context[i].content +def test_build_model_omits_none_generate_kwargs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + class _FakeOpenAIChatModel: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(runner_module, "OpenAIChatModel", _FakeOpenAIChatModel) + + runner = AgentScopeRunner() + stage_config = runner_module.SystemAgentRuntimeConfig( + agent_type=AgentType.ROUTER, + model_code="demo", + api_base_url="https://example.com", + api_key="test", + llm_config=runner_module.SystemAgentLLMConfig( + temperature=None, + max_tokens=None, + timeout_seconds=30.0, + ), + ) + + model = runner._build_model(stage_config=stage_config) + + assert isinstance(model, runner_module.TrackingChatModel) + assert captured["generate_kwargs"] == { + "timeout": 30.0, + "extra_body": {"enable_thinking": False}, + } + + @pytest.mark.asyncio async def test_resolve_runtime_client_time_from_forwarded_props() -> None: runner = AgentScopeRunner() diff --git a/backend/tests/unit/core/agentscope/test_calendar_tools.py b/backend/tests/unit/core/agentscope/test_calendar_tools.py index ce18c2a..b0d87a1 100644 --- a/backend/tests/unit/core/agentscope/test_calendar_tools.py +++ b/backend/tests/unit/core/agentscope/test_calendar_tools.py @@ -27,6 +27,7 @@ class _FakeService: created_request: Any = None created_id: str = field(default_factory=lambda: str(uuid4())) list_calls: list[dict[str, Any]] = field(default_factory=list) + deleted_ids: list[str] = field(default_factory=list) async def list_paginated( self, *, page: int, page_size: int, query: str | None = None @@ -57,10 +58,20 @@ class _FakeService: metadata=request.metadata, ) + async def delete(self, item_id: UUID) -> None: + self.deleted_ids.append(str(item_id)) + + async def share(self, item_id: UUID, request: Any) -> None: + if not hasattr(self, "share_calls"): + self.share_calls = [] + self.share_calls.append({"item_id": str(item_id), "request": request}) + @pytest.mark.asyncio async def test_calendar_write_requires_runtime_context() -> None: - result = await calendar_module.calendar_write(operations=["create"]) + result = await calendar_module.calendar_write( + operations=[calendar_module.CalendarWriteOperation(action="create")] + ) payload = _decode_tool_response(result) assert payload["status"] == "failure" @@ -77,8 +88,12 @@ async def test_calendar_write_create_requires_start_at( ) result = await calendar_module.calendar_write( - operations=["create"], - event_timezones=["Asia/Shanghai"], + operations=[ + calendar_module.CalendarWriteOperation( + action="create", + event_timezone="Asia/Shanghai", + ) + ], session=SimpleNamespace(), owner_id=uuid4(), ) @@ -99,8 +114,12 @@ async def test_calendar_write_create_requires_event_timezone( ) result = await calendar_module.calendar_write( - operations=["create"], - start_ats=["2026-03-16T09:00:00+08:00"], + operations=[ + calendar_module.CalendarWriteOperation( + action="create", + start_at="2026-03-16T09:00:00+08:00", + ) + ], session=SimpleNamespace(), owner_id=uuid4(), ) @@ -121,9 +140,13 @@ async def test_calendar_write_rejects_naive_start_at( ) result = await calendar_module.calendar_write( - operations=["create"], - start_ats=["2026-03-16T09:00:00"], - event_timezones=["Asia/Shanghai"], + operations=[ + calendar_module.CalendarWriteOperation( + action="create", + start_at="2026-03-16T09:00:00", + event_timezone="Asia/Shanghai", + ) + ], session=SimpleNamespace(), owner_id=uuid4(), ) @@ -144,11 +167,15 @@ async def test_calendar_write_create_normalizes_to_utc( ) result = await calendar_module.calendar_write( - operations=["create"], - titles=["晨会"], - start_ats=["2026-03-16T09:00:00+08:00"], - end_ats=["2026-03-16T10:00:00+08:00"], - event_timezones=["Asia/Shanghai"], + operations=[ + calendar_module.CalendarWriteOperation( + action="create", + title="晨会", + start_at="2026-03-16T09:00:00+08:00", + end_at="2026-03-16T10:00:00+08:00", + event_timezone="Asia/Shanghai", + ) + ], session=SimpleNamespace(), owner_id=uuid4(), ) @@ -166,7 +193,7 @@ async def test_calendar_write_create_normalizes_to_utc( @pytest.mark.asyncio -async def test_calendar_write_rejects_misaligned_batch_lists( +async def test_calendar_write_batch_supports_create_and_delete( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_service = _FakeService() @@ -175,17 +202,26 @@ async def test_calendar_write_rejects_misaligned_batch_lists( ) result = await calendar_module.calendar_write( - operations=["create", "delete"], - start_ats=["2026-03-16T09:00:00+08:00"], - event_timezones=["Asia/Shanghai", "Asia/Shanghai"], + operations=[ + calendar_module.CalendarWriteOperation( + action="create", + title="晨会", + start_at="2026-03-16T09:00:00+08:00", + event_timezone="Asia/Shanghai", + ), + calendar_module.CalendarWriteOperation( + action="delete", + event_id=str(uuid4()), + ), + ], session=SimpleNamespace(), owner_id=uuid4(), ) payload = _decode_tool_response(result) - assert payload["status"] == "failure" - assert payload["error"]["code"] == "INVALID_ARGUMENT" - assert "长度必须与 operations 一致" in payload["error"]["message"] + assert payload["status"] == "success" + assert "success=2" in payload["result"] + assert len(fake_service.deleted_ids) == 1 @pytest.mark.asyncio @@ -214,3 +250,45 @@ async def test_calendar_read_returns_structured_result_with_ids( assert "status=" in payload["result"] assert fake_service.created_id in payload["result"] assert fake_service.list_calls == [{"page": 1, "page_size": 20, "query": "会议"}] + + +@pytest.mark.asyncio +async def test_calendar_share_executes_with_valid_invitee( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_service = _FakeService() + monkeypatch.setattr( + calendar_module, "create_schedule_service", lambda *_: fake_service + ) + target_user_id = str(uuid4()) + monkeypatch.setattr( + calendar_module, + "resolve_share_target_phone_map", + lambda user_ids: {target_user_id: "+8613900001234"} + if target_user_id in user_ids + else {}, + ) + + event_id = str(uuid4()) + result = await calendar_module.calendar_share( + event_id=event_id, + invitees=[ + calendar_module.CalendarShareInvitee( + userId=target_user_id, + permissionView=True, + permissionEdit=False, + permissionInvite=False, + ) + ], + session=SimpleNamespace(), + owner_id=uuid4(), + ) + payload = _decode_tool_response(result) + + assert payload["status"] == "success" + assert payload["result"].startswith("status=success invited_count=1") + assert "+8613900001234" in payload["result"] + assert len(fake_service.share_calls) == 1 + share_call = fake_service.share_calls[0] + assert share_call["item_id"] == event_id + assert share_call["request"].phone == "+8613900001234" diff --git a/backend/tests/unit/core/agentscope/test_memory_tools.py b/backend/tests/unit/core/agentscope/test_memory_tools.py index aa2d0e1..ce8bbd7 100644 --- a/backend/tests/unit/core/agentscope/test_memory_tools.py +++ b/backend/tests/unit/core/agentscope/test_memory_tools.py @@ -60,8 +60,12 @@ def _user_memory(): @pytest.mark.asyncio async def test_memory_write_requires_runtime_context() -> None: response = await memory_module.memory_write( - memory_type="user", - user_content=UserMemoryContent(interests=["跑步"]), + operations=[ + memory_module.MemoryWriteArgs( + memory_type=MemoryType.USER, + user_content=UserMemoryContent(interests=["跑步"]), + ) + ], ) payload = _decode_tool_response(response) assert payload["status"] == "failure" @@ -78,15 +82,20 @@ async def test_memory_write_updates_user_content( ) response = await memory_module.memory_write( - memory_type="user", - user_content=UserMemoryContent(interests=["阅读"]), + operations=[ + memory_module.MemoryWriteArgs( + memory_type=MemoryType.USER, + user_content=UserMemoryContent(interests=["阅读"]), + ) + ], session=SimpleNamespace(), owner_id=uuid4(), ) payload = _decode_tool_response(response) assert payload["status"] == "success" - assert "memory_type=user" in str(payload["result"]) + assert "success=1" in str(payload["result"]) + assert "updated_types=[user]" in str(payload["result"]) assert fake_service.updated_user == 1 @@ -101,13 +110,61 @@ async def test_memory_forget_updates_content_paths( ) response = await memory_module.memory_forget( - memory_type="user", - forget_paths=["preferences.communication_style"], + operations=[ + memory_module.MemoryForgetArgs( + memory_type=MemoryType.USER, + forget_paths=["preferences.communication_style"], + ) + ], session=SimpleNamespace(), owner_id=uuid4(), ) payload = _decode_tool_response(response) assert payload["status"] == "success" + assert "success=1" in str(payload["result"]) assert "forgotten=1" in str(payload["result"]) assert fake_service.updated_user == 1 + + +@pytest.mark.asyncio +async def test_memory_write_partial_status_contains_error_details( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_service = _FakeMemoriesService() + call_count = 0 + + async def _update_user_memory(**kwargs): + nonlocal call_count + _ = kwargs + call_count += 1 + if call_count == 2: + raise ValueError("invalid payload") + fake_service.updated_user += 1 + return SimpleNamespace() + + fake_service.update_user_memory = _update_user_memory # type: ignore[method-assign] + monkeypatch.setattr( + memory_module, "create_memories_service", lambda **_: fake_service + ) + + response = await memory_module.memory_write( + operations=[ + memory_module.MemoryWriteArgs( + memory_type=MemoryType.USER, + user_content=UserMemoryContent(interests=["阅读"]), + ), + memory_module.MemoryWriteArgs( + memory_type=MemoryType.USER, + user_content=UserMemoryContent(interests=["跑步"]), + ), + ], + session=SimpleNamespace(), + owner_id=uuid4(), + ) + payload = _decode_tool_response(response) + + assert payload["status"] == "partial" + assert "status=partial" in str(payload["result"]) + assert "failed=1" in str(payload["result"]) + assert _payload_error_code(payload) in {"INVALID_ARGUMENT", "UNKNOWN_ERROR"} diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 1846912..85a2183 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -1,13 +1,14 @@ -# 环境变量配置模板(复制到 .env 并填写实际值) +# 环境变量配置模板(复制到 deploy/.env.prod 并填写实际值) # 警告:切勿将包含真实密钥的 .env 提交到代码仓库 ############ # 运行时配置 ############ -SOCIAL_RUNTIME__ENVIRONMENT=dev -SOCIAL_RUNTIME__DEBUG=true +SOCIAL_RUNTIME__ENVIRONMENT=prod +SOCIAL_RUNTIME__DEBUG=false SOCIAL_RUNTIME__LOG_LEVEL=INFO SOCIAL_RUNTIME__SQL_LOG_QUERIES=false +SOCIAL_RUNTIME__TRUSTED_PROXY_IPS=[] ############ # Web 服务器配置(Uvicorn) @@ -20,7 +21,7 @@ SOCIAL_WEB__WORKERS=2 # Redis 配置 ############ SOCIAL_REDIS__PASSWORD=redis-secure-2026 -SOCIAL_REDIS__HOST=localhost +SOCIAL_REDIS__HOST=redis SOCIAL_REDIS__PORT=6379 SOCIAL_REDIS__DB=0 @@ -29,10 +30,16 @@ SOCIAL_REDIS__DB=0 ############ # agent: 常规异步任务 # automation: 批处理/重计算/可延迟任务 -SOCIAL_WORKER__GROUPS__AGENT__CONCURRENCY=2 - +SOCIAL_WORKER__GROUPS__AGENT__CONCURRENCY=3 SOCIAL_WORKER__GROUPS__AUTOMATION__CONCURRENCY=1 +############ +# Automation 调度器配置 +############ +SOCIAL_AUTOMATION_SCHEDULER__ENABLED=true +SOCIAL_AUTOMATION_SCHEDULER__INTERVAL_SECONDS=60 +SOCIAL_AUTOMATION_SCHEDULER__BATCH_LIMIT=100 + ############ # Taskiq(可选,默认回落到 Redis URL) ############ diff --git a/deploy/README.md b/deploy/README.md index abd9c52..9822ce1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -2,7 +2,7 @@ 本目录是单机 `docker compose` 的生产交付包,架构为: -- 应用层:`web + worker-critical + worker-default + worker-bulk + init-job` +- 应用层:`web + worker-agent + worker-automation + scheduler + init-job` - 中间件:`redis` - 数据与认证:云 Supabase(通过环境变量访问) - 反向代理:由服务器侧 nginx 托管(不在本目录编排) @@ -81,7 +81,7 @@ cp deploy/.env.prod.example deploy/.env.prod ### 2) 启动常驻服务 ```bash -docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d redis web worker-critical worker-default worker-bulk +docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d redis web worker-agent worker-automation scheduler ``` ### 3) 执行一次性 bootstrap diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 129dc26..f868ad8 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -37,7 +37,7 @@ services: - SOCIAL_REDIS__HOST=redis - SOCIAL_REDIS__PORT=6379 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:]")' + sh -c 'uv run 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: @@ -72,7 +72,7 @@ services: - SOCIAL_REDIS__HOST=redis - SOCIAL_REDIS__PORT=6379 command: > - sh -c '.venv/bin/taskiq worker core.taskiq.app:default_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AGENT__CONCURRENCY:-2}' + sh -c 'uv run taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AGENT__CONCURRENCY:-2}' depends_on: redis: condition: service_healthy @@ -94,7 +94,7 @@ services: - SOCIAL_REDIS__HOST=redis - SOCIAL_REDIS__PORT=6379 command: > - sh -c '.venv/bin/taskiq worker core.taskiq.app:bulk_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AUTOMATION__CONCURRENCY:-1}' + sh -c 'uv run taskiq worker core.taskiq.app:worker_automation_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AUTOMATION__CONCURRENCY:-1}' depends_on: redis: condition: service_healthy @@ -115,7 +115,7 @@ services: - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - SOCIAL_REDIS__HOST=redis - SOCIAL_REDIS__PORT=6379 - command: .venv/bin/python -m core.runtime.cli automation-scheduler + command: uv run python -m core.runtime.cli automation-scheduler depends_on: redis: condition: service_healthy @@ -136,7 +136,7 @@ services: - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod} - SOCIAL_REDIS__HOST=redis - SOCIAL_REDIS__PORT=6379 - command: .venv/bin/python -m core.runtime.cli bootstrap + command: uv run python -m core.runtime.cli bootstrap depends_on: redis: condition: service_healthy diff --git a/deploy/static/releases/manifest.json b/deploy/static/releases/manifest.json index 7807d3a..d806500 100644 --- a/deploy/static/releases/manifest.json +++ b/deploy/static/releases/manifest.json @@ -10,6 +10,17 @@ "release_notes": null, "file_size": 21371568, "sha256": "34691f96004b3dc3b2070d84ae0e7f0d2943f6c9978160eb78550081bc72a74a" + }, + { + "platform": "android", + "channel": "release", + "version_name": "0.1.1", + "version_code": 4, + "min_supported_version_code": 4, + "file_name": "social-app-android-v0.1.1+4-release.apk", + "release_notes": null, + "file_size": 21572828, + "sha256": "2b59596044d473c8aa477a12d01958b9dc08b2aee528226039c37bdaa1372da8" } ] } diff --git a/docs/plans/2026-03-23-memories-ui-implementation.md b/docs/plans/2026-03-23-memories-ui-implementation.md deleted file mode 100644 index b669370..0000000 --- a/docs/plans/2026-03-23-memories-ui-implementation.md +++ /dev/null @@ -1,231 +0,0 @@ -# Memories 界面实现计划 - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 实现前端memories界面,卡片列表展示user和work记忆,支持查看详情和更新 - -**Architecture:** -- 使用现有ApiClient调用后端 `/api/v1/memories` 接口 -- 数据模型解析UserMemoryContent和WorkProfileContent -- 遵循项目视觉设计语言(soft blue, layered card-based) -- 通过GoRouter管理导航 - -**Tech Stack:** Flutter, Dio, GoRouter, GetIt依赖注入 - ---- - -### Task 1: 创建Memory数据模型 - -**Files:** -- Create: `apps/lib/features/settings/data/models/memory_models.dart` - -**Step 1: 创建数据模型文件** - -```dart -// UserMemoryContent - 用户记忆 -class UserMemoryContent { - final String? occupation; - final String? timezone; - final String? primaryLanguage; - final List people; - final List places; - final UserPreferences preferences; - final SchedulingPreferences schedulingPreferences; - final List interests; - final List avoidTopics; - final List customRules; - final List recurringRoutines; - - // ... 工厂方法 fromJson -} - -class Person { - final String name; - final String? relationship; - final String? role; - final String? preferredContactChannel; - final String? notes; - final PersonMeta? meta; -} - -class Place { - final String name; - final String? category; - final String? address; - final String? timezone; - final int? commuteMinutes; - final String? preference; - final String? notes; - final PlaceMeta? meta; -} - -// WorkProfileContent - 工作记忆 -class WorkProfileContent { - final String? occupation; - final List expertise; - final List preferredTools; - final List currentProjects; - final WorkHabits workHabits; - final List teamMembers; - final String? teamContext; - final List workRules; -} - -// MemoryListResponse - API响应 -class MemoryListResponse { - final UserMemoryContent? userMemory; - final WorkProfileContent? workMemory; -} -``` - ---- - -### Task 2: 更新MemoryService调用真实API - -**Files:** -- Modify: `apps/lib/features/settings/data/services/memory_service.dart` - -**Step 1: 重写MemoryService** - -```dart -import 'package:social_app/core/api/i_api_client.dart'; -import '../models/memory_models.dart'; - -class MemoryService { - final IApiClient _client; - static const _prefix = '/api/v1/memories'; - - MemoryService(this._client); - - Future getAllMemories() async { ... } - Future getUserMemory() async { ... } - Future getWorkMemory() async { ... } - Future updateUserMemory(UserMemoryContent content) async { ... } - Future updateWorkMemory(WorkProfileContent content) async { ... } - Future patchUserMemory(Map content) async { ... } - Future patchWorkMemory(Map content) async { ... } -} -``` - ---- - -### Task 3: 在injection.dart中注册MemoryService - -**Files:** -- Modify: `apps/lib/core/di/injection.dart:26` - 添加import -- Modify: `apps/lib/core/di/injection.dart` - 注册MemoryService - ---- - -### Task 4: 重新设计MemoryScreen主页面 - -**Files:** -- Modify: `apps/lib/features/settings/ui/screens/memory_screen.dart` - -**设计要点:** -- 顶部启用记忆开关卡片 -- 两个主要卡片: User Memory (用户记忆) 和 Work Memory (工作记忆) -- 每个卡片显示关键摘要信息 -- 点击卡片进入对应详情页 -- 底部"管理记忆条目"按钮可点击展开更多信息 -- 遵循soft blue品牌、layered card-based界面 - -**卡片内容:** -- User Memory: 显示 occupation, people数量, places数量, interests数量 -- Work Memory: 显示 occupation, expertise数量, currentProjects数量, teamMembers数量 - ---- - -### Task 5: 创建UserMemoryDetailScreen - -**Files:** -- Create: `apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart` - -**功能:** -- 显示完整的UserMemoryContent信息 -- 支持编辑和更新 -- 分组展示: 基本信息、联系人、地点、偏好设置、日程偏好、兴趣、回避话题等 -- 使用可折叠的Section展示 - ---- - -### Task 6: 创建WorkMemoryDetailScreen - -**Files:** -- Create: `apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart` - -**功能:** -- 显示完整的WorkProfileContent信息 -- 支持编辑和更新 -- 分组展示: 基本信息、项目、团队成员、工作习惯等 -- 使用可折叠的Section展示 - ---- - -### Task 7: 添加路由配置 - -**Files:** -- Modify: `apps/lib/core/router/app_router.dart:26` - 添加import -- Modify: `apps/lib/core/router/app_router.dart` - 添加路由 -- Modify: `apps/lib/core/router/app_routes.dart` - 添加路由常量 - -**新增路由:** -- `/settings/memory/user` - UserMemoryDetailScreen -- `/settings/memory/work` - WorkMemoryDetailScreen - ---- - -### Task 8: 更新MemoryScreen导航 - -**Files:** -- Modify: `apps/lib/features/settings/ui/screens/memory_screen.dart` - -**Step 1: 添加导航跳转** - -```dart -// 点击User Memory卡片 -context.push('/settings/memory/user'); - -// 点击Work Memory卡片 -context.push('/settings/memory/work'); -``` - ---- - -### Task 9: 验证和测试 - -**Step 1: 运行flutter analyze检查代码质量** - -```bash -cd apps && flutter analyze -``` - -**Step 2: 验证设计token使用正确** - -检查所有颜色、间距、圆角都来自design_tokens.dart - -**Step 3: 验证API调用正确** - -确认使用正确的endpoint和HTTP方法 - ---- - -### 关键文件路径参考 - -- Design tokens: `apps/lib/core/theme/design_tokens.dart` -- Visual design language: `apps/rules/visual_design_language.md` -- Settings scaffold: `apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart` -- API client: `apps/lib/core/api/api_client.dart` -- 后端router: `backend/src/v1/memories/router.py` -- Protocol文档: `docs/protocols/models/memory.md` - ---- - -### 视觉设计要点 - -1. **Surface层次**: Background → Primary cards → Secondary grouped surfaces -2. **Color**: 使用AppColors.blue系列作为品牌色,避免过度使用 -3. **Spacing**: 使用AppSpacing (xs=4, sm=8, md=12, lg=16, xl=20, xxl=24) -4. **Radius**: 使用AppRadius (sm=6, md=12, lg=16, xl=18, xxl=24) -5. **Motion**: 150-300ms micro-interactions, soft press feedback -6. **卡片阴影**: 使用soft shadow如 `blurRadius: 8, offset: (0, 2)` diff --git a/docs/plans/2026-03-23-memory-system-design.md b/docs/plans/2026-03-23-memory-system-design.md deleted file mode 100644 index e4f069f..0000000 --- a/docs/plans/2026-03-23-memory-system-design.md +++ /dev/null @@ -1,78 +0,0 @@ -# Memory System Design - -## Overview - -每用户有两条记忆记录(user_type 和 work_type),存储在 `memories` 表的 `content` JSONB 字段中。 - -## Data Model - -### UserMemoryContent - -```python -class UserPreferences(BaseModel): - time_preference: str | None = None # "上午效率高" - communication_style: str | None = None # "简洁直接" - location_preference: str | None = None # "喜欢远程工作" - work_lifestyle: str | None = None # "早睡早起" - -class UserMemoryContent(BaseModel): - occupation: str | None = None # 职业 - timezone: str | None = None # 时区 - language: str | None = None # 语言偏好 - people: list[str] = [] # 重要人物 - places: list[str] = [] # 常去地点 - projects: list[str] = [] # 个人项目 - preferences: UserPreferences = UserPreferences() - interests: list[str] = [] # 兴趣爱好 - avoid_topics: list[str] = [] # 不想讨论的话题 - custom_rules: list[str] = [] # 自定义规则 - recurring_contexts: list[str] = [] # 周期性场景 -``` - -### WorkMemoryContent - -```python -class WorkProject(BaseModel): - name: str - description: str | None = None - status: str | None = None # "active", "paused" - key_milestones: list[str] = [] # 关键里程碑 - -class WorkHabit(BaseModel): - available_hours: dict[str, str] = {} # {"monday": "09:00-18:00"} - deep_work_blocks: list[str] = [] # 深度工作时段 - meeting_preference: str | None = None # "short", "30min最佳" - notification_channel: str | None = None # 首选沟通渠道 - -class WorkMemoryContent(BaseModel): - expertise: list[str] = [] # 专业领域 - preferred_tools: list[str] = [] # 惯用工具 - current_projects: list[WorkProject] = [] - work_habits: WorkHabit = WorkHabit() - team_members: list[str] = [] # 团队成员 - team_context: str | None = None # 团队概述 -``` - -## API Endpoints - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/v1/memories` | 获取用户所有记忆 | -| PUT | `/api/v1/memories/user` | 创建/更新用户记忆 | -| PUT | `/api/v1/memories/work` | 创建/更新工作记忆 | -| DELETE | `/api/v1/memories/{type}` | 删除指定类型记忆 | - -## TaskType支撑关系 - -| TaskType | Router决策依赖 | Worker执行依赖 | -|----------|---------------|---------------| -| SCHEDULING | timezone, available_hours | current_projects, team_members | -| PLANNING | preferences, work_lifestyle | expertise, work_habits | -| COMMUNICATION_DRAFTING | communication_style, avoid_topics | team_context, preferred_tools | -| RECOMMENDATION | interests, people | expertise, current_projects | - -## Frontend UI - -- MemoryScreen: 主列表页,展示user/work两条记忆卡片 -- MemoryDetailScreen: 详情/编辑页,分Tab展示各字段 -- 支持新建/编辑/删除操作 diff --git a/docs/plans/visibility-mask-restructure.md b/docs/plans/visibility-mask-restructure.md deleted file mode 100644 index b6d31d8..0000000 --- a/docs/plans/visibility-mask-restructure.md +++ /dev/null @@ -1,151 +0,0 @@ -# 可见性掩码重构方案 - -> 日期:2026-03-22 -> 状态:待执行 - -## 背景 - -现有可见性系统存在以下问题: -- `UI_REALTIME` 定义但从未使用 -- `visibility_consumer_bit` 语义模糊,用于 context 过滤但无法正确区分 chat/automation -- stage bits (16/17/18) 在 chat/automation 永不共享 thread 的设计下无意义 -- 无法正确实现:automation user_message 不进 /history、不进 context,automation agent_reply 进 /history 但不进 context - -## 设计目标 - -| runtime_mode | 消息 | /history 可见 | context_messages 组装 | -|-------------|------|:-------------:|:-------------------:| -| chat | user_message | ✅ | ✅ | -| chat | agent_reply | ✅ | ✅ | -| automation | user_message | ❌ | ❌ | -| automation | agent_reply | ✅ | ❌ | - -## 前提条件 - -- chat 和 automation **永不共享 thread_id**(已确认的设计约束) -- memory == automation,无需单独处理 - ---- - -## Bit 定义 - -``` -BIT 0 → UI_HISTORY → /history API 可见 -BIT 1 → CONTEXT_ASSEMBLY → 组装进 context_messages -``` - -> `UI_REALTIME` 废弃,删除。 -> `visibility_consumer_bit` 废弃,删除。 -> Stage bits (16/17/18) 废弃,删除。 - ---- - -## 消息 Mask 矩阵 - -| 消息 | runtime_mode | UI_HISTORY | CONTEXT_ASSEMBLY | Mask | -|------|-------------|:----------:|:---------------:|:----:| -| user_message | chat | 1 | 1 | **3** | -| user_message | automation | 0 | 0 | **0** | -| agent_reply | chat | 1 | 1 | **3** | -| agent_reply | automation | 1 | 0 | **1** | - ---- - -## 查询设计 - -| 查询 | Mask | 匹配规则 | -|------|------|---------| -| `/history` | `UI_HISTORY = 1` | `(message_mask & 1) != 0` | -| `context_messages` | `CONTEXT_ASSEMBLY = 2` | `(message_mask & 2) != 0` | - ---- - -## 查询结果验证 - -| 消息 | Mask | `/history & 1` | `/history` | `context & 2` | `context` | -|------|------|:-------------:|:----------:|:-------------:|:---------:| -| chat user_message | 3 | 1 ✅ | ✅ | 1 ✅ | ✅ | -| chat agent_reply | 3 | 1 ✅ | ✅ | 1 ✅ | ✅ | -| automation user_message | 0 | 0 ❌ | ❌ | 0 ❌ | ❌ | -| automation agent_reply | 1 | 1 ✅ | ✅ | 0 ❌ | ❌ | - ---- - -## 变更清单 - -### 1. `schemas/agent/visibility.py` - -- [ ] 删除 `UI_REALTIME = 1` 从 `SystemVisibilityBit` -- [ ] 删除 `VisibilityBitRef` 类 -- [ ] 保留 `bit_mask()` 函数 -- [ ] 保留 `VisibilityMask` 类(其他模块可能使用) - -### 2. `schemas/agent/system_agent.py` - -- [ ] 删除 `SystemAgentLLMConfig.visibility_consumer_bit` 字段 - -### 3. `core/config/static/database/system_agents.yaml` - -- [ ] 删除 `router.visibility_consumer_bit` -- [ ] 删除 `worker.visibility_consumer_bit` - -### 4. `v1/agent/service.py` - -- [ ] 重写 `_resolve_user_message_visibility_mask`: - ```python - async def _resolve_user_message_visibility_mask( - self, *, runtime_mode: RuntimeMode - ) -> int: - if runtime_mode == RuntimeMode.CHAT: - return UI_HISTORY | CONTEXT_ASSEMBLY # = 3 - return 0 # automation user_message - ``` - -### 5. `core/agentscope/events/store.py` - -- [ ] 重写 `_resolve_stage_visibility_mask`: - - chat stage (router/worker) → `UI_HISTORY | CONTEXT_ASSEMBLY` = 3 - - automation stage (memory) → `UI_HISTORY` = 1 -- [ ] 删除 `_load_stage_visibility_bit_map` 中对 `visibility_consumer_bit` 的依赖 -- [ ] 删除 `system_agents.yaml` 配置加载逻辑 - -### 6. `core/agentscope/runtime/context_service.py` - -- [ ] `load_context_messages` 查询 mask 改为 `CONTEXT_ASSEMBLY = 2` - ```python - visibility_mask = bit_mask(bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY)) - ``` - -### 7. `core/agentscope/runtime/tasks.py` - -- [ ] 删除 `_build_recent_context_messages` 中 memory job 的特殊处理 -- [ ] memory mode 改用 `runtime_mode=automation` 语义 - -### 8. `core/agentscope/runtime/runner.py` - -- [ ] 删除硬编码 `visibility_consumer_bit=18` 的 `SystemAgentLLMConfig` -- [ ] memory agent 配置改用 automation 语义 - -### 9. 清理迁移 - -- [ ] 更新 `schemas/agent/__init__.py` 导出(删除 `visibility_consumer_bit` 相关) -- [ ] 更新所有引用 `visibility_consumer_bit` 的文件 -- [ ] 运行测试验证 /history 和 context 组装行为 - ---- - -## 实施顺序 - -1. 新增 `CONTEXT_ASSEMBLY = 1` bit,更新 `service.py` -2. 更新 `events/store.py` 可见性逻辑 -3. 更新 `context_service.py` 查询 mask -4. 清理废弃配置和字段 -5. 运行测试验证 - ---- - -## 风险 - -- `VisibilityBitRef` 可能在其他未知位置使用(需全面搜索) -- `visibility_consumer_bit` 被 `runner.py` 硬编码,修改可能影响 memory pipeline -- 测试覆盖不足可能导致 regression diff --git a/docs/todo/2026-03-17-worker-token-latency-optimization.md b/docs/todo/2026-03-17-worker-token-latency-optimization.md deleted file mode 100644 index 1a265fc..0000000 --- a/docs/todo/2026-03-17-worker-token-latency-optimization.md +++ /dev/null @@ -1,78 +0,0 @@ -# Worker Token/Latency 优化 TODO - -日期: 2026-03-17 -Owner: backend runtime -状态: pending - -## 背景 - -- Router 阶段成本与延迟基本可接受。 -- Worker 阶段(deepseek-chat)`input_tokens` 与 `latency` 显著偏高,是总成本的主要来源。 -- 优化目标是在不降低结果质量与稳定性的前提下,优先压缩 Worker 输入 token。 - -## 现状观察 - -- Worker 平均 `input_tokens` 明显高于 Router。 -- Worker 平均延迟明显高于 Router。 -- 成本主要由 Worker 阶段贡献。 - -## 核心优化方向(按优先级) - -### P0(优先执行,低风险高收益) - -1. 路由提示词瘦身:从“全量路由清单”改为“route_id 约束 + 服务端映射”。 - - 模型仅输出 `route_id` 与必要参数。 - - 后端基于静态 route catalog 映射到最终 `path`。 - - 目标:减少每次 system prompt 的固定 token 开销。 - -2. Finalize 最小上下文化:避免 finalize 回放完整 memory。 - - finalize 阶段仅输入:最后一轮候选答案 + 必要工具结果摘要 + schema 指令。 - - 不再注入完整历史会话。 - - 目标:降低两段式结构化输出的额外输入成本。 - -3. 工具按需暴露(dynamic tool allowlist)。 - - 按 router 的 task/result typing 只下发当前任务必需工具。 - - 避免每轮 ReAct 携带全量工具 schema。 - - 目标:降低每次 reasoning 的工具描述负担。 - -### P1(次优先,稳定收益) - -4. system prompt 分层裁剪。 - - 按 `agent_type` 与 `ui_mode` 组装最小提示词集合。 - - Router 不携带 Worker 专属规则;`ui_mode=none` 不携带 rich UI 细则。 - -5. 输出体积约束。 - - 限制 `key_points`、`suggested_actions`、`ui_hints.actions` 数量与文本长度。 - - 降低 `output_tokens`,同时减少前端渲染负担。 - -6. 上下文策略优化(摘要 + 最近少量原文)。 - - 从“固定最近 N 轮原文”改为“结构化摘要 + 最近 1~2 轮原文”。 - - 控制长会话 token 膨胀。 - -### P2(可选增强) - -7. Prompt 缓存命中优化。 - - 固定可缓存前缀,动态段后置。 - - 利用 provider prompt cache 降低计费 token(若模型侧支持)。 - -## 不建议作为当前主线 - -- 直接切换为 ReAct 原生 `structured_model` 作为主方案(当前实测稳定性与成本不占优)。 -- 在未完成 P0 优化前,优先投入复杂的 ReAct 内核重写。 - -## 验收指标(更新) - -- 在典型多轮场景中,Worker `input_tokens` 降低 >= 30%。 -- Worker p95 `latency_ms` 降低 >= 20%。 -- 结构化输出校验成功率不低于当前基线。 -- 关键路径功能行为保持不变(agent run 结果与前端交互不回退)。 - -## 验证方式 - -1. 固定场景脚本对比(优化前/后同输入): - - 指标:`input_tokens`、`output_tokens`、`latency_ms`、`cost`、结构化成功率。 -2. 线上观测(`public.messages`): - - 按 stage(router/worker)聚合对比日均与 p95。 -3. 回归校验: - - 工具调用结果一致性; - - `ui_hints`/`ui_schema` 可渲染性与导航动作正确性。 diff --git a/docs/todo/calendar-reminder-migration-checklist.md b/docs/todo/calendar-reminder-migration-checklist.md deleted file mode 100644 index 5cf70d4..0000000 --- a/docs/todo/calendar-reminder-migration-checklist.md +++ /dev/null @@ -1,27 +0,0 @@ -# Calendar Reminder Migration Checklist - -## Scope - -本清单用于跟踪提醒模块迁移后旧代码清理,字段固定为:文件路径、符号名、处理决策、责任人、状态。 - -## Items - -| File | Symbol | Decision | Owner | Status | Notes | -|---|---|---|---|---|---| -| `apps/lib/features/calendar/reminders/models/reminder_action.dart` | `ReminderAction.cancel` | delete | agent | done | 枚举项删除,保留字符串兼容映射到 `archive` | -| `apps/lib/features/calendar/reminders/models/reminder_action.dart` | `ReminderAction.timeout30s` | delete | agent | done | 枚举项删除,保留字符串兼容映射到 `snooze10m` | -| `apps/lib/features/calendar/reminders/models/reminder_action.dart` | `ReminderAction.autoArchive` | delete | agent | done | 枚举项删除,保留字符串兼容映射到 `archive` | -| `apps/lib/features/calendar/reminders/models/reminder_action.dart` | `ReminderAction.normalized` | delete | agent | done | canonical 枚举后不再需要 | -| `apps/lib/core/notifications/local_notification_service.dart` | `_actionSnooze = 'snooze_10m'` | replace | agent | done | 统一为 `_actionSnooze = 'snooze10m'` | -| `apps/lib/core/notifications/local_notification_service.dart` | `_iosCategoryId = 'calendar_reminder_actions_v1'` | replace | agent | done | 已升级为 `calendar_reminder_v2` | - -## Verification Commands - -```bash -rg "calendar_reminder_actions_v1|ReminderAction\.cancel|_oldReminderEntry|_legacyReminderRoute" apps/lib apps/test -rg "snooze_10m" apps/lib apps/test -``` - -Expected: -- 第一条:no matches -- 第二条:仅允许出现在兼容映射分支(若存在) diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index bedce08..229bb2a 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -159,8 +159,8 @@ start() { ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers \ ${SOCIAL_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" - WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:default_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AGENT__CONCURRENCY:-2}" - WORKER_AUTOMATION_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-automation uv run taskiq worker core.taskiq.app:bulk_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AUTOMATION__CONCURRENCY:-1}" + WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AGENT__CONCURRENCY:-2}" + WORKER_AUTOMATION_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-automation uv run taskiq worker core.taskiq.app:worker_automation_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AUTOMATION__CONCURRENCY:-1}" SCHEDULER_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=scheduler uv run python -m core.runtime.cli automation-scheduler" echo "Starting tmux workers in session '$SESSION_NAME'..."