refactor: 梳理规则体系并统一记忆与部署流程
This commit is contained in:
@@ -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.
|
||||
|
||||
+37
-198
@@ -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.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="灵可析"
|
||||
android:label="林小夕"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<receiver
|
||||
|
||||
@@ -24,6 +24,8 @@ import '../../features/todo/ui/screens/todo_edit_screen.dart';
|
||||
import '../../features/settings/ui/screens/settings_screen.dart';
|
||||
import '../../features/settings/ui/screens/features_screen.dart';
|
||||
import '../../features/settings/ui/screens/memory_screen.dart';
|
||||
import '../../features/settings/ui/screens/user_memory_view_screen.dart';
|
||||
import '../../features/settings/ui/screens/work_memory_view_screen.dart';
|
||||
import '../../features/settings/ui/screens/user_memory_detail_screen.dart';
|
||||
import '../../features/settings/ui/screens/work_memory_detail_screen.dart';
|
||||
import '../../features/settings/ui/screens/edit_profile_screen.dart';
|
||||
@@ -45,6 +47,8 @@ final _protectedRoutes = [
|
||||
AppRoutes.settingsMemory,
|
||||
AppRoutes.settingsMemoryUser,
|
||||
AppRoutes.settingsMemoryWork,
|
||||
AppRoutes.settingsMemoryUserEdit,
|
||||
AppRoutes.settingsMemoryWorkEdit,
|
||||
AppRoutes.settingsEditProfile,
|
||||
AppRoutes.messageInviteList,
|
||||
];
|
||||
@@ -182,10 +186,18 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.settingsMemoryUser,
|
||||
builder: (context, state) => 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(
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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<MemoryScreen> {
|
||||
return SettingsPageScaffold(
|
||||
title: '我的记忆',
|
||||
onBack: () => context.pop(),
|
||||
footer: _buildFooter(),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -393,7 +390,7 @@ class _MemoryScreenState extends State<MemoryScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'工作Profile',
|
||||
'工作画像',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -486,36 +483,4 @@ class _MemoryScreenState extends State<MemoryScreen> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class _UserMemoryDetailScreenState extends State<UserMemoryDetailScreen> {
|
||||
@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<UserMemoryDetailScreen> {
|
||||
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<UserMemoryDetailScreen> {
|
||||
),
|
||||
child: Center(
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
? const AppLoadingIndicator(variant: AppLoadingVariant.button)
|
||||
: const Text(
|
||||
'保存更改',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -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<UserMemoryViewScreen> createState() => _UserMemoryViewScreenState();
|
||||
}
|
||||
|
||||
class _UserMemoryViewScreenState extends State<UserMemoryViewScreen> {
|
||||
final MemoryService _memoryService = sl<MemoryService>();
|
||||
UserMemoryContent? _memory;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMemory();
|
||||
}
|
||||
|
||||
Future<void> _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<Widget> 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<Person> 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<Place> 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<RecurringRoutine> 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<String> 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<String> values) {
|
||||
if (values.isEmpty) return '未设置';
|
||||
return values.join('、');
|
||||
}
|
||||
|
||||
String _intListText(List<int> 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';
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ class _WorkMemoryDetailScreenState extends State<WorkMemoryDetailScreen> {
|
||||
@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<WorkMemoryDetailScreen> {
|
||||
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<WorkMemoryDetailScreen> {
|
||||
),
|
||||
child: Center(
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
? const AppLoadingIndicator(variant: AppLoadingVariant.button)
|
||||
: const Text(
|
||||
'保存更改',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -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<WorkMemoryViewScreen> createState() => _WorkMemoryViewScreenState();
|
||||
}
|
||||
|
||||
class _WorkMemoryViewScreenState extends State<WorkMemoryViewScreen> {
|
||||
final MemoryService _memoryService = sl<MemoryService>();
|
||||
WorkProfileContent? _memory;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMemory();
|
||||
}
|
||||
|
||||
Future<void> _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<Widget> 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<CurrentProject> 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<TeamMember> 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<String> 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<String> values) {
|
||||
if (values.isEmpty) return '未设置';
|
||||
return values.join('、');
|
||||
}
|
||||
|
||||
String _intListText(List<int> values, {required String suffix}) {
|
||||
if (values.isEmpty) return '未设置';
|
||||
return values.map((value) => '$value$suffix').join('、');
|
||||
}
|
||||
|
||||
String _timeWindowSummary(List<TimeWindow> windows) {
|
||||
if (windows.isEmpty) return '未设置';
|
||||
return '${windows.length} 个时段';
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+47
-280
@@ -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 <command>`
|
||||
- Add dependencies: `uv add <package>`
|
||||
- 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.
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,8 +83,10 @@ async def _dispatch_automation_run(
|
||||
"content": input_text,
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {
|
||||
"runtimeMode": RuntimeMode.AUTOMATION.value,
|
||||
"runtime_mode": RuntimeMode.AUTOMATION.value,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
############
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<Person> people;
|
||||
final List<Place> places;
|
||||
final UserPreferences preferences;
|
||||
final SchedulingPreferences schedulingPreferences;
|
||||
final List<String> interests;
|
||||
final List<String> avoidTopics;
|
||||
final List<String> customRules;
|
||||
final List<RecurringRoutine> 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<String> expertise;
|
||||
final List<String> preferredTools;
|
||||
final List<CurrentProject> currentProjects;
|
||||
final WorkHabits workHabits;
|
||||
final List<TeamMember> teamMembers;
|
||||
final String? teamContext;
|
||||
final List<String> 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<MemoryListResponse> getAllMemories() async { ... }
|
||||
Future<UserMemoryContent?> getUserMemory() async { ... }
|
||||
Future<WorkProfileContent?> getWorkMemory() async { ... }
|
||||
Future<UserMemoryContent> updateUserMemory(UserMemoryContent content) async { ... }
|
||||
Future<WorkProfileContent> updateWorkMemory(WorkProfileContent content) async { ... }
|
||||
Future<UserMemoryContent> patchUserMemory(Map<String, dynamic> content) async { ... }
|
||||
Future<WorkProfileContent> patchWorkMemory(Map<String, dynamic> 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)`
|
||||
@@ -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展示各字段
|
||||
- 支持新建/编辑/删除操作
|
||||
@@ -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
|
||||
@@ -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` 可渲染性与导航动作正确性。
|
||||
@@ -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
|
||||
- 第二条:仅允许出现在兼容映射分支(若存在)
|
||||
@@ -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'..."
|
||||
|
||||
Reference in New Issue
Block a user