refactor: 梳理规则体系并统一记忆与部署流程

This commit is contained in:
qzl
2026-03-23 17:57:24 +08:00
parent 2a14ad1d8e
commit f4b7eb7e09
39 changed files with 2091 additions and 1454 deletions
+23 -47
View File
@@ -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
View File
@@ -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
+13 -1
View File
@@ -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(
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+98 -1
View File
@@ -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,
*,
+28 -10
View File
@@ -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))
+274 -100
View File
@@ -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
+3 -1
View File
@@ -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
-4
View File
@@ -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(
-1
View File
@@ -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
+2 -1
View File
@@ -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)
+77
View File
@@ -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"}
+13 -6
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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
+11
View File
@@ -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展示各字段
- 支持新建/编辑/删除操作
-151
View File
@@ -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、不进 contextautomation 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`):
- 按 stagerouter/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
- 第二条:仅允许出现在兼容映射分支(若存在)
+2 -2
View File
@@ -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'..."