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
This commit is contained in:
193
development-doc/plan/memory-update/phase-m-4-auto-extraction.md
Normal file
193
development-doc/plan/memory-update/phase-m-4-auto-extraction.md
Normal file
@@ -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 天** |
|
||||
228
development-doc/plan/memory-update/phase-m-5-recall-injection.md
Normal file
228
development-doc/plan/memory-update/phase-m-5-recall-injection.md
Normal file
@@ -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 天** |
|
||||
38
frontend/src/api/memory.d.ts
vendored
Normal file
38
frontend/src/api/memory.d.ts
vendored
Normal file
@@ -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<unknown>
|
||||
export function snoozeReminder(id: string, minutes: number): Promise<unknown>
|
||||
export function dismissReminder(id: string): Promise<unknown>
|
||||
export function getDueReminders(): Promise<{ data: DueReminderListResponse }>
|
||||
37
frontend/src/api/memory.js
Normal file
37
frontend/src/api/memory.js
Normal file
@@ -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')
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
302
frontend/src/components/memory/DailyDigestCard.vue
Normal file
302
frontend/src/components/memory/DailyDigestCard.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
|
||||
export interface KeyPoint {
|
||||
content: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
text: string
|
||||
type?: 'action' | 'info' | 'warning'
|
||||
}
|
||||
|
||||
export interface DailyDigestData {
|
||||
date: string
|
||||
summary: string
|
||||
keyPoints: KeyPoint[]
|
||||
suggestions: Suggestion[]
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
digest?: DailyDigestData | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="digest-card digest-loading">
|
||||
<div class="digest-skeleton header-skeleton"></div>
|
||||
<div class="digest-skeleton text-skeleton"></div>
|
||||
<div class="digest-skeleton text-skeleton short"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="digest" class="digest-card">
|
||||
<div class="digest-header">
|
||||
<div class="digest-date">
|
||||
<Calendar :size="14" class="date-icon" />
|
||||
<span class="date-label">{{ digest.date }}</span>
|
||||
</div>
|
||||
<div class="digest-badge">每日摘要</div>
|
||||
</div>
|
||||
|
||||
<div class="digest-summary">
|
||||
{{ digest.summary }}
|
||||
</div>
|
||||
|
||||
<div v-if="digest.keyPoints.length > 0" class="digest-section">
|
||||
<div class="section-label">KEY_INSIGHTS</div>
|
||||
<ul class="key-points-list">
|
||||
<li v-for="(point, index) in digest.keyPoints" :key="index" class="key-point-item">
|
||||
<span class="point-bullet">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||
<div class="point-content">
|
||||
<span class="point-text">{{ point.content }}</span>
|
||||
<span v-if="point.source" class="point-source">{{ point.source }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="digest.suggestions.length > 0" class="digest-section">
|
||||
<div class="section-label">SUGGESTIONS</div>
|
||||
<div class="suggestions-list">
|
||||
<div
|
||||
v-for="(suggestion, index) in digest.suggestions"
|
||||
:key="index"
|
||||
class="suggestion-item"
|
||||
:class="suggestion.type || 'info'"
|
||||
>
|
||||
<span class="suggestion-marker">{{ suggestion.type === 'action' ? '→' : suggestion.type === 'warning' ? '!' : '●' }}</span>
|
||||
<span class="suggestion-text">{{ suggestion.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.digest-card {
|
||||
margin: 16px 20px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(123, 44, 191, 0.15) 0%, rgba(59, 130, 246, 0.12) 50%, rgba(123, 44, 191, 0.08) 100%);
|
||||
border: 1px solid rgba(123, 44, 191, 0.25);
|
||||
border-radius: var(--radius-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: fade-in-up 0.4s ease-out;
|
||||
}
|
||||
|
||||
.digest-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(123, 44, 191, 0.5), rgba(59, 130, 246, 0.5), transparent);
|
||||
}
|
||||
|
||||
.digest-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at top right, rgba(123, 44, 191, 0.1), transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.digest-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.digest-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-icon {
|
||||
color: rgba(123, 44, 191, 0.8);
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.digest-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, rgba(123, 44, 191, 0.3), rgba(59, 130, 246, 0.25));
|
||||
border: 1px solid rgba(123, 44, 191, 0.4);
|
||||
color: #c4b5fd;
|
||||
text-shadow: 0 0 10px rgba(123, 44, 191, 0.5);
|
||||
}
|
||||
|
||||
.digest-summary {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.digest-section {
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: rgba(123, 44, 191, 0.7);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.key-points-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.key-point-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(123, 44, 191, 0.15);
|
||||
}
|
||||
|
||||
.point-bullet {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(123, 44, 191, 0.9);
|
||||
text-shadow: 0 0 8px rgba(123, 44, 191, 0.5);
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.point-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.point-text {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.point-source {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid;
|
||||
}
|
||||
|
||||
.suggestion-item.info {
|
||||
border-left-color: rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
|
||||
.suggestion-item.action {
|
||||
border-left-color: rgba(0, 245, 212, 0.6);
|
||||
}
|
||||
|
||||
.suggestion-item.warning {
|
||||
border-left-color: rgba(249, 168, 37, 0.6);
|
||||
}
|
||||
|
||||
.suggestion-marker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestion-item.info .suggestion-marker {
|
||||
color: rgba(59, 130, 246, 0.9);
|
||||
}
|
||||
|
||||
.suggestion-item.action .suggestion-marker {
|
||||
color: rgba(0, 245, 212, 0.9);
|
||||
}
|
||||
|
||||
.suggestion-item.warning .suggestion-marker {
|
||||
color: rgba(249, 168, 37, 0.9);
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
.digest-loading {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.digest-skeleton {
|
||||
background: linear-gradient(90deg, rgba(123, 44, 191, 0.1) 25%, rgba(123, 44, 191, 0.2) 50%, rgba(123, 44, 191, 0.1) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
height: 24px;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.text-skeleton {
|
||||
height: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-skeleton.short {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
224
frontend/src/components/memory/ReminderToast.vue
Normal file
224
frontend/src/components/memory/ReminderToast.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { Bell, Clock } from 'lucide-vue-next'
|
||||
|
||||
export interface ActiveReminder {
|
||||
id: string
|
||||
content: string
|
||||
triggerAt: string
|
||||
triggerType: 'time' | 'context'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
reminder: ActiveReminder | null
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'snooze', id: string, minutes: number): void
|
||||
(e: 'dismiss', id: string): void
|
||||
}>()
|
||||
|
||||
function handleSnooze() {
|
||||
if (props.reminder) {
|
||||
emit('snooze', props.reminder.id, 15)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
if (props.reminder) {
|
||||
emit('dismiss', props.reminder.id)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="slide-up">
|
||||
<div v-if="visible && reminder" class="reminder-toast">
|
||||
<div class="toast-icon">
|
||||
<Bell :size="20" />
|
||||
</div>
|
||||
|
||||
<div class="toast-content">
|
||||
<div class="toast-header">
|
||||
<span class="toast-label">REMINDER</span>
|
||||
<span class="toast-time">
|
||||
<Clock :size="10" />
|
||||
{{ formatTime(reminder.triggerAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="toast-message">{{ reminder.content }}</div>
|
||||
</div>
|
||||
|
||||
<div class="toast-actions">
|
||||
<button class="toast-btn snooze" @click="handleSnooze">
|
||||
稍后
|
||||
</button>
|
||||
<button class="toast-btn dismiss" @click="handleDismiss">
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reminder-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
min-width: 320px;
|
||||
max-width: 420px;
|
||||
background: linear-gradient(135deg, rgba(13, 21, 37, 0.98) 0%, rgba(10, 15, 26, 0.96) 100%);
|
||||
border: 1px solid rgba(249, 168, 37, 0.3);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 20px rgba(249, 168, 37, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.reminder-toast::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(249, 168, 37, 0.6), rgba(249, 168, 37, 0.8), rgba(249, 168, 37, 0.6), transparent);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(249, 168, 37, 0.15);
|
||||
color: var(--accent-amber);
|
||||
flex-shrink: 0;
|
||||
animation: pulse-glow-amber 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow-amber {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 8px rgba(249, 168, 37, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 16px rgba(249, 168, 37, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.toast-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.toast-time {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toast-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-btn {
|
||||
padding: 8px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast-btn.snooze {
|
||||
background: rgba(0, 245, 212, 0.08);
|
||||
border-color: rgba(0, 245, 212, 0.25);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.toast-btn.snooze:hover {
|
||||
background: rgba(0, 245, 212, 0.15);
|
||||
border-color: rgba(0, 245, 212, 0.4);
|
||||
box-shadow: 0 0 12px rgba(0, 245, 212, 0.2);
|
||||
}
|
||||
|
||||
.toast-btn.dismiss {
|
||||
background: rgba(249, 168, 37, 0.1);
|
||||
border-color: rgba(249, 168, 37, 0.3);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.toast-btn.dismiss:hover {
|
||||
background: rgba(249, 168, 37, 0.2);
|
||||
border-color: rgba(249, 168, 37, 0.5);
|
||||
box-shadow: 0 0 12px rgba(249, 168, 37, 0.25);
|
||||
}
|
||||
|
||||
/* Slide-up animation */
|
||||
.slide-up-enter-active {
|
||||
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.25s ease-in;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(40px) scale(0.95);
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
93
frontend/src/pages/chat/composables/useClientTime.ts
Normal file
93
frontend/src/pages/chat/composables/useClientTime.ts
Normal file
@@ -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<number | null>(null)
|
||||
let clientTimeTimer: ReturnType<typeof setInterval> | 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
|
||||
}
|
||||
}
|
||||
74
frontend/src/pages/chat/composables/useDailyDigest.ts
Normal file
74
frontend/src/pages/chat/composables/useDailyDigest.ts
Normal file
@@ -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<any>(null)
|
||||
const digestLoading = ref(false)
|
||||
const recentDigests = ref<any[]>([])
|
||||
const activeReminder = ref<any>(null)
|
||||
const reminderVisible = ref(false)
|
||||
let reminderPollTimer: ReturnType<typeof setInterval> | 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
|
||||
}
|
||||
}
|
||||
194
frontend/src/pages/chat/composables/useSidebarPlan.ts
Normal file
194
frontend/src/pages/chat/composables/useSidebarPlan.ts
Normal file
@@ -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<ScheduleCenterDateResponse | null>(null)
|
||||
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
|
||||
|
||||
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<SidebarFocusItem[]>(() => {
|
||||
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<SidebarNewsItem[]>(() => [
|
||||
{ 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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
2354
frontend/src/pages/chat/index_backup_sidebar.vue
Normal file
2354
frontend/src/pages/chat/index_backup_sidebar.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,284 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { logApi, type Log, type LogQueryParams, type LogStats } from '@/api/log'
|
||||
import { Terminal, RefreshCw, Bot, MessageSquare, Settings, AlertCircle, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||
import { useLogsView } from './composables/useLogsView'
|
||||
|
||||
type TimePreset = '1h' | '6h' | '24h' | '7d' | 'custom'
|
||||
|
||||
interface LogFilters {
|
||||
preset: TimePreset
|
||||
start_at: string
|
||||
end_at: string
|
||||
request_id: string
|
||||
route: string
|
||||
operation: string
|
||||
status_code: string
|
||||
log_type: string
|
||||
level: string
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
const logs = ref<Log[]>([])
|
||||
const stats = ref<LogStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const expandedLogId = ref<string | null>(null)
|
||||
const autoRefresh = ref(false)
|
||||
let refreshInterval: ReturnType<typeof window.setInterval> | null = null
|
||||
|
||||
const pageSizeOptions = [20, 50, 100]
|
||||
const timePresets: Array<{ value: TimePreset; label: string }> = [
|
||||
{ value: '1h', label: '1h' },
|
||||
{ value: '6h', label: '6h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '7d', label: '7d' },
|
||||
{ value: 'custom', label: '自定义' },
|
||||
]
|
||||
|
||||
const logTypes = [
|
||||
{ value: '', label: '全部', icon: null },
|
||||
{ value: 'agent', label: '智能体', icon: Bot },
|
||||
{ value: 'system', label: '系统', icon: Settings },
|
||||
{ value: 'chat', label: '问答', icon: MessageSquare },
|
||||
]
|
||||
|
||||
const logLevels = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'debug', label: '调试' },
|
||||
{ value: 'info', label: '信息' },
|
||||
{ value: 'warning', label: '警告' },
|
||||
{ value: 'error', label: '错误' },
|
||||
]
|
||||
|
||||
const statusCodeOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: '200', label: '200' },
|
||||
{ value: '400', label: '400' },
|
||||
{ value: '401', label: '401' },
|
||||
{ value: '404', label: '404' },
|
||||
{ value: '422', label: '422' },
|
||||
{ value: '500', label: '500' },
|
||||
]
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: 'var(--text-dim)',
|
||||
info: 'var(--accent-cyan)',
|
||||
warning: 'var(--accent-amber)',
|
||||
error: 'var(--accent-red)',
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
agent: '智能体',
|
||||
system: '系统',
|
||||
chat: '问答',
|
||||
}
|
||||
|
||||
function toDatetimeLocalValue(date: Date): string {
|
||||
const offsetMs = date.getTimezoneOffset() * 60 * 1000
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function getPresetRange(preset: Exclude<TimePreset, 'custom'>) {
|
||||
const end = new Date()
|
||||
const start = new Date(end)
|
||||
const hours = preset === '1h' ? 1 : preset === '6h' ? 6 : preset === '24h' ? 24 : 24 * 7
|
||||
start.setHours(start.getHours() - hours)
|
||||
return {
|
||||
start_at: toDatetimeLocalValue(start),
|
||||
end_at: toDatetimeLocalValue(end),
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultFilters(): LogFilters {
|
||||
return {
|
||||
preset: '24h',
|
||||
...getPresetRange('24h'),
|
||||
request_id: '',
|
||||
route: '',
|
||||
operation: '',
|
||||
status_code: '',
|
||||
log_type: '',
|
||||
level: '',
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
}
|
||||
}
|
||||
|
||||
const filters = ref<LogFilters>(createDefaultFilters())
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / filters.value.page_size)))
|
||||
const visiblePageNumbers = computed(() => {
|
||||
const totalPageCount = totalPages.value
|
||||
const currentPage = filters.value.page
|
||||
if (totalPageCount <= 5) {
|
||||
return Array.from({ length: totalPageCount }, (_, index) => index + 1)
|
||||
}
|
||||
|
||||
let startPage = Math.max(1, currentPage - 2)
|
||||
let endPage = Math.min(totalPageCount, startPage + 4)
|
||||
|
||||
if (endPage - startPage < 4) {
|
||||
startPage = Math.max(1, endPage - 4)
|
||||
}
|
||||
|
||||
return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index)
|
||||
})
|
||||
const total = ref(0)
|
||||
|
||||
function formatTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
if (Number.isNaN(d.getTime())) return dateStr
|
||||
return d.toLocaleString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms == null) return ''
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function formatDetails(details: Record<string, unknown> | null): string {
|
||||
if (!details) return ''
|
||||
return JSON.stringify(details, null, 2)
|
||||
}
|
||||
|
||||
function toggleDetails(logId: string) {
|
||||
expandedLogId.value = expandedLogId.value === logId ? null : logId
|
||||
}
|
||||
|
||||
function normalizeFilters(): LogQueryParams {
|
||||
return {
|
||||
log_type: filters.value.log_type || undefined,
|
||||
level: filters.value.level || undefined,
|
||||
request_id: filters.value.request_id.trim() || undefined,
|
||||
route: filters.value.route.trim() || undefined,
|
||||
operation: filters.value.operation.trim() || undefined,
|
||||
status_code: filters.value.status_code ? Number(filters.value.status_code) : undefined,
|
||||
start_at: filters.value.start_at ? new Date(filters.value.start_at).toISOString() : undefined,
|
||||
end_at: filters.value.end_at ? new Date(filters.value.end_at).toISOString() : undefined,
|
||||
page: filters.value.page,
|
||||
page_size: filters.value.page_size,
|
||||
}
|
||||
}
|
||||
|
||||
function validateDateRange() {
|
||||
if (filters.value.start_at && filters.value.end_at && filters.value.start_at > filters.value.end_at) {
|
||||
errorMessage.value = '开始时间不能晚于结束时间'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const requestedPage = filters.value.page
|
||||
const res = await logApi.list(normalizeFilters())
|
||||
logs.value = res.data.logs
|
||||
total.value = res.data.total
|
||||
const maxPage = Math.max(1, Math.ceil(res.data.total / filters.value.page_size))
|
||||
if (requestedPage > maxPage) {
|
||||
filters.value.page = maxPage
|
||||
if (maxPage !== requestedPage) {
|
||||
await fetchLogs()
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '加载日志失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await logApi.getStats(normalizeFilters())
|
||||
stats.value = res.data
|
||||
} catch {
|
||||
if (!errorMessage.value) {
|
||||
errorMessage.value = '加载日志统计失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!validateDateRange()) return
|
||||
await Promise.all([fetchLogs(), fetchStats()])
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
filters.value.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = createDefaultFilters()
|
||||
loadData()
|
||||
}
|
||||
|
||||
function applyPreset(preset: TimePreset) {
|
||||
filters.value.preset = preset
|
||||
if (preset !== 'custom') {
|
||||
const range = getPresetRange(preset)
|
||||
filters.value.start_at = range.start_at
|
||||
filters.value.end_at = range.end_at
|
||||
}
|
||||
}
|
||||
|
||||
function markCustomRange() {
|
||||
filters.value.preset = 'custom'
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh.value = !autoRefresh.value
|
||||
if (autoRefresh.value) {
|
||||
refreshInterval = window.setInterval(() => {
|
||||
loadData()
|
||||
}, 5000)
|
||||
} else if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page < 1 || page > totalPages.value || page === filters.value.page) {
|
||||
return
|
||||
}
|
||||
filters.value.page = page
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
goToPage(filters.value.page - 1)
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
goToPage(filters.value.page + 1)
|
||||
}
|
||||
|
||||
function changePageSize(size: number) {
|
||||
filters.value.page_size = size
|
||||
filters.value.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
})
|
||||
const {
|
||||
Terminal,
|
||||
RefreshCw,
|
||||
Bot,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
logs,
|
||||
stats,
|
||||
loading,
|
||||
errorMessage,
|
||||
expandedLogId,
|
||||
autoRefresh,
|
||||
filters,
|
||||
total,
|
||||
totalPages,
|
||||
visiblePageNumbers,
|
||||
timePresets,
|
||||
logTypes,
|
||||
logLevels,
|
||||
statusCodeOptions,
|
||||
levelColors,
|
||||
typeLabels,
|
||||
formatTime,
|
||||
formatDuration,
|
||||
formatDetails,
|
||||
toggleDetails,
|
||||
applyPreset,
|
||||
markCustomRange,
|
||||
toggleAutoRefresh,
|
||||
applyFilters,
|
||||
resetFilters,
|
||||
goToPage,
|
||||
prevPage,
|
||||
nextPage,
|
||||
changePageSize,
|
||||
loadData,
|
||||
} = useLogsView()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -415,346 +177,11 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<button class="page-btn" :disabled="filters.page >= totalPages" @click="nextPage">下一页</button>
|
||||
<select class="filter-select page-size-select" :value="filters.page_size" @change="changePageSize(Number(($event.target as HTMLSelectElement).value))">
|
||||
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}/页</option>
|
||||
<option v-for="size in [20, 50, 100]" :key="size" :value="size">{{ size }}/页</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.view-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover, .btn-icon.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.btn-icon .spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-value.cyan { color: var(--accent-cyan); }
|
||||
.stat-value.purple { color: var(--accent-purple); }
|
||||
.stat-value.amber { color: var(--accent-amber); }
|
||||
.stat-value.red { color: var(--accent-red); }
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn,
|
||||
.filter-input,
|
||||
.filter-select,
|
||||
.action-btn,
|
||||
.page-btn {
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-btn,
|
||||
.action-btn,
|
||||
.page-btn {
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-btn.active,
|
||||
.filter-btn:hover,
|
||||
.action-btn:hover,
|
||||
.page-btn:hover:not(:disabled),
|
||||
.page-btn.active,
|
||||
.filter-input:focus,
|
||||
.filter-select:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.filter-btn.active,
|
||||
.action-btn.primary,
|
||||
.page-btn.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.filter-input,
|
||||
.filter-select {
|
||||
min-width: 120px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.log-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 60px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-items {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.log-summary {
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.log-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-item:hover {
|
||||
background: rgba(0, 245, 212, 0.02);
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.log-type {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.log-type.agent {
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.log-type.system {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.log-type.chat {
|
||||
background: rgba(249, 168, 37, 0.1);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.log-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.log-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-status,
|
||||
.log-operation,
|
||||
.log-expand {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-details {
|
||||
padding: 0 12px 12px;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
margin-top: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: var(--bg-void);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.page-summary {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.page-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
min-width: 36px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
min-width: 90px;
|
||||
}
|
||||
</style>
|
||||
<style scoped src="./logsPage.css"></style>
|
||||
|
||||
@@ -251,383 +251,5 @@ const {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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; } }
|
||||
<style scoped src="./settingsPage.css">
|
||||
</style>
|
||||
|
||||
378
frontend/src/pages/settings/settingsPage.css
Normal file
378
frontend/src/pages/settings/settingsPage.css
Normal file
@@ -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; } }
|
||||
56
frontend/src/pages/temple/index.vue
Normal file
56
frontend/src/pages/temple/index.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// 智慧神殿 - Temple of Wisdom
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="temple-page">
|
||||
<div class="page-header">
|
||||
<h1>⛩️ 智慧神殿</h1>
|
||||
<p class="subtitle">深邃智慧,永恒传承</p>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div class="placeholder-content">
|
||||
<div class="temple-icon">🏛️</div>
|
||||
<p>智慧神殿 - 敬请期待</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.temple-page {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.temple-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
56
frontend/src/pages/war-room/index.vue
Normal file
56
frontend/src/pages/war-room/index.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// 战情室 - War Room
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="war-room-page">
|
||||
<div class="page-header">
|
||||
<h1>🗺️ 战情室</h1>
|
||||
<p class="subtitle">运筹帷幄,决胜千里</p>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div class="placeholder-content">
|
||||
<div class="war-icon">⚔️</div>
|
||||
<p>战情室 - 敬请期待</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.war-room-page {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.war-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user