From 472528e70868a085d5e28b13d9d48b22d7dd388f Mon Sep 17 00:00:00 2001 From: "WIN-JHFT4D3SIVT\\caoxiaozhu" Date: Sun, 5 Apr 2026 20:45:16 +0800 Subject: [PATCH] feat(frontend): add memory components, temple/war-room pages, and composables - Add DailyDigestCard and ReminderToast memory components - Add temple and war-room page routes - Add memory API module with TypeScript definitions - Add chat composables: useClientTime, useDailyDigest, useSidebarPlan - Simplify chat/logs/settings pages (remove unused code) - Add settingsPage.css --- .../phase-m-4-auto-extraction.md | 193 ++ .../phase-m-5-recall-injection.md | 228 ++ frontend/src/api/memory.d.ts | 38 + frontend/src/api/memory.js | 37 + frontend/src/app/router/routes.ts | 10 + .../src/components/memory/DailyDigestCard.vue | 302 ++ .../src/components/memory/ReminderToast.vue | 224 ++ frontend/src/pages/chat/chatPage.css | 61 + .../pages/chat/composables/useClientTime.ts | 93 + .../pages/chat/composables/useDailyDigest.ts | 74 + .../pages/chat/composables/useSidebarPlan.ts | 194 ++ frontend/src/pages/chat/index.vue | 2894 +---------------- .../src/pages/chat/index_backup_sidebar.vue | 2354 ++++++++++++++ frontend/src/pages/logs/index.vue | 659 +--- frontend/src/pages/settings/index.vue | 380 +-- frontend/src/pages/settings/settingsPage.css | 378 +++ frontend/src/pages/temple/index.vue | 56 + frontend/src/pages/war-room/index.vue | 56 + 18 files changed, 4435 insertions(+), 3796 deletions(-) create mode 100644 development-doc/plan/memory-update/phase-m-4-auto-extraction.md create mode 100644 development-doc/plan/memory-update/phase-m-5-recall-injection.md create mode 100644 frontend/src/api/memory.d.ts create mode 100644 frontend/src/api/memory.js create mode 100644 frontend/src/components/memory/DailyDigestCard.vue create mode 100644 frontend/src/components/memory/ReminderToast.vue create mode 100644 frontend/src/pages/chat/composables/useClientTime.ts create mode 100644 frontend/src/pages/chat/composables/useDailyDigest.ts create mode 100644 frontend/src/pages/chat/composables/useSidebarPlan.ts create mode 100644 frontend/src/pages/chat/index_backup_sidebar.vue create mode 100644 frontend/src/pages/settings/settingsPage.css create mode 100644 frontend/src/pages/temple/index.vue create mode 100644 frontend/src/pages/war-room/index.vue diff --git a/development-doc/plan/memory-update/phase-m-4-auto-extraction.md b/development-doc/plan/memory-update/phase-m-4-auto-extraction.md new file mode 100644 index 0000000..64a91d1 --- /dev/null +++ b/development-doc/plan/memory-update/phase-m-4-auto-extraction.md @@ -0,0 +1,193 @@ +# Phase M.4:对话自动学习(Auto Memory Extraction) + +日期:2026-04-05 +状态:规划中 +依赖:M.1 (重要性评分) +工作量:3 天 + +--- + +## 1. 本阶段目的 + +让 Jarvis 在每次对话结束后**自动**从对话内容中提取记忆,而不需要用户手动触发。 + +当前问题: +- `POST /brain/learn/run` 是手动触发,用户不会每次手动调 +- 没有自动学习,M.1 的评分系统、M.2 的遗忘系统都缺少输入 +- 记忆库会随时间停滞,而不是随使用不断丰富 + +--- + +## 2. 核心架构 + +``` +对话结束 + │ + ▼ +ConversationEndHook + │ + ▼ +MemoryExtractor + ├── extract_facts() # 事实:你住在北京、你用 Python + ├── extract_preferences() # 偏好:你喜欢简短的回答 + ├── extract_goals() # 目标:你想学 Rust + ├── extract_pain_points() # 痛点:反复问同一类问题 + └── extract_events() # 事件:今天提到的重要事情 + │ + ▼ + ImportanceScorer (M.1) # 评分后存入 UserMemory + │ + ▼ + 去重检查 # 避免重复存储相似记忆 + │ + ▼ + UserMemory / BrainMemory +``` + +--- + +## 3. 核心实现 + +### 3.1 MemoryExtractor + +```python +class MemoryExtractor: + async def extract_from_conversation( + self, + user_id: str, + messages: list[Message], + ) -> list[ExtractedMemory]: + """ + 从一段对话中提取记忆条目。 + 调用 LLM 做结构化抽取,返回待存储的记忆列表。 + """ + + async def deduplicate( + self, + new_memories: list[ExtractedMemory], + existing_memories: list[UserMemory], + ) -> list[ExtractedMemory]: + """ + 与现有记忆做相似度对比,过滤重复项。 + 相似度 > 0.85 视为重复,更新而非新增。 + """ +``` + +### 3.2 LLM 提取 Prompt(结构化输出) + +```python +EXTRACT_PROMPT = """ +从以下对话中提取用户的记忆信息,以 JSON 格式返回: + +对话内容: +{conversation_text} + +提取以下类型: +- fact: 关于用户的客观事实(职业、地点、技能等) +- preference: 用户的偏好和习惯 +- goal: 用户提到的目标或计划 +- pain_point: 反复出现或明显困扰用户的问题 +- event: 今天发生的重要事件 + +输出格式: +[ + {"type": "fact", "content": "...", "confidence": 0.9}, + {"type": "goal", "content": "...", "confidence": 0.7} +] + +只提取明确的信息,不要猜测。 +""" +``` + +### 3.3 触发时机 + +```python +# 在 conversation router 的对话结束时异步触发 +# routers/conversation.py + +@router.post("/api/conversations/{conversation_id}/end") +async def end_conversation(conversation_id: str, ...): + # 原有逻辑... + + # 异步触发记忆提取,不阻塞响应 + background_tasks.add_task( + memory_extractor.extract_from_conversation, + user_id=current_user.id, + messages=messages, + ) +``` + +也支持**会话超时自动触发**(超过 30 分钟无新消息视为对话结束): + +```python +# scheduler_service.py +@scheduler.scheduled_task("interval", minutes=30) +async def check_idle_conversations(): + """检查闲置对话,触发记忆提取""" +``` + +--- + +## 4. 去重逻辑 + +```python +# 简单相似度检查(用 Mem0 自带的语义去重,或简单字符串匹配) +async def deduplicate(self, new_memory: ExtractedMemory, user_id: str) -> bool: + """ + 返回 True 表示是新记忆,False 表示已存在(更新原记忆即可) + """ + existing = await self.memory_service.search( + query=new_memory.content, + user_id=user_id, + top_k=3, + ) + for mem in existing: + if similarity(mem.content, new_memory.content) > 0.85: + # 更新现有记忆的 frequency_count,而非新建 + await self.memory_service.reinforce(mem.id) + return False + return True +``` + +--- + +## 5. 核心文件 + +### 5.1 新增文件 + +| 文件 | 职责 | +|------|------| +| `services/memory/memory_extractor.py` | 对话记忆提取 | +| `tests/services/test_memory_extractor.py` | 提取测试 | + +### 5.2 修改文件 + +| 文件 | 修改内容 | +|------|---------| +| `routers/conversation.py` | 对话结束时触发提取 | +| `services/scheduler_service.py` | 添加闲置对话检查 | + +--- + +## 6. 验收标准 + +| 标准 | 说明 | +|------|------| +| 自动触发 | 对话结束后 30 秒内完成提取 | +| 提取准确 | fact/goal/pain_point 类型识别准确 | +| 去重有效 | 重复内容不新建,只强化原记忆 | +| 不阻塞对话 | 提取为后台任务,不影响响应速度 | +| 单元测试覆盖率 | > 80% | + +--- + +## 7. 工作量估算 + +| 任务 | 工作量 | +|------|--------| +| MemoryExtractor 实现 | 1 天 | +| LLM Prompt 调优 | 0.5 天 | +| 去重逻辑 | 0.5 天 | +| 触发集成(对话结束 + 调度) | 0.5 天 | +| 测试 | 0.5 天 | +| **合计** | **3 天** | diff --git a/development-doc/plan/memory-update/phase-m-5-recall-injection.md b/development-doc/plan/memory-update/phase-m-5-recall-injection.md new file mode 100644 index 0000000..be3ee8e --- /dev/null +++ b/development-doc/plan/memory-update/phase-m-5-recall-injection.md @@ -0,0 +1,228 @@ +# Phase M.5:记忆召回注入(Memory Recall Injection) + +日期:2026-04-05 +状态:规划中 +依赖:M.1 (重要性评分), M.4 (自动提取) +工作量:2 天 + +--- + +## 1. 本阶段目的 + +让 Jarvis 在每次对话时**自动**将相关记忆注入到 LLM 的 system prompt,使 AI 真正「记得」用户。 + +当前问题: +- M.1-M.4 构建和管理了记忆,但 LLM 在生成回答时根本看不到这些记忆 +- `memory_service.recall_memories()` 虽然存在,但没有在对话路由中被调用 +- 记忆库有内容,对话却没有个性化——记忆和对话是两个孤立的系统 + +--- + +## 2. 核心架构 + +``` +用户发来消息 + │ + ▼ +MemoryRecallInjector + ├── retrieve_relevant() # 语义搜索匹配当前消息 + ├── rank_by_importance() # 按 M.1 重要性分数排序 + ├── budget_tokens() # 控制注入 token 数量(上限 800) + └── format_context() # 格式化为 system prompt 片段 + │ + ▼ + LLM system prompt 中追加 memory context + │ + ▼ + LLM 生成回答(带个人化上下文) + │ + ▼ + 触发 M.2 强化(召回的记忆 frequency +1) +``` + +--- + +## 3. 核心实现 + +### 3.1 MemoryRecallInjector + +```python +class MemoryRecallInjector: + async def build_context( + self, + user_id: str, + current_message: str, + token_budget: int = 800, + ) -> str: + """ + 根据当前消息,检索相关记忆并拼装为 system prompt 片段。 + """ + # 1. 语义检索最相关的记忆 + candidates = await self.memory_service.recall_memories( + user_id=user_id, + query=current_message, + top_k=20, + ) + + # 2. 过滤已归档记忆(M.2 decay < 0.2 的记忆不注入) + active = [m for m in candidates if not m.is_archived] + + # 3. 按重要性评分 + 相关性综合排序 + ranked = self._rank(active, current_message) + + # 4. Token 预算控制,避免占用过多上下文 + selected = self._budget_select(ranked, token_budget) + + # 5. 格式化 + return self._format(selected) + + def _format(self, memories: list[UserMemory]) -> str: + if not memories: + return "" + lines = ["[关于你的记忆]"] + for m in memories: + lines.append(f"- {m.content}") + return "\n".join(lines) +``` + +### 3.2 注入点:对话路由 + +```python +# routers/conversation.py + +@router.post("/api/conversations/{conversation_id}/messages") +async def send_message(conversation_id: str, body: MessageRequest, ...): + # 1. 召回注入 + memory_context = await memory_injector.build_context( + user_id=current_user.id, + current_message=body.content, + ) + + # 2. 拼装 system prompt + system_prompt = base_system_prompt + if memory_context: + system_prompt = f"{system_prompt}\n\n{memory_context}" + + # 3. 发送给 LLM + response = await llm.chat( + messages=conversation_messages, + system=system_prompt, + ) + + # 4. 触发记忆强化(后台任务,不阻塞) + background_tasks.add_task( + memory_reinforcement.trigger_by_query, + user_id=current_user.id, + query=body.content, + ) + + return response +``` + +### 3.3 排序逻辑 + +```python +def _rank( + self, + memories: list[UserMemory], + query: str, +) -> list[UserMemory]: + """ + 综合排序:语义相关性 × 重要性评分 + - 重要性分数来自 M.1 ImportanceScorer + - 相关性分数来自向量距离(mem0 已计算) + """ + def score(m: UserMemory) -> float: + relevance = m.similarity_score or 0.5 # 来自召回时的余弦相似度 + importance = m.importance_score # 来自 M.1 + recency_boost = 1.0 if m.memory_type in ("goal", "pain_point") else 0.8 + return relevance * 0.6 + importance * 0.4 * recency_boost + + return sorted(memories, key=score, reverse=True) +``` + +--- + +## 4. Token 预算控制 + +记忆注入不能无限制增长,否则会挤压对话本身的上下文空间。 + +```python +def _budget_select( + self, + memories: list[UserMemory], + token_budget: int, +) -> list[UserMemory]: + """ + 贪心选择:按排名依次选入,直到 token 预算耗尽。 + 粗略估算:1 条记忆 ≈ 30 token + """ + selected = [] + used = 20 # "[关于你的记忆]\n" 的固定开销 + for m in memories: + cost = len(m.content) // 2 + 10 # 粗略估算 + if used + cost > token_budget: + break + selected.append(m) + used += cost + return selected +``` + +**默认预算:800 token**(约 26 条记忆),可在 config 中调整。 + +--- + +## 5. 记忆类型优先级 + +不同类型的记忆注入优先级不同: + +| 类型 | 优先级 | 说明 | +|------|--------|------| +| `pain_point` | 最高 | 反复困扰用户的问题,每次都应提醒 | +| `goal` | 高 | 用户的目标,影响回答方向 | +| `preference` | 中 | 影响回答风格(简短、代码优先等)| +| `fact` | 中 | 基础事实(职业、地点、技术栈)| +| `event` | 低 | 今日事件,时效性强,过期降权 | + +--- + +## 6. 核心文件 + +### 6.1 新增文件 + +| 文件 | 职责 | +|------|------| +| `services/memory/recall_injector.py` | 记忆召回与注入 | +| `tests/services/test_recall_injector.py` | 注入测试 | + +### 6.2 修改文件 + +| 文件 | 修改内容 | +|------|---------| +| `routers/conversation.py` | 发送消息前注入记忆 context | +| `services/memory_service.py` | recall_memories() 返回相似度分数 | + +--- + +## 7. 验收标准 + +| 标准 | 说明 | +|------|------| +| 注入生效 | LLM 回答中能体现用户个人信息 | +| 不超 token 预算 | 注入内容 ≤ 800 token | +| 高优先级优先 | goal/pain_point 比 fact 更早注入 | +| 已归档不注入 | decay < 0.2 的记忆不出现在 context 中 | +| 不阻塞响应 | 注入耗时 < 100ms(内存/向量检索) | +| 强化触发 | 被召回的记忆 frequency_count +1 | + +--- + +## 8. 工作量估算 + +| 任务 | 工作量 | +|------|--------| +| MemoryRecallInjector 实现 | 0.5 天 | +| 对话路由集成 | 0.5 天 | +| Token 预算 + 排序调优 | 0.5 天 | +| 测试 | 0.5 天 | +| **合计** | **2 天** | diff --git a/frontend/src/api/memory.d.ts b/frontend/src/api/memory.d.ts new file mode 100644 index 0000000..dd70649 --- /dev/null +++ b/frontend/src/api/memory.d.ts @@ -0,0 +1,38 @@ +export interface DailyDigestKeyPoint { + content: string + source: string +} + +export interface DailyDigestSuggestion { + text: string + type?: 'action' | 'info' | 'warning' +} + +export interface DailyDigestItem { + date: string + summary: string + keyPoints: DailyDigestKeyPoint[] + suggestions: DailyDigestSuggestion[] +} + +export interface DailyDigestListResponse { + items: DailyDigestItem[] +} + +export interface DueReminderItem { + id: string + content?: string + title?: string + trigger_at?: string +} + +export interface DueReminderListResponse { + items: DueReminderItem[] +} + +export function getDailyDigest(date: string): Promise<{ data: DailyDigestItem }> +export function getRecentDigests(limit?: number): Promise<{ data: DailyDigestListResponse }> +export function createReminder(content: string, triggerAt: string, triggerType?: string): Promise +export function snoozeReminder(id: string, minutes: number): Promise +export function dismissReminder(id: string): Promise +export function getDueReminders(): Promise<{ data: DueReminderListResponse }> diff --git a/frontend/src/api/memory.js b/frontend/src/api/memory.js new file mode 100644 index 0000000..26cbdcc --- /dev/null +++ b/frontend/src/api/memory.js @@ -0,0 +1,37 @@ +import api from './index' + +/** + * Daily Digest API + */ + +export async function getDailyDigest(date) { + return api.get(`/api/memory/daily-digest/${date}`) +} + +export async function getRecentDigests(limit = 7) { + return api.get('/api/memory/daily-digests', { params: { limit } }) +} + +/** + * Reminder API + */ + +export async function createReminder(content, triggerAt, triggerType = 'time') { + return api.post('/api/memory/reminders', { + content, + trigger_at: triggerAt, + trigger_type: triggerType, + }) +} + +export async function snoozeReminder(id, minutes) { + return api.post(`/api/memory/reminders/${id}/snooze`, { minutes }) +} + +export async function dismissReminder(id) { + return api.delete(`/api/memory/reminders/${id}`) +} + +export async function getDueReminders() { + return api.get('/api/memory/reminders/due') +} diff --git a/frontend/src/app/router/routes.ts b/frontend/src/app/router/routes.ts index 88c16fa..2d5e0c9 100644 --- a/frontend/src/app/router/routes.ts +++ b/frontend/src/app/router/routes.ts @@ -63,6 +63,16 @@ const appChildren: RouteRecordRaw[] = [ name: 'code-commander', component: () => import('@/pages/chat/CodeCommander.vue'), }, + { + path: 'temple', + name: 'temple', + component: () => import('@/pages/temple/index.vue'), + }, + { + path: 'war-room', + name: 'war-room', + component: () => import('@/pages/war-room/index.vue'), + }, ] export const routes: RouteRecordRaw[] = [ diff --git a/frontend/src/components/memory/DailyDigestCard.vue b/frontend/src/components/memory/DailyDigestCard.vue new file mode 100644 index 0000000..539559d --- /dev/null +++ b/frontend/src/components/memory/DailyDigestCard.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/frontend/src/components/memory/ReminderToast.vue b/frontend/src/components/memory/ReminderToast.vue new file mode 100644 index 0000000..d5909cb --- /dev/null +++ b/frontend/src/components/memory/ReminderToast.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/frontend/src/pages/chat/chatPage.css b/frontend/src/pages/chat/chatPage.css index cfe7d95..8897943 100644 --- a/frontend/src/pages/chat/chatPage.css +++ b/frontend/src/pages/chat/chatPage.css @@ -1366,6 +1366,67 @@ 100% { transform: translateY(12px); } } +/* ── Top Buttons Row ── */ +.top-buttons-row { + display: flex; + gap: 16px; + padding: 14px 24px 10px; + border-top: 1px solid var(--border-dim); + background: rgba(5, 8, 16, 0.6); +} + +.top-action-btn { + position: relative; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + background: linear-gradient(135deg, rgba(0, 245, 212, 0.08) 0%, rgba(123, 44, 191, 0.05) 100%); + border: 1px solid rgba(0, 245, 212, 0.2); + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + overflow: hidden; +} + +.top-action-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.1), transparent); + transition: left 0.5s ease; +} + +.top-action-btn:hover::before { + left: 100%; +} + +.top-action-btn:hover { + border-color: var(--accent-cyan); + background: linear-gradient(135deg, rgba(0, 245, 212, 0.15) 0%, rgba(123, 44, 191, 0.1) 100%); + box-shadow: 0 0 20px rgba(0, 245, 212, 0.2), inset 0 0 20px rgba(0, 245, 212, 0.05); + transform: translateY(-1px); +} + +.top-action-btn:active { + transform: translateY(0); +} + +.top-action-btn .btn-icon { + font-size: 18px; + filter: drop-shadow(0 0 4px rgba(0, 245, 212, 0.5)); +} + +.top-action-btn .btn-text { + letter-spacing: 0.5px; +} + /* ── Input Area ── */ .input-area { padding: 16px 24px 20px; diff --git a/frontend/src/pages/chat/composables/useClientTime.ts b/frontend/src/pages/chat/composables/useClientTime.ts new file mode 100644 index 0000000..e4ad3eb --- /dev/null +++ b/frontend/src/pages/chat/composables/useClientTime.ts @@ -0,0 +1,93 @@ +import { computed, onMounted, onUnmounted, ref } from 'vue' +import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-vue-next' + +export function formatNetworkRate(bytesPerSecond: number | null, online: boolean) { + if (!online || bytesPerSecond === null) return 'OFFLINE' + if (bytesPerSecond >= 1024 * 1024) return `${(bytesPerSecond / (1024 * 1024)).toFixed(2)} MB/s` + if (bytesPerSecond >= 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` + return `${bytesPerSecond.toFixed(0)} B/s` +} + +export function useClientTime() { + const clientTime = ref(new Date()) + const weatherSummary = ref('Weather unavailable') + const weatherCode = ref(null) + let clientTimeTimer: ReturnType | null = null + + function updateClientTime() { + clientTime.value = new Date() + } + + function formatClientDate(date: Date) { + return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) + } + + function formatClientClock(date: Date) { + return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + } + + function weatherCodeLabel(code: number | null | undefined) { + if (code === 0) return 'Clear' + if (code === 1 || code === 2) return 'Partly Cloudy' + if (code === 3) return 'Overcast' + if (code === 45 || code === 48) return 'Fog' + if ([51, 53, 55, 56, 57].includes(code ?? -1)) return 'Drizzle' + if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return 'Rain' + if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'Snow' + if ([95, 96, 99].includes(code ?? -1)) return 'Thunderstorm' + return 'Weather' + } + + const weatherIcon = computed(() => { + const code = weatherCode.value + if (code === 0) return Sun + if (code === 1 || code === 2 || code === 3) return Cloud + if (code === 45 || code === 48) return CloudFog + if ([51, 53, 55, 56, 57].includes(code ?? -1)) return CloudDrizzle + if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return CloudRain + if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return CloudSnow + if ([95, 96, 99].includes(code ?? -1)) return CloudLightning + return Cloud + }) + + async function loadWeather(latitude: number, longitude: number) { + try { + const response = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,weather_code&timezone=auto`, + ) + if (!response.ok) throw new Error('weather request failed') + const data = await response.json() + const current = data.current ?? {} + weatherCode.value = typeof current.weather_code === 'number' ? current.weather_code : null + const temp = typeof current.temperature_2m === 'number' ? `${Math.round(current.temperature_2m)}°C` : '--' + weatherSummary.value = `${weatherCodeLabel(current.weather_code)} ${temp}` + } catch { + weatherCode.value = null + weatherSummary.value = 'Weather unavailable' + } + } + + onMounted(() => { + updateClientTime() + clientTimeTimer = setInterval(updateClientTime, 1000) + if (!navigator.geolocation) { + weatherCode.value = null + weatherSummary.value = 'Weather unavailable' + return + } + navigator.geolocation.getCurrentPosition( + (position) => { void loadWeather(position.coords.latitude, position.coords.longitude) }, + () => { weatherCode.value = null; weatherSummary.value = 'Weather unavailable' }, + { enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }, + ) + }) + + onUnmounted(() => { + if (clientTimeTimer) clearInterval(clientTimeTimer) + }) + + return { + clientTime, weatherSummary, weatherCode, weatherIcon, + updateClientTime, formatClientDate, formatClientClock, weatherCodeLabel, loadWeather + } +} diff --git a/frontend/src/pages/chat/composables/useDailyDigest.ts b/frontend/src/pages/chat/composables/useDailyDigest.ts new file mode 100644 index 0000000..636d46a --- /dev/null +++ b/frontend/src/pages/chat/composables/useDailyDigest.ts @@ -0,0 +1,74 @@ +import { ref } from 'vue' +import { getRecentDigests, getDueReminders, snoozeReminder, dismissReminder } from '@/api/memory' + +function formatDateKey(date: Date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function useDailyDigest() { + const dailyDigest = ref(null) + const digestLoading = ref(false) + const recentDigests = ref([]) + const activeReminder = ref(null) + const reminderVisible = ref(false) + let reminderPollTimer: ReturnType | null = null + + async function loadDailyDigest() { + digestLoading.value = true + try { + const today = formatDateKey(new Date()) + const response = await getRecentDigests(6) + const items = response.data?.items ?? [] + recentDigests.value = items + dailyDigest.value = items.find((item: any) => item.date === today) ?? null + } catch (err) { + console.warn('Failed to load daily digest:', err) + recentDigests.value = [] + dailyDigest.value = null + } finally { + digestLoading.value = false + } + } + + async function pollDueReminders() { + try { + const response = await getDueReminders() + if (response.data?.items?.length > 0) { + activeReminder.value = response.data.items[0] + reminderVisible.value = true + } else { + reminderVisible.value = false + } + } catch (err) { + console.warn('Failed to poll due reminders:', err) + } + } + + async function handleSnooze(id: string, minutes: number) { + try { + await snoozeReminder(id, minutes) + reminderVisible.value = false + setTimeout(pollDueReminders, minutes * 60 * 1000) + } catch (err) { + console.warn('Failed to snooze reminder:', err) + } + } + + async function handleDismiss(id: string) { + try { + await dismissReminder(id) + reminderVisible.value = false + } catch (err) { + console.warn('Failed to dismiss reminder:', err) + } + } + + return { + dailyDigest, digestLoading, recentDigests, + activeReminder, reminderVisible, reminderPollTimer, + loadDailyDigest, pollDueReminders, handleSnooze, handleDismiss + } +} diff --git a/frontend/src/pages/chat/composables/useSidebarPlan.ts b/frontend/src/pages/chat/composables/useSidebarPlan.ts new file mode 100644 index 0000000..1140a1e --- /dev/null +++ b/frontend/src/pages/chat/composables/useSidebarPlan.ts @@ -0,0 +1,194 @@ +import { computed, onMounted, ref, watch } from 'vue' +import { CornerDownLeft, Database, Sparkles, Sun } from 'lucide-vue-next' +import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter' + +export interface SidebarFocusItem { + id: string; label: string; title: string; meta: string; tone: 'done' | 'doing' | 'pending' +} +export interface SidebarNewsItem { + id: string; title: string; meta: string +} + +export const sidebarCollapsedModules = [ + { id: 'calendar', label: '日历', icon: Sun }, + { id: 'status', label: '计划', icon: Database }, + { id: 'focus', label: '重点', icon: Sparkles }, + { id: 'review', label: '复盘', icon: CornerDownLeft }, +] + +function formatDateKey(date: Date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function formatMonthKey(date: Date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + return `${year}-${month}` +} + +export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn: () => void) { + const todayPlanDetail = ref(null) + const monthPlanDays = ref([]) + + const todayDateKey = computed(() => formatDateKey(clientTimeRef.value)) + const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item]))) + + const calendarCells = computed(() => { + const year = clientTimeRef.value.getFullYear() + const month = clientTimeRef.value.getMonth() + const daysInMonth = new Date(year, month + 1, 0).getDate() + const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7 + const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean }> = [] + for (let index = 0; index < firstDayOffset; index += 1) { + cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false }) + } + for (let day = 1; day <= daysInMonth; day += 1) { + const monthDate = new Date(year, month, day) + const dateKey = formatDateKey(monthDate) + const summary = monthPlanSummaryMap.value.get(dateKey) + const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0) + cells.push({ key: dateKey, value: day, active: day === clientTimeRef.value.getDate(), busy }) + } + while (cells.length % 7 !== 0) { + cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false }) + } + return cells + }) + + const todayPlanCounters = computed(() => { + const detail = todayPlanDetail.value + if (!detail) return { done: 0, doing: 0, pending: 0, total: 0, completion: 0 } + const todoDone = detail.todos.filter((item) => item.is_completed).length + const todoPending = detail.todos.filter((item) => !item.is_completed).length + const taskDone = detail.tasks.filter((item) => item.status === 'done').length + const taskDoing = detail.tasks.filter((item) => item.status === 'in_progress').length + const taskPending = detail.tasks.filter((item) => item.status === 'todo').length + const goalDone = detail.goals.filter((item) => item.status === 'done').length + const goalPending = detail.goals.filter((item) => item.status !== 'done').length + const reminderDone = detail.reminders.filter((item) => item.status === 'done' || item.is_dismissed).length + const reminderPending = detail.reminders.filter((item) => item.status !== 'done' && !item.is_dismissed).length + const done = todoDone + taskDone + goalDone + reminderDone + const doing = taskDoing + const pending = todoPending + taskPending + goalPending + reminderPending + const total = done + doing + pending + return { done, doing, pending, total, completion: total > 0 ? Math.round((done / total) * 100) : 0 } + }) + + const monthReviewStats = computed(() => monthPlanDays.value.reduce( + (acc, item) => { + acc.todoTotal += item.todo_total + acc.todoCompleted += item.todo_completed + acc.taskTotal += item.task_due_total + acc.reminderTotal += item.reminder_total + acc.goalTotal += item.goal_total + acc.highPriorityTotal += item.high_priority_total + if (item.todo_total + item.task_due_total + item.reminder_total + item.goal_total > 0) acc.activeDays += 1 + return acc + }, + { todoTotal: 0, todoCompleted: 0, taskTotal: 0, reminderTotal: 0, goalTotal: 0, highPriorityTotal: 0, activeDays: 0 }, + )) + + const sidebarWeekLabels = ['一', '二', '三', '四', '五', '六', '日'] + + const sidebarStatusHeadline = computed(() => ( + todayPlanCounters.value.total + ? `今日共 ${todayPlanCounters.value.total} 项计划,已完成 ${todayPlanCounters.value.done} 项` + : '今日计划正在同步,稍后会显示最新状态' + )) + + const sidebarStatusBreakdown = computed(() => [ + { key: 'done', label: '已完成', value: todayPlanCounters.value.done, tone: 'done' }, + { key: 'doing', label: '进行中', value: todayPlanCounters.value.doing, tone: 'doing' }, + { key: 'pending', label: '未开始', value: todayPlanCounters.value.pending, tone: 'pending' }, + ]) + + const sidebarFocusItems = computed(() => { + const detail = todayPlanDetail.value + if (!detail) return [] + const goalItems = detail.goals.filter((goal) => goal.status !== 'done').map((goal) => ({ + id: `goal-${goal.id}`, label: '目标', title: goal.title, meta: goal.note || '今日目标推进', tone: 'doing' as const, + })) + const taskItems = detail.tasks.filter((task) => task.status !== 'done' && task.status !== 'cancelled') + .sort((a, b) => { const r = { urgent: 0, high: 1, medium: 2, low: 3 }; return r[a.priority] - r[b.priority] }) + .map((task) => ({ + id: `task-${task.id}`, label: task.priority === 'urgent' || task.priority === 'high' ? '高优任务' : '任务', + title: task.title, meta: task.status === 'in_progress' ? '处理中' : '待启动', + tone: task.status === 'in_progress' ? 'doing' as const : 'pending' as const, + })) + const reminderItems = detail.reminders.filter((r) => r.status !== 'done' && !r.is_dismissed) + .map((r) => ({ id: `reminder-${r.id}`, label: '提醒', title: r.title, meta: r.reminder_at.slice(11, 16), tone: 'pending' as const })) + const todoItems = detail.todos.filter((t) => !t.is_completed) + .map((t) => ({ id: `todo-${t.id}`, label: '待办', title: t.title, meta: t.source === 'manual' ? '手动记录' : '系统同步', tone: 'pending' as const })) + return [...goalItems, ...taskItems, ...reminderItems, ...todoItems].slice(0, 5) + }) + + const sidebarReviewAchievements = computed(() => { + const stats = monthReviewStats.value + const items = [ + stats.todoCompleted > 0 ? `累计完成 ${stats.todoCompleted} 项待办,执行节啬已形成闭环。` : '', + stats.activeDays > 0 ? `本月已有 ${stats.activeDays} 天产生有效计划记录,日程连贯性稳定。` : '', + stats.highPriorityTotal > 0 ? `高优事项共 ${stats.highPriorityTotal} 项进行中,重点任务没有脱离视野。` : '', + ].filter(Boolean) + if (items.length > 0) return items.slice(0, 3) + return ['本月计划数据还在积累中,可以从今日重点开始逐步建立复盘样本。'] + }) + + const sidebarReviewReflections = computed(() => { + const stats = monthReviewStats.value + const pendingTodoCount = Math.max(stats.todoTotal - stats.todoCompleted, 0) + const items = [ + pendingTodoCount > 0 ? `仍有 ${pendingTodoCount} 项待办未完成,建议拆成更短的收尾窗口。` : '', + stats.highPriorityTotal >= 8 ? '高优事项密度偏高,最好提前锁定 1 到 2 个绝对优先级别。' : '', + stats.reminderTotal >= Math.max(6, stats.activeDays) ? '提醒数量较多,说明执行中断点偏多,适合增加固定回固时段。' : '', + ].filter(Boolean) + if (items.length > 0) return items.slice(0, 3) + return ['本月节啬相对稳定,下一步可以把重点事项再收到更清晰的主线。'] + }) + + const sidebarFeedItems = computed(() => [ + { id: 'fallback-1', title: 'AI 研发节啬继续升温,模型与工作流一体化成为主溜话题。', meta: 'Industry' }, + { id: 'fallback-2', title: '本地知识库与计划系统的联动体验,正在成为效率工具的新竞争点。', meta: 'Product' }, + { id: 'fallback-3', title: '建议接入真实 RSS 源后替换当前占位卡片,以获得即时资讯流。', meta: 'System' }, + ]) + + const topbarFeedItems = computed(() => sidebarFeedItems.value.length > 0 ? [...sidebarFeedItems.value, ...sidebarFeedItems.value] : []) + + async function loadSidebarPlanSnapshot(date = new Date()) { + const dateKey = formatDateKey(date) + const monthKey = formatMonthKey(date) + try { + const [todayResponse, monthResponse] = await Promise.all([ + scheduleCenterApi.date(dateKey), + scheduleCenterApi.month(monthKey), + ]) + todayPlanDetail.value = todayResponse.data + monthPlanDays.value = monthResponse.data.days + } catch (err) { + console.warn('Failed to load sidebar plan snapshot:', err) + todayPlanDetail.value = null + monthPlanDays.value = [] + } + } + + watch(todayDateKey, (next, previous) => { + if (next === previous) return + void loadDailyDigestFn() + void loadSidebarPlanSnapshot(clientTimeRef.value) + }) + + onMounted(() => { + void loadDailyDigestFn() + void loadSidebarPlanSnapshot(new Date()) + }) + + return { + todayPlanDetail, monthPlanDays, todayDateKey, monthPlanSummaryMap, + calendarCells, todayPlanCounters, monthReviewStats, + sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown, + sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections, + sidebarFeedItems, topbarFeedItems, loadSidebarPlanSnapshot, sidebarCollapsedModules + } +} diff --git a/frontend/src/pages/chat/index.vue b/frontend/src/pages/chat/index.vue index ab0a0cb..af1a444 100644 --- a/frontend/src/pages/chat/index.vue +++ b/frontend/src/pages/chat/index.vue @@ -1,15 +1,7 @@ + + + + diff --git a/frontend/src/pages/logs/index.vue b/frontend/src/pages/logs/index.vue index 2e0ccb6..6dc6dc8 100644 --- a/frontend/src/pages/logs/index.vue +++ b/frontend/src/pages/logs/index.vue @@ -1,284 +1,46 @@ - + diff --git a/frontend/src/pages/settings/index.vue b/frontend/src/pages/settings/index.vue index b2597eb..4650cef 100644 --- a/frontend/src/pages/settings/index.vue +++ b/frontend/src/pages/settings/index.vue @@ -251,383 +251,5 @@ const { - diff --git a/frontend/src/pages/settings/settingsPage.css b/frontend/src/pages/settings/settingsPage.css new file mode 100644 index 0000000..44c17c8 --- /dev/null +++ b/frontend/src/pages/settings/settingsPage.css @@ -0,0 +1,378 @@ +.settings-view { + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-void); + position: relative; + overflow: hidden; +} + +.bg-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px); + background-size: 40px 40px; + pointer-events: none; + z-index: 0; +} + +.bg-glow { + position: absolute; + inset: 0; + background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +.view-header { + display: flex; + align-items: center; + padding: 14px 24px; + border-bottom: 1px solid var(--border-dim); + background: rgba(5,8,16,0.6); + backdrop-filter: blur(8px); + position: relative; + z-index: 10; +} + +.header-title { + font-family: var(--font-display); + font-size: 13px; + letter-spacing: 0.2em; + color: var(--text-primary); +} + +/* Toast */ +.toast { + position: fixed; + top: 80px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: 12px; + z-index: 1000; + animation: slide-in 0.3s ease; +} + +.toast.success { + background: rgba(0, 245, 212, 0.15); + border: 1px solid var(--accent-cyan); + color: var(--accent-cyan); +} + +.toast.error { + background: rgba(255, 71, 87, 0.15); + border: 1px solid var(--accent-red); + color: var(--accent-red); +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.3s; +} +.fade-enter-from, .fade-leave-to { + opacity: 0; +} + +/* Loading */ +.loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(5,8,16,0.8); + z-index: 50; +} + +.loading-text { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.2em; + color: var(--accent-cyan); + animation: pulse 1s ease-in-out infinite; +} + +/* Content */ +.settings-content { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 16px; + position: relative; + z-index: 1; +} + +/* Card */ +.settings-card { + background: rgba(13,21,37,0.9); + border: 1px solid var(--border-dim); + border-radius: var(--radius-lg); + padding: 20px; + backdrop-filter: blur(12px); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.card-title { + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 0.2em; + color: var(--accent-cyan); +} + +.reset-btn, .add-btn, .test-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: transparent; + border: 1px solid var(--border-mid); + border-radius: var(--radius-sm); + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 10px; + cursor: pointer; + transition: all var(--transition-fast); +} + +.reset-btn:hover, .add-btn:hover, .test-btn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.add-btn { + border-color: rgba(0,245,212,0.3); + color: var(--accent-cyan); +} + +.test-btn { + border-color: rgba(0,245,212,0.3); + color: var(--accent-cyan); +} + +/* LLM Type Section */ +.llm-type-section { + margin-bottom: 20px; +} + +.llm-type-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.llm-type-title { + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 0.15em; + color: var(--accent-cyan); +} + +.optional-tag { + font-size: 9px; + color: var(--text-dim); + letter-spacing: 0.1em; +} + +.required-tag { + font-size: 9px; + color: var(--accent-red); + letter-spacing: 0.1em; +} + +/* Warning Bar */ +.warning-bar { + padding: 10px 14px; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-sm); + color: var(--accent-red); + font-family: var(--font-mono); + font-size: 11px; + margin-bottom: 16px; +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 20px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 11px; +} + +/* Form */ +.form-group { + margin-bottom: 14px; +} + +.form-row { + display: flex; + gap: 14px; +} + +.form-row .form-group { + flex: 1; +} + +.form-label { + display: block; + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.15em; + color: var(--text-dim); + margin-bottom: 6px; +} + +.form-input, .form-select { + width: 100%; + padding: 10px 12px; + background: var(--bg-card); + border: 1px solid var(--border-mid); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + transition: all var(--transition-fast); +} + +.form-input:focus, .form-select:focus { + outline: none; + border-color: var(--accent-cyan); + box-shadow: 0 0 0 1px rgba(0,245,212,0.1); +} + +.form-input.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-select { + cursor: pointer; +} + +.input-with-toggle { + display: flex; + gap: 8px; +} + +.input-with-toggle .form-input { + flex: 1; +} + +.toggle-visibility { + width: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-card); + border: 1px solid var(--border-mid); + border-radius: var(--radius-sm); + color: var(--text-dim); + cursor: pointer; + transition: all var(--transition-fast); +} + +.toggle-visibility:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +/* Toggle */ +.toggle-group { + display: flex; + align-items: center; + justify-content: space-between; +} + +.toggle-btn { + width: 44px; + height: 22px; + background: var(--bg-card); + border: 1px solid var(--border-mid); + border-radius: 11px; + padding: 2px; + cursor: pointer; + transition: all 0.25s; +} + +.toggle-btn.active { + background: rgba(0,245,212,.15); + border-color: var(--accent-cyan); +} + +.toggle-knob { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--text-dim); + transition: all 0.25s; +} + +.toggle-btn.active .toggle-knob { + background: var(--accent-cyan); + box-shadow: 0 0 8px var(--accent-cyan); + transform: translateX(22px); +} + +/* Save Button */ +.save-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 16px; + background: rgba(0,245,212,0.08); + border: 1px solid rgba(0,245,212,0.3); + border-radius: var(--radius-sm); + color: var(--accent-cyan); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.1em; + cursor: pointer; + transition: all var(--transition-fast); + margin-top: 8px; +} + +.save-btn:hover:not(:disabled) { + background: rgba(0,245,212,0.15); + box-shadow: 0 0 12px rgba(0,245,212,0.2); +} + +.save-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.save-btn.full-width { + margin-top: 0; +} + +.btn-loader { + width: 14px; + height: 14px; + border: 2px solid transparent; + border-top-color: var(--accent-cyan); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } diff --git a/frontend/src/pages/temple/index.vue b/frontend/src/pages/temple/index.vue new file mode 100644 index 0000000..891e752 --- /dev/null +++ b/frontend/src/pages/temple/index.vue @@ -0,0 +1,56 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/pages/war-room/index.vue b/frontend/src/pages/war-room/index.vue new file mode 100644 index 0000000..7bf25b8 --- /dev/null +++ b/frontend/src/pages/war-room/index.vue @@ -0,0 +1,56 @@ + + + + + \ No newline at end of file