Version: 0.9.33.dev.260419
后端: 1. deliver 收口上下文重构——历史折叠到工作区,仅基于本轮 execute 窗口诚实收口 - newAgent/prompt/deliver.go:BuildDeliverMessages 改为向 buildDeliverWorkspace 透传 ConversationContext - newAgent/prompt/deliver_context.go:deliver 的 msg1 改为轻量提示,不再回灌完整历史;msg2 追加本轮 execute 窗口与结果态信息 前端: 2. 品牌命名统一切换为 SmartMate - index.html:页面标题从 SmartFlow 改为 SmartMate - package.json:前端包名改为 smartmate-frontend - App.vue:布局类名从 smartflow-* 统一改为 smartmate-* - stores/auth.ts:access/refresh token 与 last username 的 localStorage key 全部切到 smartmate_* - utils/idempotency.ts:默认幂等键前缀从 smartflow 改为 smartmate - DashboardView.vue:首页默认问候名从 SmartFlow 用户改为 SmartMate 用户 3. 助手页体验重做——默认空会话、排程卡片懒加载、上下文统计刷新时机收口 - components/dashboard/AssistantPanel.vue:进入页面不再自动打开最后一次会话,改为展示居中欢迎空态 - components/dashboard/AssistantPanel.vue:schedule_completed 改为先展示占位卡片,点击后再拉取 schedule preview,避免预览未落库时并发 404 - components/dashboard/AssistantPanel.vue:tool done、schedule card、SSE block done、[DONE] 与整轮流结束后统一刷新 context stats - components/dashboard/AssistantPanel.vue:重构聊天区布局、空态欢迎内容、底部交互区与内外边距,整体视觉切到更轻的阅读式界面 - views/AssistantView.vue:移除外层白底卡片壳,交由 AssistantPanel 自己承接容器视觉 4. 排程微调保存链路补幂等保护,并修正请求头口径 - api/schedule_agent.ts:正式应用接口请求头从 Idempotency-Key 改为 X-Idempotency-Key - components/assistant/ScheduleFineTuneModal.vue:同一预览会话复用稳定幂等键,保存成功后再刷新新 key,避免重试或延迟导致重复落库 - components/assistant/ScheduleResultCard.vue:结果卡片样式、hover 与进场动效整体升级 5. 任务类选择器与侧边导航细节调整 - components/assistant/TaskClassPlanningPicker.vue:popover、骨架屏、列表项、选中态与按钮视觉整体重绘 - components/common/MainSidebar.vue:移除“任务”占位入口,侧栏只保留总览 / 日程 / 助手 6. 登录页与首页展示风格重做 - views/AuthView.vue:品牌文案切到 SmartMate,登录/注册从 tabs 改为自定义双态切换,重做背景、玻璃卡片、表单与动效 - views/DashboardView.vue:首页主区改为 auto + 1fr 布局,锁定顶部栏高度,避免缩放时形变 仓库: 7. README 全量更新到当前版本能力边界 - README.md:重写项目定位、功能描述、业务闭环图、newAgent graph 流程、工具定义、前端衔接边界、页面展示、部署方案与监控说明
This commit is contained in:
722
README.md
722
README.md
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
> **问题3:** 那么我作为一个规划能力比较差的懒人,也能用这个项目来让自己变的充实吗?
|
> **问题3:** 那么我作为一个规划能力比较差的懒人,也能用这个项目来让自己变的充实吗?
|
||||||
|
|
||||||
**本项目带来的解决方案3:** 当然可以,这就是本项目接入AI的意义。聊天区域的AI将会被调教成一个日程安排的小助手,既能满足你简单的对日程的增删改查,又能协助你从0开始一点点制定属于你的计划。(**第一批次开发计划**只支持AI随口记这一"增"的功能,以及大多数能想到的"查"功能,暂时无让AI改和删的想法)
|
**本项目带来的解决方案3:** 当然可以,这就是本项目接入AI的意义。聊天区域的AI将会被调教成一个日程安排的小助手,既能满足你简单的任务记录、任务查询与部分日程调整需求,又能协助你从0开始一点点制定属于你的计划。(当前版本已支持AI随口记、任务查询,以及部分日程调整的确认流;更完整的自动规划与更复杂的读写能力仍在继续迭代)
|
||||||
|
|
||||||
> **问题4:** 我平时会突然冒出来一个能让自己活的更舒服亦或是变得更好的小想法(例如把桌面理一下、给自己挑一件新衣服等),但是现在很忙,根本没时间做,然后等忙完了有时间了又忘记了。传统的日程软件确实能让我记录下来(比如将这个小想法记录在日程软件的四象限里面的"不重要不紧急"象限),就是太麻烦了。
|
> **问题4:** 我平时会突然冒出来一个能让自己活的更舒服亦或是变得更好的小想法(例如把桌面理一下、给自己挑一件新衣服等),但是现在很忙,根本没时间做,然后等忙完了有时间了又忘记了。传统的日程软件确实能让我记录下来(比如将这个小想法记录在日程软件的四象限里面的"不重要不紧急"象限),就是太麻烦了。
|
||||||
>
|
>
|
||||||
@@ -58,31 +58,31 @@
|
|||||||
|
|
||||||
1. **对用户目前时间尺度的适应。**
|
1. **对用户目前时间尺度的适应。**
|
||||||
|
|
||||||
如果用户是正在上学的大学生,时间尺度可以设置为以学校排课为主,通用时间为辅(以第1-2节,第3-4节这种的学校排课的时间方式为主,又能兼容某个特定时间的突发小事,做到对总体执行效率和事情安排效率的兼顾);
|
当前版本以学校排课节次为主,采用第1-2节、第3-4节这种时间组织方式,同时兼容首页任务管理与周课表日程编排两套视图,方便把任务管理和日程安排放在同一系统中;
|
||||||
|
|
||||||
如果用户既想要自定义时间,又想要一键编排任务,本项目还支持用户自定义时间尺度,例如设置9:00-11:00为第一节课等。
|
目前暂未开放用户自定义时间尺度配置,当前仍以固定节次模型为主。后续会有更新计划的!
|
||||||
|
|
||||||
2. **导入学校课表。** 如果用户选择以学校排课为主的时间尺度,本项目支持快速导入学校课表(只会尝试兼容CQUPT的课表格式),以便后续以课表为基底的日程安排。
|
2. **导入学校课表。** 本项目后端已提供学校课表导入能力(当前主要尝试兼容CQUPT的课表格式),以便后续以课表为基底进行日程安排;前端完整导入流程入口仍在补齐。
|
||||||
|
|
||||||
3. **"水课"任务嵌入。** 正如上方**问题2**所言,在导入课表后,支持设置某一门你想拿来干其它事情的课为"可嵌入任务"状态,此时这门课所占据的时间区域就是可以嵌入任务的了,但是仍然有区别于其它完全空白的时间区域,便于真正安排适合在嘈杂环境下做的事情。
|
3. **"水课"任务嵌入。** 正如上方**问题2**所言,在已导入课表的前提下,支持设置某一门你想拿来干其它事情的课为"可嵌入任务"状态,此时这门课所占据的时间区域就是可以嵌入任务的了,但是仍然有区别于其它完全空白的时间区域,便于真正安排适合在嘈杂环境下做的事情。
|
||||||
|
|
||||||
4. **设置某一任务类,并提前安排其执行路线。** 正如上方**问题1**所言,用户可以先设置一个大的任务类(例如概率论复习、算法进阶计划等等),再在这个任务类下方安排其在对应时间尺度下的执行计划(例如第1-2节干啥,第3-4节干啥),方便后续的日程编排。
|
4. **设置某一任务类,并配置其编排参数。** 正如上方**问题1**所言,用户可以先设置一个大的任务类(例如概率论复习、算法进阶计划等等),再为这个任务类配置任务块内容、起止日期、总节数、编排策略等信息,方便后续的日程编排。
|
||||||
|
|
||||||
5. **一键编排任务。** 结合算法、用户偏好以及AI的建议,将任务基于上方的时间尺度、导入的课表,排进日程中,并给出这样排的理由(如果动用了AI)。
|
5. **一键编排任务。** 结合算法与用户配置,将任务基于导入的课表和任务类设置先生成预览结果;确认无误后,再正式应用到日程中。
|
||||||
|
|
||||||
6. **AI随口记。** 正如问题4所言,就是支持通过AI随手记录一些大小事。
|
6. **AI随口记与任务查询。** 正如问题4所言,当前版本支持通过AI随手记录一些大小事,也支持按象限、关键词、截止时间等维度查询任务;部分日程调整能力已接入确认流。
|
||||||
|
|
||||||
7. **多用户。** 本系统可支持多个用户同时使用,并且记录AI对话、编排任务的Token使用情况等,并进行限额。
|
7. **多用户。** 本系统可支持多个用户同时使用,并且记录AI对话、编排任务的Token使用情况等,并进行限额。
|
||||||
|
|
||||||
8. **动态任务和静态任务。** **动态任务**包括学校的课和排入日程中的任务类,这些任务随着时间往后会默认已经完成,无需手动勾选;
|
8. **四象限任务与日程编排并行管理。** 首页的四象限任务用于日常待办管理;
|
||||||
|
|
||||||
而**静态任务**为四个任务队列中的任务,这些任务需要手动勾选为完成状态。
|
而周课表中的课程与排入日程的任务类则用于日程编排,两类信息分别展示、相互配合。
|
||||||
|
|
||||||
9. **完成任务状态的撤回。** 无论是因为哪种情况,是误触给队列里面的任务打钩,还是水课翘课了被叫回去点名导致任务中断,都支持**撤回**这个"任务已完成"的状态。
|
9. **完成任务状态的恢复。** 当前首页四象限任务支持将"已完成"恢复为"未完成"状态。
|
||||||
|
|
||||||
前者,用户只需要在队列的下沉列表中找到该任务然后点击一下灰色的勾即可(模仿了滴答清单的设计)。
|
用户只需要在队列中找到该任务,然后再次点击对应状态按钮即可完成恢复。
|
||||||
|
|
||||||
至于后者,由于后者为动态任务,所以用户需要手动去"最近已完成任务"的清单里面选择该任务然后恢复,此时任务会自动回到未安排状态。目前暂不支持课程的撤回,课程方面的改动目前仅支持删除,其它操作后续考虑开发。
|
至于日程侧,当前主要支持删除、解除安排与预览后再正式应用,其它更完整的恢复能力仍在后续迭代中。
|
||||||
|
|
||||||
10. **长期记忆积累。** 系统在对话中自动抽取用户相关事实与偏好,跨会话召回并注入对话上下文,实现"越用越懂你"的个性化体验。支持结构化检索 + 向量召回双路召回,全链路优雅降级。
|
10. **长期记忆积累。** 系统在对话中自动抽取用户相关事实与偏好,跨会话召回并注入对话上下文,实现"越用越懂你"的个性化体验。支持结构化检索 + 向量召回双路召回,全链路优雅降级。
|
||||||
|
|
||||||
@@ -90,23 +90,65 @@
|
|||||||
|
|
||||||
## 2.1 业务流程图
|
## 2.1 业务流程图
|
||||||
|
|
||||||
|
当前版本主业务闭环如下,重点体现“首页任务管理 / AI 助手 / 日程编排 / 长期记忆”四条主线如何互相联动:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["用户进入系统"] --> B{"是否已登录"}
|
||||||
|
B -- "否" --> C["/auth 登录 / 注册"]
|
||||||
|
C --> D["进入主工作区"]
|
||||||
|
B -- "是" --> D
|
||||||
|
|
||||||
## 2.2 原型展示
|
D --> E{"选择功能入口"}
|
||||||
|
|
||||||

|
E -- "首页 /dashboard" --> F["查看四象限任务 + 今日日程"]
|
||||||
|
F --> G["创建任务 / 完成任务 / 恢复任务"]
|
||||||
|
G --> H["首页持续展示最新待办状态"]
|
||||||
|
|
||||||

|
E -- "AI 助手 /assistant" --> I["输入自然语言需求"]
|
||||||
|
I --> J["newAgent 统一 graph"]
|
||||||
|
J --> K{"路由结果"}
|
||||||
|
K -- "随口记 / 任务查询" --> L["写入任务或返回查询结果"]
|
||||||
|
K -- "智能编排 / 日程调整" --> M["plan / confirm / execute / deliver"]
|
||||||
|
K -- "普通问答" --> N["直接回复用户"]
|
||||||
|
|
||||||

|
M --> O["返回排程结果卡片"]
|
||||||
|
O --> P["查看结构化预览并继续微调"]
|
||||||
|
P --> Q{"如何收口"}
|
||||||
|
Q -- "暂存" --> R["保存到 Redis 运行态"]
|
||||||
|
Q -- "正式应用" --> S["写入正式课表"]
|
||||||
|
|
||||||

|
L --> T["更新任务列表 / 对话历史"]
|
||||||
|
N --> U["沉淀对话历史"]
|
||||||
|
|
||||||

|
E -- "日程中心 /schedule" --> V["查看周课表与任务类"]
|
||||||
|
V --> W["新建任务类 / 删除任务块 / 单选或批量多选"]
|
||||||
|
W --> X["智能粗排 / 批量粗排"]
|
||||||
|
X --> Y["前端预览态拖拽调整"]
|
||||||
|
Y --> Z["正式应用到日程"]
|
||||||
|
|
||||||

|
S --> AA["周课表更新"]
|
||||||
|
Z --> AA
|
||||||
|
H --> AB["用户继续使用系统"]
|
||||||
|
T --> AB
|
||||||
|
U --> AB
|
||||||
|
AA --> AB
|
||||||
|
|
||||||

|
AB --> AC["异步事件:聊天持久化 / Outbox / Memory 抽取"]
|
||||||
|
AC --> AD["长期记忆写入与更新"]
|
||||||
|
AD --> AE["下一轮对话自动召回 memory_context"]
|
||||||
|
AE --> I
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2 页面展示
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
# 3 后端数据架构
|
# 3 后端数据架构
|
||||||
|
|
||||||
@@ -278,6 +320,51 @@ CREATE TABLE `users`
|
|||||||
|
|
||||||
## 4.2 Agent可调用的工具定义
|
## 4.2 Agent可调用的工具定义
|
||||||
|
|
||||||
|
以下定义基于当前代码实现(`backend/newAgent/tools/registry.go` + `backend/cmd/start.go` 注入),不是规划态文档。
|
||||||
|
|
||||||
|
### 4.2.1 调用契约
|
||||||
|
|
||||||
|
1. `tool_call` 必须是单个对象,格式为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"tool_call":{"name":"工具名","arguments":{}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 日程写工具默认走 `confirm` 确认闸门(`always_execute=true` 时可跳过确认)。
|
||||||
|
3. 非日程写工具(如 `quick_note_create`、`query_tasks`、`web_search`、`web_fetch`)走 `continue + tool_call`。
|
||||||
|
4. 当前每轮只允许调用一个工具,不支持同轮批量工具数组。
|
||||||
|
|
||||||
|
### 4.2.2 工具清单(当前版本)
|
||||||
|
|
||||||
|
| 工具名 | 类型 | 是否需确认 | 是否依赖 ScheduleState | 核心参数 | 作用与约束 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `get_overview` | 读 | 否 | 是 | 无 | 获取规划窗口总览(任务视角,全量返回) |
|
||||||
|
| `query_range` | 读 | 否 | 是 | `day`(必填), `slot_start`, `slot_end` | 查询某天/某时段占用详情 |
|
||||||
|
| `query_available_slots` | 读 | 否 | 是 | `span`, `duration`, `limit`, `day_scope`, `week_filter` 等 | 查询候选空位池(纯空位优先,不足再补可嵌入位) |
|
||||||
|
| `query_target_tasks` | 读 | 否 | 是 | `status`, `category`, `task_ids`, `enqueue` 等 | 过滤任务集合,可选自动入队供后续队列工具处理 |
|
||||||
|
| `queue_pop_head` | 读 | 否 | 是 | 无 | 取出/复用当前队首任务(一次只处理一个) |
|
||||||
|
| `queue_status` | 读 | 否 | 是 | 无 | 查看队列状态(pending/current/completed/skipped) |
|
||||||
|
| `get_task_info` | 读 | 否 | 是 | `task_id`(必填) | 查询单任务详细信息 |
|
||||||
|
| `place` | 写 | 是 | 是 | `task_id`, `day`, `slot_start`(均必填) | 将待安排任务预排到指定位置 |
|
||||||
|
| `move` | 写 | 是 | 是 | `task_id`, `new_day`, `new_slot_start`(均必填) | 仅允许移动 `suggested`;`existing` 不可 `move` |
|
||||||
|
| `swap` | 写 | 是 | 是 | `task_a`, `task_b`(均必填) | 交换两个已落位任务,要求时长一致 |
|
||||||
|
| `batch_move` | 写 | 是 | 是 | `moves[]`(必填) | 原子批量移动,当前最多 2 条,任一冲突整批回滚 |
|
||||||
|
| `queue_apply_head_move` | 写 | 是 | 是 | `new_day`, `new_slot_start`(均必填) | 移动当前队首并自动出队,不接受 `task_id` |
|
||||||
|
| `queue_skip_head` | 队列控制 | 否 | 是 | `reason` | 跳过当前队首并标记 `skipped`(不改日程) |
|
||||||
|
| `spread_even` | 写 | 是 | 是 | `task_ids`(必填,兼容 `task_id`) | 在任务集合内做均匀铺开,按筛选条件原子落地 |
|
||||||
|
| `min_context_switch` | 写 | 是 | 是 | `task_ids`(必填,兼容 `task_id`) | 减少上下文切换重排;仅在用户明确允许打乱顺序时可执行 |
|
||||||
|
| `unplace` | 写 | 是 | 是 | `task_id`(必填) | 取消任务落位并恢复待安排状态 |
|
||||||
|
| `quick_note_create` | 读写混合(业务写入) | 否 | 否 | `title`(必填), `deadline_at`, `priority_group` | 记录随口记任务,支持中文相对时间;优先级可自动推断 |
|
||||||
|
| `query_tasks` | 读 | 否 | 否 | `quadrant`, `keyword`, `deadline_before/after`, `limit` 等 | 按象限/关键词/时间边界查询任务 |
|
||||||
|
| `web_search` | 读 | 否 | 否 | `query`(必填), `top_k`, `domain_allow`, `recency_days` | Web 检索,返回结构化标题/摘要/URL;未启用时优雅返回错误 observation |
|
||||||
|
| `web_fetch` | 读 | 否 | 否 | `url`(必填), `max_chars` | 抓取并清洗网页正文;服务不可用时优雅返回错误 observation |
|
||||||
|
|
||||||
|
### 4.2.3 当前实现中的关键规则
|
||||||
|
|
||||||
|
1. `min_context_switch` 有顺序护栏:未授权“允许打乱顺序”会被后端拦截并返回拒绝结果。
|
||||||
|
2. `batch_move` 有安全上限:当前最多支持 2 条移动请求,超出建议走队列化逐项处理。
|
||||||
|
3. `quick_note_create` 和 `query_tasks` 不依赖 `ScheduleState`,由执行层注入 `_user_id` 后可直接调用。
|
||||||
|
4. `web_search`/`web_fetch` 失败不会打断主链路,都会回传结构化错误 observation 给模型继续决策。
|
||||||
|
|
||||||
|
|
||||||
# 5 后端实现
|
# 5 后端实现
|
||||||
@@ -368,193 +455,170 @@ $$Gap = \frac{TotalAvailableSlots - (TaskCount \times 2)}{TaskCount + 1}$$
|
|||||||
|
|
||||||
## 5.4 Agent范式实现细节
|
## 5.4 Agent范式实现细节
|
||||||
|
|
||||||
### 1) 总分流图(消息识别后的去向)
|
### 1) 统一入口与 graph 主链
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A["/api/v1/agent/chat<br/>解析请求体 + 规范 conversation_id<br/>Header 写入 X-Conversation-ID"] --> B["AgentService.AgentChat<br/>创建 outChan / errChan"]
|
A["/api/v1/agent/chat<br/>接收 user_message / thinkingMode / extra"] --> B["AgentService.runNewAgentGraph"]
|
||||||
B --> C["规范 chat_id + 选择模型(worker/strategist)"]
|
B --> C["确保会话存在<br/>加载或创建 RuntimeState"]
|
||||||
C --> D["确保会话存在<br/>先查 Redis 状态<br/>未命中回源 DB + 必要时创建"]
|
C --> D["恢复或构建 ConversationContext<br/>并预取 memory pinned block"]
|
||||||
D --> E["模型控制码路由<br/>route.DecideActionRouting<br/>action=chat/quick_note_create/task_query/schedule_plan_create/schedule_plan_refine"]
|
D --> E["先持久化本轮 user message"]
|
||||||
E --> F{"RouteFailed?"}
|
E --> F["组装 AgentGraphRunInput<br/>Chat/Deliver 用 Pro<br/>Plan/Execute 用 Max"]
|
||||||
|
F --> G["RunAgentGraph"]
|
||||||
|
G --> H["START -> chat"]
|
||||||
|
|
||||||
F -- "是" --> G["pushErrNonBlocking(errChan, RouteControlInternalError)<br/>API 侧 SSE 输出 error + [DONE]"]
|
H --> I{"chat 决定下一步"}
|
||||||
F -- "否" --> H{"action 类型"}
|
I -- "简单回复 / 深答" --> J["chat 节点内直接完成"]
|
||||||
|
I -- "复杂规划" --> K["plan"]
|
||||||
|
I -- "直接执行" --> L["execute"]
|
||||||
|
I -- "execute 前需要粗排" --> M["rough_build"]
|
||||||
|
I -- "已有 pending interaction" --> N["chat 先做 resume"]
|
||||||
|
|
||||||
H -- "chat" --> I["runNormalChatFlow<br/>Redis 取历史 -> miss 回源 DB + 回填<br/>裁剪上下文窗口 -> StreamChat 流式输出"]
|
K --> O["confirm / rough_build / execute / deliver / interrupt"]
|
||||||
I --> I2["后置持久化收口<br/>user/assistant 先写 Redis<br/>再 PersistChatHistory(outbox 或同步DB)<br/>异步尝试生成标题"]
|
L --> P["execute / confirm / order_guard / deliver / interrupt"]
|
||||||
|
M --> Q["execute / order_guard / deliver / interrupt"]
|
||||||
H -- "quick_note_create" --> J["发阶段块 request.accepted<br/>tryHandleQuickNoteWithGraph"]
|
|
||||||
J --> J1{"graph 出错?"}
|
|
||||||
J1 -- "是" --> J2["记录日志 + 发 fallback 阶段块<br/>回退 runNormalChatFlow"]
|
|
||||||
J1 -- "否" --> J3{"handled=true?"}
|
|
||||||
J3 -- "否" --> J2
|
|
||||||
J3 -- "是" --> J4["buildQuickNoteFinalReply<br/>emitSingleAssistantCompletion"]
|
|
||||||
J4 --> J5["persistChatAfterReply<br/>统一后置持久化 + 异步标题"]
|
|
||||||
|
|
||||||
H -- "task_query" --> K["runTaskQueryFlow -> TaskQueryGraph<br/>plan/quadrant/time_anchor/tool_query/reflect"]
|
|
||||||
K --> K1{"查询链路报错?"}
|
|
||||||
K1 -- "是" --> K2["记录日志 + 发 fallback 阶段块<br/>回退 runNormalChatFlow"]
|
|
||||||
K1 -- "否" --> K3["emitSingleAssistantCompletion<br/>persistChatAfterReply + 异步标题"]
|
|
||||||
|
|
||||||
H -- "schedule_plan_create" --> L["runSchedulePlanFlow -> SchedulePlanGraph<br/>并写入排程预览缓存"]
|
|
||||||
L --> L1{"排程链路报错?"}
|
|
||||||
L1 -- "是" --> L2["记录日志 + 发 fallback 阶段块<br/>回退 runNormalChatFlow"]
|
|
||||||
L1 -- "否" --> L3["emitSingleAssistantCompletion<br/>persistChatAfterReply + 异步标题"]
|
|
||||||
|
|
||||||
H -- "schedule_plan_refine" --> M["runScheduleRefineFlow -> ScheduleRefineGraph<br/>读取上一版排程预览上下文"]
|
|
||||||
M --> M1{"连续微调链路报错?"}
|
|
||||||
M1 -- "是" --> M2["直接上报错误<br/>不回退普通聊天"]
|
|
||||||
M1 -- "否" --> M3["emitSingleAssistantCompletion<br/>persistChatAfterReply + 异步标题"]
|
|
||||||
|
|
||||||
H -- "未知 action" --> N["兜底回退 runNormalChatFlow"]
|
|
||||||
|
|
||||||
I2 --> Z["API c.Stream 转发 outChan/errChan<br/>正常收尾或错误收尾"]
|
|
||||||
J2 --> Z
|
|
||||||
J5 --> Z
|
|
||||||
K2 --> Z
|
|
||||||
K3 --> Z
|
|
||||||
L2 --> Z
|
|
||||||
L3 --> Z
|
|
||||||
M2 --> Z
|
|
||||||
M3 --> Z
|
|
||||||
N --> Z
|
|
||||||
G --> Z
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2) 命中“添加日程/随口记”后的业务流转
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[用户消息进入 /agent/chat] --> B[规范会话ID + 选模型]
|
|
||||||
B --> C[确保会话存在<br/>Redis会话状态检查<br/>必要时回源DB创建]
|
|
||||||
C --> D[模型控制码路由<br/>action=quick_note/chat]
|
|
||||||
D --> E{route是否命中quick_note}
|
|
||||||
E -- 否 --> X[普通聊天链路<br/>StreamChat流式输出<br/>或者其它分支]
|
|
||||||
E -- 是 --> F[quick_note.request.accepted<br/>推送reasoning状态块]
|
|
||||||
F --> G[跳过二次意图判定<br/>直接进入聚合规划]
|
|
||||||
G --> H[单请求聚合规划<br/>生成title/deadline/priority/banter]
|
|
||||||
H --> I[时间校验<br/>quick_note.deadline.validating]
|
|
||||||
I --> J{时间是否有效}
|
|
||||||
J -- 否 --> K[返回纠错文案<br/>不写库<br/>quick_note.failed]
|
|
||||||
J -- 是 --> L{优先级是否有效}
|
|
||||||
L -- 是 --> M[复用聚合优先级]
|
|
||||||
L -- 否 --> N[本地优先级兜底<br/>不再二次调用模型]
|
|
||||||
M --> O[调用写库工具<br/>quick_note.persisting]
|
|
||||||
N --> O
|
N --> O
|
||||||
O --> P{task_id是否有效}
|
N --> P
|
||||||
P -- 否 --> Q[按重试策略处理<br/>最终返回失败文案]
|
|
||||||
P -- 是 --> R[quick_note.persisted]
|
J --> R["deliver 或 END"]
|
||||||
R --> S[拼接最终正文<br/>优先复用聚合banter<br/>一次性content输出]
|
O --> S["deliver"]
|
||||||
S --> T[后置持久化<br/>user+assistant写Redis<br/>并写outbox/DB]
|
P --> S
|
||||||
X --> T
|
Q --> S
|
||||||
|
S --> T["END"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3) 命中“随口问/任务查询”后的业务流转
|
### 2) `chat` 节点路由与恢复
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A["用户消息进入 /agent/chat"] --> B["通用控制码分流<br/>action=chat/quick_note_create/task_query/schedule_plan_create/schedule_plan_refine"]
|
A["进入 chat 节点"] --> B{"存在 pending interaction?"}
|
||||||
B --> C{"action 是否为 task_query"}
|
|
||||||
C -- 否 --> D["走其它分支<br/>普通聊天或随口记"]
|
B -- "是" --> C["handleChatResume<br/>不再调路由 LLM"]
|
||||||
C -- 是 --> E["进入 TaskQueryGraph"]
|
C --> D{"pending 类型"}
|
||||||
E --> F["节点1: plan<br/>一次模型调用产出查询计划"]
|
D -- "ask_user" --> E["ResumeFromPending<br/>发 resumed 状态"]
|
||||||
F --> G["节点2: quadrant<br/>归一化象限范围"]
|
E --> F["恢复原 phase<br/>继续 plan 或 execute"]
|
||||||
G --> H["节点3: time_anchor<br/>锁定时间过滤边界"]
|
D -- "confirm + accept" --> G["恢复 PendingTool<br/>Phase=executing"]
|
||||||
H --> I["节点4: tool_query<br/>调用 query_tasks 工具查询"]
|
D -- "confirm + reject(计划)" --> H["RejectPlan<br/>回 planning"]
|
||||||
I --> J{"首次结果是否为空"}
|
D -- "confirm + reject(工具)" --> I["回 executing 改策略"]
|
||||||
J -- 是 --> K["自动放宽一次<br/>仅放宽关键词/完成状态/时间边界"]
|
D -- "interaction_id 不匹配" --> J["stale_resume<br/>本轮结束"]
|
||||||
K --> L["再次调用 query_tasks"]
|
|
||||||
J -- 否 --> M["进入反思节点"]
|
B -- "否" --> K{"上一轮是否 completed?"}
|
||||||
|
K -- "是" --> L["写 execute_loop_closed marker<br/>ResetForNextRun"]
|
||||||
|
K -- "否" --> M["直接构建路由消息"]
|
||||||
L --> M
|
L --> M
|
||||||
M --> N["节点5: reflect<br/>模型判断结果是否满足用户诉求"]
|
M --> N["一次快速路由 LLM<br/>BuildChatRoutingMessages"]
|
||||||
N --> O{"need_retry 且未超上限"}
|
N --> O{"route"}
|
||||||
O -- 是 --> P["应用 retry_patch<br/>重试次数+1"]
|
|
||||||
P --> I
|
O -- "direct_reply" --> P["chat 内直接流式回复<br/>Phase=chatting -> END"]
|
||||||
O -- 否 --> Q["后端确定性渲染最终回复<br/>严格按 limit 输出条数"]
|
O -- "deep_answer" --> Q["二次 deep answer 调用<br/>可开启 thinking -> END"]
|
||||||
Q --> R["后置持久化<br/>user+assistant 写 Redis + outbox/DB"]
|
O -- "plan" --> R["Phase=planning"]
|
||||||
|
O -- "execute" --> S["StartDirectExecute<br/>写 AllowReorder / ExecuteThinking"]
|
||||||
|
|
||||||
|
S --> T{"NeedsRoughBuild?"}
|
||||||
|
T -- "是" --> U["rough_build"]
|
||||||
|
T -- "否" --> V["execute"]
|
||||||
|
R --> W["plan"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4) 命中新建“智能排程”后的业务流转图
|
### 3) `plan / confirm / rough_build` 链路
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A["命中 action=schedule_plan_create<br/>发 request.accepted 阶段块"] --> B["runSchedulePlanFlow 入口"]
|
A["Phase=planning -> plan 节点"] --> B["LLM 生成 PlanDecision<br/>可开启 thinking"]
|
||||||
B --> B1{"依赖齐全?<br/>model + 3个函数注入"}
|
B --> C{"action"}
|
||||||
B1 -- "否" --> B2["返回 error 给上层<br/>上层回退普通聊天"]
|
|
||||||
B1 -- "是" --> C["清理旧预览缓存<br/>DeleteSchedulePlanPreview<br/>失败仅记日志"]
|
|
||||||
C --> D["加载对话历史<br/>Redis 优先 -> miss 回源 DB<br/>失败降级为空历史继续"]
|
|
||||||
D --> E["RunSchedulePlanGraph<br/>注入并发度与预算配置"]
|
|
||||||
|
|
||||||
E --> P1["plan 节点<br/>合并 extra.task_class_ids + 模型提取约束/策略/标签<br/>模型失败时可用 extra 兜底"]
|
C -- "continue" --> A
|
||||||
P1 --> P1B{"FinalSummary 非空<br/>或 task_class_ids 为空?"}
|
C -- "ask_user" --> D["OpenAskUserInteraction<br/>graph -> interrupt"]
|
||||||
P1B -- "是" --> PX["exit 节点 -> END<br/>直接返回已有失败文案"]
|
C -- "done" --> E["FinishPlan<br/>写 pinned blocks:<br/>current_plan / current_step"]
|
||||||
P1B -- "否" --> P2["rough_build 节点<br/>HybridScheduleWithPlanMulti 构建 HybridEntries<br/>可选解析全局窗口(起止周/天)"]
|
|
||||||
|
|
||||||
P2 --> P2B{"HybridEntries 为空<br/>或构建失败?"}
|
E --> F{"AlwaysExecute?"}
|
||||||
P2B -- "是" --> PX
|
F -- "否" --> G["confirm"]
|
||||||
P2B -- "否" --> P3{"len(task_class_ids) >= 2 ?"}
|
G --> H["EmitConfirmRequest<br/>OpenConfirmInteraction(type=plan)"]
|
||||||
|
H --> I["interrupt 等用户"]
|
||||||
|
I --> J["用户下一轮回复 -> chat resume"]
|
||||||
|
J --> K{"accept / reject"}
|
||||||
|
K -- "accept" --> L{"NeedsRoughBuild?"}
|
||||||
|
K -- "reject" --> A
|
||||||
|
|
||||||
P3 -- "是" --> P4["daily_split<br/>按周天拆 DayGroup + 注入 ContextTag<br/>suggested<=2 标记 SkipRefine"]
|
F -- "是" --> M["自动展示计划摘要<br/>ConfirmPlan -> PhaseExecuting"]
|
||||||
P4 --> P5["daily_refine(并发)<br/>按天并发 ReAct<br/>单天失败回退原天结果"]
|
M --> L
|
||||||
P5 --> P6["merge<br/>合并 DailyResults<br/>冲突则整体回退 merge 前快照"]
|
|
||||||
P6 --> P7["weekly_refine(并发按周)<br/>有效周保底预算 + 负载加权分配"]
|
|
||||||
|
|
||||||
P3 -- "否" --> P7
|
L -- "否" --> N["execute"]
|
||||||
P7 --> P7A["单周 worker 循环<br/>每轮只允许 1 个 Move/Swap 或 done<br/>总预算(成功/失败都扣) + 有效预算(仅成功扣)<br/>Move 受本周与全局窗口硬约束"]
|
L -- "是" --> O["rough_build"]
|
||||||
|
O --> P["调用 RoughBuildFunc<br/>写回 ScheduleState"]
|
||||||
P7A --> P8["final_check<br/>physicsCheck(冲突/节次越界/数量核对)<br/>失败回退 MergeSnapshot<br/>再生成自然语言总结"]
|
P --> Q["写 rough_build_done pinned block<br/>标记 HasScheduleChanges"]
|
||||||
P8 --> P9["return_preview<br/>回填 AllocatedItems 嵌入时间<br/>生成 CandidatePlans + FinalSummary + Completed"]
|
Q --> R{"仍有真实 pending?"}
|
||||||
P9 --> F1["saveSchedulePlanPreview<br/>写 Redis 结构化快照<br/>失败仅记日志"]
|
R -- "是" --> S["Abort -> deliver"]
|
||||||
F1 --> F2["返回 FinalSummary 给 AgentChat"]
|
R -- "否" --> T{"需要继续微调?"}
|
||||||
|
T -- "是" --> N
|
||||||
F2 --> G1["emitSingleAssistantCompletion<br/>SSE 输出终审文本"]
|
T -- "否" --> U["Done -> deliver"]
|
||||||
G1 --> G2["persistChatAfterReply<br/>user/assistant 写 Redis + outbox/DB"]
|
|
||||||
G2 --> G3["ensureConversationTitleAsync"]
|
|
||||||
|
|
||||||
F1 --> H1["结构化通道<br/>GET /api/v1/agent/schedule-preview?conversation_id=..."]
|
|
||||||
H1 --> H2["GetSchedulePlanPreview<br/>按 user_id + conversation_id 读 Redis 快照<br/>未命中返回业务错误码"]
|
|
||||||
|
|
||||||
B2 --> Z["上层发 fallback 阶段块<br/>回退 runNormalChatFlow"]
|
|
||||||
PX --> F1
|
|
||||||
G3 --> Z
|
|
||||||
H2 --> Z
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5) 命中“排程连续微调”后的业务流转图
|
### 4) `execute / confirm / order_guard` 链路
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A["命中 action=schedule_plan_refine<br/>发 request.accepted 阶段块"] --> B["runScheduleRefineFlow 入口"]
|
A["Phase=executing -> execute 节点"] --> U{"NextRound 成功?"}
|
||||||
B --> C{"selectedModel 非空?"}
|
U -- "否" --> V["Exhaust -> deliver"]
|
||||||
C -- "否" --> C1["直接返回错误<br/>不回退普通聊天"]
|
U -- "是" --> B{"有 PendingConfirmTool?"}
|
||||||
C -- "是" --> D["loadSchedulePreviewContext<br/>Redis 预览优先 -> miss 回源 MySQL 快照"]
|
B -- "是" --> C["直接执行已确认写工具"]
|
||||||
D --> E{"上一版预览存在?"}
|
B -- "否" --> D["BuildExecuteMessages<br/>LLM 产出 ExecuteDecision"]
|
||||||
E -- "否" --> E1["返回 SchedulePlanPreviewNotFound<br/>直接上报错误"]
|
|
||||||
E -- "是" --> F["NewScheduleRefineState<br/>注入 HybridEntries / AllocatedItems / CandidatePlans / OriginOrderMap"]
|
|
||||||
F --> G["RunScheduleRefineGraph"]
|
|
||||||
|
|
||||||
G --> H["contract<br/>抽取 intent / strategy / hard_assertions<br/>默认 keep_relative_order=true"]
|
C --> E["写 observation / 更新 ScheduleState"]
|
||||||
H --> I["plan<br/>生成 3~4 步执行计划<br/>必要时注入复合工具硬条件"]
|
D --> F{"action"}
|
||||||
I --> J["slice<br/>提取 week/source_days/target_days<br/>编译 objective + workset"]
|
|
||||||
J --> K["route<br/>命中 SpreadEven / MinContextSwitch 时先走复合路由<br/>首次 + 最多2次重试"]
|
|
||||||
K --> K1{"CompositeRouteSucceeded?"}
|
|
||||||
K1 -- "是" --> L["react<br/>检测到已收口,直接 skip"]
|
|
||||||
K1 -- "否" --> M["react<br/>单任务微步循环<br/>失败后禁复合,只用基础工具"]
|
|
||||||
L --> N["hard_check<br/>先锁定业务目标<br/>再按需顺序归位 / 一次修复"]
|
|
||||||
M --> N
|
|
||||||
N --> O["summary<br/>回填 AllocatedItems + CandidatePlans<br/>Completed 仅由终审是否通过决定"]
|
|
||||||
|
|
||||||
O --> P{"shouldPersistScheduleRefinePreview?"}
|
F -- "continue + tool_call" --> G["每轮只调用 1 个工具"]
|
||||||
P -- "是" --> Q["saveSchedulePlanPreview<br/>覆盖 Redis + MySQL 快照"]
|
G --> H{"写工具且需要确认?"}
|
||||||
P -- "否" --> R["emit schedule_refine.preview.skipped<br/>保留上一版预览基线"]
|
H -- "是" --> I["PendingConfirmTool -> confirm"]
|
||||||
Q --> S["emitSingleAssistantCompletion<br/>输出 FinalSummary"]
|
I --> J["EmitConfirmRequest<br/>OpenConfirmInteraction(type=execute)"]
|
||||||
|
J --> K["interrupt 等用户"]
|
||||||
|
K --> L["用户 accept -> chat resume -> execute"]
|
||||||
|
H -- "否" --> M["写 observation<br/>继续 execute 循环"]
|
||||||
|
|
||||||
|
F -- "continue 无工具" --> M
|
||||||
|
F -- "ask_user" --> N["OpenAskUserInteraction<br/>interrupt 等用户"]
|
||||||
|
F -- "next_plan" --> O["推进 current_step<br/>写 execute_step_advanced marker"]
|
||||||
|
O --> P{"还有后续计划?"}
|
||||||
|
P -- "是" --> M
|
||||||
|
P -- "否" --> Q["Done -> deliver"]
|
||||||
|
|
||||||
|
F -- "done" --> R{"AllowReorder?"}
|
||||||
|
R -- "false" --> S["order_guard<br/>校验 suggested 相对顺序"]
|
||||||
|
S --> T["自动复原或保守放行"]
|
||||||
|
T --> Q
|
||||||
|
R -- "true" --> Q
|
||||||
|
|
||||||
|
F -- "abort" --> Q
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5) `interrupt / deliver / 状态持久化` 链路
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["plan / execute / confirm 产生 pending"] --> B["interrupt"]
|
||||||
|
B --> C{"pending 类型"}
|
||||||
|
C -- "ask_user" --> D["把 DisplayText 当 assistant 文本输出"]
|
||||||
|
C -- "confirm" --> E["不重复发确认卡片<br/>仅发 waiting status"]
|
||||||
|
D --> F["interrupt -> END"]
|
||||||
|
E --> F
|
||||||
|
F --> G["saveAgentState"]
|
||||||
|
G --> H["用户下一轮输入<br/>重新从 chat resume"]
|
||||||
|
|
||||||
|
I["任务正常完成 / abort / exhausted"] --> J["deliver"]
|
||||||
|
J --> K["GenerateDeliverSummary<br/>LLM 失败则降级机械总结"]
|
||||||
|
K --> L{"completed 且有日程变更?"}
|
||||||
|
L -- "是" --> M["EmitScheduleCompleted"]
|
||||||
|
L -- "否" --> N["跳过排程完毕卡片"]
|
||||||
|
M --> O["输出最终总结"]
|
||||||
|
N --> O
|
||||||
|
|
||||||
|
O --> P{"terminal_status=completed?"}
|
||||||
|
P -- "是" --> Q["WriteSchedulePreview<br/>只写结果态工作区"]
|
||||||
|
P -- "否" --> R["跳过排程预览写入"]
|
||||||
|
Q --> S["deliver 后 saveAgentState"]
|
||||||
R --> S
|
R --> S
|
||||||
S --> T["persistChatAfterReply<br/>统一后置持久化 + 异步标题"]
|
S --> T["graph 返回 service<br/>发布 AgentStateSnapshot(outbox)"]
|
||||||
|
T --> U["EmitDone + 异步生成会话标题"]
|
||||||
C1 --> Z["错误直接返回前端"]
|
|
||||||
E1 --> Z
|
|
||||||
T --> Z
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5.5 长期记忆系统
|
## 5.5 长期记忆系统
|
||||||
@@ -578,50 +642,58 @@ flowchart TD
|
|||||||
### 读路径(同步)
|
### 读路径(同步)
|
||||||
|
|
||||||
```
|
```
|
||||||
用户消息到达 → injectMemoryContext → 结构化检索(按用户/类别过滤)
|
用户消息到达 → injectMemoryContext
|
||||||
+ 向量召回(Milvus) → 重排序 → 拼接为 pinned block 注入 Prompt → LLM 生成回复
|
→ 先读 Redis 预取缓存并注入 memory_context(上一轮检索结果,首字节零等待)
|
||||||
|
→ 后台完整检索:结构化检索(按用户/类别过滤) + 向量召回(Milvus) + 重排序
|
||||||
|
→ 渲染后的最新结果经 channel 交给 Plan/Execute 节点消费,同时回写 Redis
|
||||||
|
→ 作为下一轮 Chat 节点的预取记忆继续接力 → LLM 生成回复
|
||||||
```
|
```
|
||||||
|
|
||||||
关键设计:
|
关键设计:
|
||||||
|
|
||||||
1. **双路召回**:结构化检索保证精确匹配,向量召回覆盖语义关联,两者合并后重排序取 Top-K。
|
1. **Redis 接力预取**:本轮 Chat 先消费上一轮写入 Redis 的记忆预取缓存,保证首字节几乎不受完整检索耗时影响;后台再把最新检索结果通过 channel 交给 Plan/Execute,并顺手回写 Redis,形成“上一轮服务下一轮”的接力链路。
|
||||||
2. **优雅降级**:记忆检索失败时不阻断主对话链路,仅降级为无记忆模式,不影响正常功能。
|
2. **双路召回**:完整检索阶段仍采用结构化检索 + 向量召回的混合模式,先保证精确过滤,再补足语义相关性,最后统一重排序取 Top-K。
|
||||||
3. **访问时间刷新**:被召回的记忆会更新最近访问时间,热点记忆更不容易被淘汰。
|
3. **优雅降级**:Redis 未命中、后台检索失败、短应答跳过检索等情况都不会阻断主链路;最差也只是本轮不注入记忆,而不是把对话打挂。
|
||||||
|
|
||||||
# 6 前端实现
|
# 6 前端实现
|
||||||
|
|
||||||
PS:当前前端进度大幅度落后于后端,将在后端闭环跑通后开始维护。
|
当前前端位于 `frontend/` 目录,已经形成一个可独立运行的 Vue 单页应用,并且前端入口目前呈现为“两条主线并存”:
|
||||||
|
|
||||||
## 6.1 当前前端技术栈与工程约定
|
1. `/assistant`:承接 `newAgent` 对话、确认、时间线、排程结果卡片与微调链路。
|
||||||
|
2. `/schedule`:承接传统课表中心、任务类侧栏、粗排预览、拖拽调整与正式应用。
|
||||||
|
|
||||||
当前前端位于 `frontend/` 目录,已经落地为一个可独立运行的 Vue 单页应用。
|
## 6.1 当前前端技术栈与整体结构
|
||||||
|
|
||||||
技术栈如下:
|
技术栈如下:
|
||||||
|
|
||||||
| 分类 | 当前选型 | 说明 |
|
| 分类 | 当前选型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 前端框架 | Vue 3 | 统一使用 Composition API 与 `<script setup>` 编写页面与组件 |
|
| 前端框架 | Vue 3 | 统一使用 Composition API 与 `<script setup>` |
|
||||||
| 构建工具 | Vite 6 | 本地开发、代理联调、生产构建均由 Vite 提供 |
|
| 构建工具 | Vite 6 | 本地开发、代理联调、生产构建统一由 Vite 提供 |
|
||||||
| 语言 | TypeScript | 页面、接口层、类型定义均已类型化 |
|
| 语言 | TypeScript | API、页面状态、排程数据结构已基本类型化 |
|
||||||
| UI 组件库 | Element Plus | 用于表单、输入框、下拉框、消息提示、弹窗等基础交互 |
|
| UI 组件库 | Element Plus | 登录、表单、选择器、弹窗、消息提示等基础交互由其承接 |
|
||||||
| 状态管理 | Pinia | 当前主要承载登录态与 token 持久化 |
|
| 状态管理 | Pinia | 当前主要用于认证态、token 与用户信息持久化 |
|
||||||
| 路由 | Vue Router 4 | 已配置鉴权守卫、访客页守卫与页面跳转 |
|
| 路由 | Vue Router 4 | 已配置 `requiresAuth` / `guestOnly` 守卫 |
|
||||||
| HTTP | Axios + 原生 fetch | 常规 JSON 接口走 Axios;AI 对话流式 SSE 走原生 `fetch` |
|
| HTTP | Axios + 原生 `fetch` | 常规 JSON 接口走 Axios;`/agent/chat` 流式接口走原生 `fetch` |
|
||||||
| Markdown 渲染 | markdown-it + highlight.js | AI 回复正文支持 Markdown 渲染与代码高亮 |
|
| Markdown 渲染 | markdown-it + highlight.js | 助手正文支持 Markdown 渲染与代码高亮 |
|
||||||
|
|
||||||
当前前端工程结构如下:
|
当前前端工程结构如下:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
frontend/src
|
frontend/src
|
||||||
├─ api/ # 接口封装:auth / task / schedule / scheduleCenter / agent
|
├─ api/ # auth / task / schedule / scheduleCenter / agent / schedule_agent
|
||||||
├─ components/
|
├─ components/
|
||||||
│ ├─ dashboard/ # 首页与 AI 面板相关组件
|
│ ├─ assistant/ # 上下文窗口、排程结果卡片、微调弹窗、任务类选择器
|
||||||
│ └─ schedule/ # 智能排程页相关组件
|
│ ├─ common/ # 全局主侧边栏 MainSidebar
|
||||||
├─ router/ # 路由定义与前置守卫
|
│ ├─ dashboard/ # 首页卡片与 AssistantPanel
|
||||||
|
│ └─ schedule/ # 任务类侧栏、周课表画板、创建弹窗
|
||||||
|
├─ router/ # 路由定义与守卫
|
||||||
├─ stores/ # Pinia store(当前主要是 auth)
|
├─ stores/ # Pinia store(当前主要是 auth)
|
||||||
├─ types/ # 页面与接口类型定义
|
├─ types/ # dashboard / schedule / api 类型定义
|
||||||
├─ utils/ # 日期、HTTP 错误、Markdown、幂等 key 等工具
|
├─ utils/ # 日期、Markdown、HTTP 错误、幂等 key 等工具
|
||||||
└─ views/ # Auth / Dashboard / Assistant / Schedule 四个主视图
|
├─ views/ # Auth / Dashboard / Assistant / Schedule / Prototype
|
||||||
|
├─ App.vue # 全局布局壳层与 router-view 容器
|
||||||
|
└─ main.ts # Vue / Pinia / Router / Element Plus 挂载入口
|
||||||
```
|
```
|
||||||
|
|
||||||
工程约定如下:
|
工程约定如下:
|
||||||
@@ -629,24 +701,28 @@ frontend/src
|
|||||||
1. 所有业务请求默认走 `/api/v1` 前缀。
|
1. 所有业务请求默认走 `/api/v1` 前缀。
|
||||||
2. 本地开发通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8080`。
|
2. 本地开发通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8080`。
|
||||||
3. 常规接口统一走 `frontend/src/api/http.ts`,内置 `401 -> refresh token -> 原请求重放`。
|
3. 常规接口统一走 `frontend/src/api/http.ts`,内置 `401 -> refresh token -> 原请求重放`。
|
||||||
4. 对话流接口 `POST /api/v1/agent/chat` 因为要消费 SSE,所以单独用原生 `fetch`。
|
4. 对话流接口 `POST /api/v1/agent/chat` 单独走原生 `fetch`,并在前端手动处理一次 refresh token 重试。
|
||||||
5. 写操作尽量补 `X-Idempotency-Key`,当前任务创建、日程应用、日程删除、任务块删除都已经这样处理。
|
5. 写操作尽量补 `X-Idempotency-Key`,当前任务类创建、日程应用、日程删除、任务块删除都已接入。
|
||||||
|
6. `App.vue` 会对 `/dashboard`、`/assistant`、`/schedule` 统一套用主壳层与 `MainSidebar`,而不是每个页面各自维护一套侧边栏。
|
||||||
|
|
||||||
## 6.2 当前页面与路由状态
|
## 6.2 当前页面与路由状态
|
||||||
|
|
||||||
当前前端已经接通的页面路由如下:
|
当前已接通的页面路由如下:
|
||||||
|
|
||||||
| 路由 | 页面状态 | 说明 |
|
| 路由 | 页面状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `/auth` | 已完成 | 登录/注册同页切换,登录成功后按 `redirect` 返回目标页 |
|
| `/` | 已完成 | 默认重定向到 `/dashboard` |
|
||||||
| `/dashboard` | 已完成 | 首页工作台,展示四象限任务、今日日程、快捷创建任务、左侧导航 |
|
| `/auth` | 已完成 | 登录/注册同页切换,支持 `redirect` 回跳 |
|
||||||
| `/assistant` | 已完成 | 独立 AI 对话页,复用同一套 AI 面板组件,支持历史会话与流式消息 |
|
| `/dashboard` | 已完成 | 首页工作台,承接四象限任务与今日日程 |
|
||||||
| `/schedule` | 已完成 | 周课表与任务编排中心,支持任务类、粗排、预览、拖拽、应用 |
|
| `/assistant` | 已完成 | `newAgent` 对话主入口,支持时间线、确认卡片、排程结果卡片 |
|
||||||
|
| `/schedule` | 已完成 | 传统课表中心,支持任务类、粗排、拖拽预览与正式应用 |
|
||||||
|
| `/prototype/tool-trace` | 原型页 | 用于展示工具 trace / 阻断 / 排程卡片交互原型,不在主导航中暴露 |
|
||||||
|
|
||||||
当前仍处于占位/未独立成页的入口:
|
当前仍未接出正式独立路由的入口:
|
||||||
|
|
||||||
1. 左侧导航里的“任务”按钮目前还是占位提示,没有独立路由页。
|
1. “任务”独立页仍未落地,侧边栏没有 `/task` 路由。
|
||||||
2. 侧边栏底部“设置”按钮目前也是视觉占位,没有接出 `/settings`。
|
2. 侧边栏底部“设置”按钮仍是视觉占位,尚未接出 `/settings`。
|
||||||
|
3. `src/views/layout`、`src/views/login`、`src/views/register`、`src/views/settings` 以及 `src/store/` 目前更偏预留/历史残留目录,不承载当前主运行链路。
|
||||||
|
|
||||||
## 6.3 认证页 `/auth`
|
## 6.3 认证页 `/auth`
|
||||||
|
|
||||||
@@ -659,25 +735,30 @@ frontend/src
|
|||||||
当前行为:
|
当前行为:
|
||||||
|
|
||||||
1. 登录与注册共用一页,通过 tab 切换。
|
1. 登录与注册共用一页,通过 tab 切换。
|
||||||
2. 登录成功后会写入 `access_token`、`refresh_token` 与最近一次登录用户名。
|
2. 登录成功后会把 `access_token`、`refresh_token` 与最近一次用户名写入 `localStorage`。
|
||||||
3. 路由守卫会阻止未登录用户进入 `/dashboard`、`/assistant`、`/schedule`。
|
3. 路由守卫会阻止未登录用户进入 `/dashboard`、`/assistant`、`/schedule`。
|
||||||
4. 登录态失效时,Axios 拦截器会自动尝试刷新 token;刷新失败则清空本地登录态并跳回登录页。
|
4. 普通 JSON 接口走 Axios 自动续签;流式对话接口走 `fetch` 时也会在前端手动尝试一次 refresh token 重试。
|
||||||
|
5. 登出时会先尽力调用后端注销接口,再无条件清理本地登录态,避免前端出现“假在线”。
|
||||||
|
|
||||||
## 6.4 首页工作台 `/dashboard`
|
## 6.4 统一壳层与首页工作台 `/dashboard`
|
||||||
|
|
||||||
对应文件:
|
对应文件:
|
||||||
|
|
||||||
|
- `frontend/src/App.vue`
|
||||||
|
- `frontend/src/components/common/MainSidebar.vue`
|
||||||
- `frontend/src/views/DashboardView.vue`
|
- `frontend/src/views/DashboardView.vue`
|
||||||
- `frontend/src/components/dashboard/TaskQuadrantCard.vue`
|
- `frontend/src/components/dashboard/TaskQuadrantCard.vue`
|
||||||
- `frontend/src/components/dashboard/TodayTimeline.vue`
|
- `frontend/src/components/dashboard/TodayTimeline.vue`
|
||||||
|
|
||||||
当前已实现能力:
|
当前已实现能力:
|
||||||
|
|
||||||
1. 左侧导航栏与顶部欢迎区已经落地,首页与 `/assistant` 使用统一的主视觉语言。
|
1. `/dashboard`、`/assistant`、`/schedule` 已统一挂在同一套全局壳层下,由 `App.vue + MainSidebar` 负责外层布局。
|
||||||
2. 中心区展示四象限任务卡片,支持获取任务列表、创建任务、完成任务、撤销完成任务。
|
2. 侧边栏当前只保留“总览 / 日程 / 助手”三项主导航,避免过早把尚未做完的页面入口暴露出来。
|
||||||
3. 右侧展示“今日日程”,通过 `/schedule/today` 拉取当天事件。
|
3. 首页中心区展示四象限任务卡片,支持获取任务列表、创建任务、完成任务、撤销完成任务。
|
||||||
4. 首页整体做了缩放适配,目标是在 100% 缩放下尽量完整展示主要内容,而不是依赖用户手动缩放浏览器。
|
4. 右侧展示“今日日程”,通过 `GET /api/v1/schedule/today` 拉取当天事件。
|
||||||
5. 首页右侧已经不再承载完整 AI 对话页;AI 对话已收口到独立 `/assistant` 页面。
|
5. 首页顶部已具备退出登录、用户昵称展示、当前日期展示等基础工作台能力。
|
||||||
|
6. 首页整体做了缩放适配,目标是在常见笔记本分辨率下尽量完整展示主要内容,而不是依赖用户手动缩放浏览器。
|
||||||
|
7. “课表导入”入口目前仍是占位提示,还没有真正接出导入向导页。
|
||||||
|
|
||||||
## 6.5 AI 对话页 `/assistant`
|
## 6.5 AI 对话页 `/assistant`
|
||||||
|
|
||||||
@@ -685,27 +766,37 @@ frontend/src
|
|||||||
|
|
||||||
- `frontend/src/views/AssistantView.vue`
|
- `frontend/src/views/AssistantView.vue`
|
||||||
- `frontend/src/components/dashboard/AssistantPanel.vue`
|
- `frontend/src/components/dashboard/AssistantPanel.vue`
|
||||||
|
- `frontend/src/components/assistant/ContextWindowMeter.vue`
|
||||||
|
- `frontend/src/components/assistant/TaskClassPlanningPicker.vue`
|
||||||
|
- `frontend/src/components/assistant/ScheduleResultCard.vue`
|
||||||
|
- `frontend/src/components/assistant/ScheduleFineTuneModal.vue`
|
||||||
- `frontend/src/api/agent.ts`
|
- `frontend/src/api/agent.ts`
|
||||||
|
- `frontend/src/api/schedule_agent.ts`
|
||||||
|
|
||||||
当前已实现能力:
|
当前已实现能力:
|
||||||
|
|
||||||
1. 页面拆成“左侧主导航 + 右侧 AI 面板”两部分,最左侧侧栏样式已与首页统一。
|
1. `/assistant` 页面本身很薄,核心能力基本都收敛在 `AssistantPanel` 中,便于后续继续复用为嵌入态或独立页态。
|
||||||
2. AI 面板同时支持嵌入态和独立页态;`/assistant` 使用独立页态。
|
2. 已接通的对话与上下文接口包括:
|
||||||
3. 已接通的对话相关接口包括:
|
|
||||||
- `POST /api/v1/agent/chat`
|
- `POST /api/v1/agent/chat`
|
||||||
- `GET /api/v1/agent/conversation-list`
|
- `GET /api/v1/agent/conversation-list`
|
||||||
- `GET /api/v1/agent/conversation-meta`
|
- `GET /api/v1/agent/conversation-meta`
|
||||||
- `GET /api/v1/agent/conversation-history`
|
- `GET /api/v1/agent/conversation-history`
|
||||||
4. 支持流式 SSE 回复,并区分深度思考内容、正文内容,以及刷新后从历史接口恢复的 `reasoning_duration_seconds`。
|
- `GET /api/v1/agent/context-stats`
|
||||||
5. 历史消息支持重试分页元数据:`retry_group_id`、`retry_index`、`retry_total`。
|
- `GET /api/v1/agent/conversation-timeline`
|
||||||
6. 助手消息底部已支持复制、重新生成、版本分页切换。
|
3. 已接通的排程结果相关接口包括:
|
||||||
7. 用户消息底部已支持复制、修改消息;当前“修改消息”的语义是“复制到输入框后重新发送一条新消息”,不会覆盖旧消息。
|
- `GET /api/v1/agent/schedule-preview`
|
||||||
8. “重新生成”按钮当前的前端策略是:
|
- `POST /api/v1/agent/schedule-state`
|
||||||
- 先尝试直接使用当前消息上的持久化 ID;
|
- `PUT /api/v1/task-class/apply-batch-into-schedule`
|
||||||
- 若命中的是本地乐观态消息,则先静默调用一次历史接口补抓真实 ID;
|
4. 输入区支持两类核心控制参数:`thinking` 模式(`auto / true / false`)与执行模式(`manual / always`)。
|
||||||
- 仍然拿不到时,再提示:`消息正在处理,请稍后再重试,或者直接复制消息重新发送`。
|
5. 对于智能编排类对话,前端可通过 `TaskClassPlanningPicker` 透传 `task_class_ids`,让 `newAgent` 直接以指定任务类启动规划。
|
||||||
9. 历史消息与本地乐观态消息会做合并,避免刷新历史时把当前页内正在看的消息直接抹掉。
|
6. 流式回复已支持区分正文、思考内容和结构化 `extra` 事件,并可将 `tool_call / tool_result / status / confirm_request / schedule_completed` 转成前端时间线展示。
|
||||||
10. 消息区实现了“自动跟随到底部 / 用户手动上滚后停止跟随”的双态滚动策略。
|
7. 对话页已经具备确认覆盖层:收到确认事件后,前端可以在卡片上直接确认、拒绝或补充说明,再把 `confirm_action / resume` 透传回后端。
|
||||||
|
8. 当后端发出 `schedule_completed` 信号后,消息流中会展示排程结果卡片;点击后可拉取结构化预览,并在 `ScheduleFineTuneModal` 中继续拖拽微调。
|
||||||
|
9. 微调弹窗已经支持两种收口方式:先“暂存到 Redis 状态”,或按任务类分桶后正式应用到数据库。
|
||||||
|
10. 对话页已具备上下文窗口计量器,可展示 `msg0 ~ msg3 / total / budget` 统计,用于观察当前对话窗口消耗。
|
||||||
|
11. 历史消息与本地乐观态消息会合并,避免刷新历史时把当前页内正在看的流式消息直接抹掉。
|
||||||
|
12. 助手消息底部支持复制、重新生成、版本分页切换;用户消息支持复制与“改写后重新发送”,但不会覆盖旧消息。
|
||||||
|
13. 消息区实现了“自动跟随到底部 / 用户手动上滚后停止跟随”的双态滚动策略。
|
||||||
|
|
||||||
## 6.6 日程编排页 `/schedule`
|
## 6.6 日程编排页 `/schedule`
|
||||||
|
|
||||||
@@ -719,12 +810,11 @@ frontend/src
|
|||||||
|
|
||||||
当前已实现能力:
|
当前已实现能力:
|
||||||
|
|
||||||
1. 左侧为任务类侧栏,右侧为周课表/排程画板。
|
1. `/schedule` 仍然是一套独立于 `newAgent` 对话页的传统编排中心,主要承接任务类管理、粗排预览与正式应用。
|
||||||
2. 任务类侧栏支持获取任务类列表、展开任务类详情、删除单个任务块、新建任务类弹窗、单选与批量多选模式切换。
|
2. 左侧为任务类侧栏,右侧为周课表/排程画板,支持单选与批量多选两种任务类操作模式。
|
||||||
3. 当任务类较多时,左侧侧栏改为固定卡片高度 + 列表滚动,不再通过压缩卡片高度硬塞。
|
3. 任务类侧栏支持获取任务类列表、展开详情、删除任务块、新建任务类弹窗,以及长列表滚动。
|
||||||
4. 单个任务类展开后,其内部任务块列表也支持独立滚动,避免详情直接溢出容器。
|
4. 周课表支持周次切换,当前前端将请求范围限制在 `1 ~ 24` 周,并且加入请求序列号保护,快速切周时只认最后一次响应。
|
||||||
5. 周课表支持周次切换,当前前端限制为 `1 ~ 24` 周,不允许继续越界请求。
|
5. 已接通的课表/编排相关接口包括:
|
||||||
6. 已接通的课表/编排相关接口包括:
|
|
||||||
- `GET /api/v1/schedule/week`
|
- `GET /api/v1/schedule/week`
|
||||||
- `GET /api/v1/task-class/list`
|
- `GET /api/v1/task-class/list`
|
||||||
- `GET /api/v1/task-class/get`
|
- `GET /api/v1/task-class/get`
|
||||||
@@ -734,41 +824,163 @@ frontend/src
|
|||||||
- `POST /api/v1/schedule/smart-planning-multi`
|
- `POST /api/v1/schedule/smart-planning-multi`
|
||||||
- `PUT /api/v1/task-class/apply-batch-into-schedule`
|
- `PUT /api/v1/task-class/apply-batch-into-schedule`
|
||||||
- `DELETE /api/v1/schedule/delete`
|
- `DELETE /api/v1/schedule/delete`
|
||||||
7. 智能编排结果当前分为单任务类粗排和多任务类批量粗排。
|
6. 智能编排结果当前分为单任务类粗排和多任务类批量粗排,结果先进入前端运行时预览态,而不会立即写入正式课表。
|
||||||
8. 预览态结果不会立刻写入正式课表,而是先保存在前端运行时内存中;用户确认后再应用到后端。
|
7. 预览态结果只保存在单页应用运行时内存中,不落 `localStorage / sessionStorage`;刷新页面会丢失,因此页面挂了 `beforeunload` 原生拦截提示。
|
||||||
9. 预览态结果的生命周期是“当前单页应用存活期间”,刷新页面会丢失,因此页面已挂载 `beforeunload` 原生拦截提示。
|
8. 已请求过的周课表会缓存在前端内存中,切周时优先复用缓存,避免反复回源后端。
|
||||||
10. 已请求过的周课表会缓存在前端内存中,当前页切换周次时优先复用缓存,避免反复打后端。
|
9. 预览态 `suggested` 任务支持拖拽调整位置,并会同步修改前端持有的预览 JSON,保证“用户看到的布局”和“最终提交给后端的布局”一致。
|
||||||
11. 周请求增加了序列号保护,快速切周时只认最后一次请求结果,用来降低闪动。
|
10. 对于可嵌入课程的预览任务,只有拖到允许嵌入的课程块上时才允许嵌入,不会把整块课程卡一起误拖走。
|
||||||
12. 预览态 `suggested` 任务支持拖拽调整位置,并且拖拽会同步修改前端持有的预览 JSON,保证“用户看到的布局”和“最终提交给后端的布局”一致。
|
11. 多任务类粗排的正式应用采用“先按任务类分桶,再逐桶调用现有应用接口”的兼容策略,避免一次性重写整个后端应用协议。
|
||||||
13. 对于嵌入课程中的预览任务,只有拖到嵌入任务本身时才允许把它单独拖出来,不会整块课程卡一起被拖动。
|
|
||||||
14. 预览态支持批量应用;如果是多任务类批量粗排,前端会先把预览结果按任务类分桶,再逐桶调用现有应用接口。
|
|
||||||
|
|
||||||
## 6.7 当前前后端衔接边界
|
## 6.7 当前前后端衔接边界
|
||||||
|
|
||||||
当前前端已经覆盖的主业务链路:
|
当前前端已经覆盖的主业务链路:
|
||||||
|
|
||||||
1. 登录 / 注册 / 自动续签
|
1. 登录 / 注册 / 自动续签 / 安全登出。
|
||||||
2. 首页任务获取、创建、完成、撤销
|
2. 首页四象限任务获取、创建、完成、撤销与今日日程展示。
|
||||||
3. 今日日程展示
|
3. `newAgent` 对话、历史会话、思考内容展示、消息重试、结构化时间线、确认覆盖层、上下文窗口计量。
|
||||||
4. AI 对话、历史会话、深度思考展示、重新生成、消息复制、消息修改
|
4. AI 对话页中的排程结果卡片、结构化预览拉取、弹窗微调、暂存到 Redis 状态、正式应用到课表。
|
||||||
5. 任务类管理、智能粗排、批量粗排、预览拖拽、正式应用、删除日程
|
5. 传统 `/schedule` 页面中的任务类管理、智能粗排、批量粗排、拖拽预览、正式应用、删除日程。
|
||||||
|
6. `/prototype/tool-trace` 原型页,用于展示工具 trace 与交互形态。
|
||||||
|
|
||||||
当前仍明确留给后续迭代的部分:
|
当前仍明确留给后续迭代的部分:
|
||||||
|
|
||||||
1. “任务”独立页面与“设置”独立页面尚未接出。
|
1. “任务”独立页面与“设置”独立页面尚未接出。
|
||||||
2. 课表导入流程入口已在首页预留,但还没有完整的导入页与导入向导。
|
2. AI 输入区已经预留附件上传按钮,但上传、解析、落盘、发送给模型的完整链路尚未接通。
|
||||||
3. 用户消息“修改后原地提交并替换旧消息”的真正后端语义尚未实现,目前按“发送一条新消息”处理。
|
3. `/assistant` 与 `/schedule` 目前还是两套并行入口,状态模型与交互语义尚未完全统一。
|
||||||
4. 更多 AI 工具态页面(例如结构化预览页、设置面板、统计页)尚未独立拆页。
|
4. 课表导入流程入口虽然已预留,但还没有完整的导入页与导入向导。
|
||||||
|
5. 用户消息“修改后原地替换旧消息”的真正后端语义尚未实现,目前仍按“复制到输入框后再发送一条新消息”处理。
|
||||||
|
6. 原型页、预留目录和历史壳层代码还未进一步收敛清理,说明前端仍处在快速迭代期。
|
||||||
|
|
||||||
# 7 部署与监控
|
# 7 部署与监控
|
||||||
|
|
||||||
## 7.1 容器化部署方案
|
## 7.1 容器化部署方案
|
||||||
|
当前项目已经具备一套**“依赖栈容器化 + 应用进程宿主机运行”**的落地方案,适合本地联调、答辩演示和单机部署。
|
||||||
|
|
||||||
|
### 当前部署形态
|
||||||
|
|
||||||
|
当前根目录已经提供 `docker-compose.yml`,用于启动以下基础设施:
|
||||||
|
|
||||||
|
| 服务 | 端口 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| MySQL 8.0 | `3306` | 主业务库,承载用户、任务、课表、对话、memory 等结构化数据 |
|
||||||
|
| Redis 7 | `6379` | 登录态、幂等键、会话缓存、Agent 快照、预览状态等 |
|
||||||
|
| Kafka 3.7 | `9092` | Outbox 事件总线,承接聊天持久化、token 统计、memory 抽取等异步事件 |
|
||||||
|
| etcd | `2379` | Milvus 元数据存储 |
|
||||||
|
| MinIO | `9000` / `9001` | Milvus 对象存储依赖 |
|
||||||
|
| Milvus Standalone | `19530` / `9091` | RAG / memory 向量检索引擎 |
|
||||||
|
| Attu | `8000` | Milvus 可视化管理台 |
|
||||||
|
| kafka-init | 无外部端口 | 启动时自动创建 `smartflow.agent.outbox` topic |
|
||||||
|
|
||||||
|
其中,`docker-compose.yml` 已经为 MySQL、Redis、Kafka、etcd、MinIO、Milvus 配好了 `healthcheck`,并通过 `depends_on.condition: service_healthy` 保证依赖按健康状态顺序启动。
|
||||||
|
|
||||||
|
### 推荐启动顺序
|
||||||
|
|
||||||
|
1. 先启动依赖栈:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 准备后端配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
然后按实际环境修改以下配置:
|
||||||
|
|
||||||
|
1. `database`:MySQL 地址、用户名、密码、库名。
|
||||||
|
2. `redis`:Redis 地址、密码。
|
||||||
|
3. `kafka`:Broker 地址与 topic / groupID。
|
||||||
|
4. `jwt`:`accessSecret` 与 `refreshSecret`。
|
||||||
|
5. `agent`:模型名、`baseURL`、推理开关。
|
||||||
|
6. `rag` / `memory`:Milvus 地址、embedding 配置、memory 模式。
|
||||||
|
7. `websearch`:联网搜索 provider 与 API Key。
|
||||||
|
|
||||||
|
3. 启动后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
后端默认监听 `8080`,并提供健康检查接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 启动前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端默认运行在 `5173`,并通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8080`。
|
||||||
|
|
||||||
|
### 当前方案的边界
|
||||||
|
|
||||||
|
1. **已容器化的部分**:MySQL、Redis、Kafka、Milvus 及其依赖。
|
||||||
|
2. **未容器化的部分**:Go 后端进程与 Vue 前端进程当前仍以宿主机方式运行,仓库中尚未提供后端/前端 Dockerfile。
|
||||||
|
3. **因此更准确的表述**:当前项目已经完成“基础设施容器化”,但还没有做到“整站一键镜像化部署”。
|
||||||
|
4. **如果后续继续工程化**:优先补 `backend/Dockerfile`、`frontend/Dockerfile` 与生产态反向代理配置,再把前后端服务一并纳入 compose 编排。
|
||||||
|
|
||||||
## 7.2 性能监控&统计
|
## 7.2 性能监控&统计
|
||||||
|
当前项目已经接入了一套**轻量级、以日志和内存计数器为主的观测方案**,但还没有完整接入 Prometheus / Grafana 这类统一监控平台。
|
||||||
|
|
||||||
|
### 当前已具备的监控能力
|
||||||
|
|
||||||
|
1. **基础健康检查**:
|
||||||
|
- 后端提供 `GET /api/v1/health`。
|
||||||
|
- Docker Compose 为 MySQL、Redis、Kafka、etcd、MinIO、Milvus 提供了容器级健康检查。
|
||||||
|
2. **应用日志观测**:
|
||||||
|
- Gin 默认开启 `Logger + Recovery`,可覆盖 HTTP 请求与 panic 恢复。
|
||||||
|
- Outbox / Kafka、RAG、memory、newAgent 执行链路都已在关键节点输出结构化或半结构化日志。
|
||||||
|
3. **RAG 运行观测**:
|
||||||
|
- `backend/cmd/start.go` 中已为 RAG runtime 注入 `LoggerObserver`。
|
||||||
|
- 当前可观测向量检索、召回、Milvus 建表/查表、fallback 等运行事件。
|
||||||
|
4. **Memory 模块计数器**:
|
||||||
|
- memory 模块已经实现 `MetricsRegistry` 这类轻量内存计数器。
|
||||||
|
- 当前已覆盖的统计项包括:
|
||||||
|
- `memory_job_total`
|
||||||
|
- `memory_job_retry_total`
|
||||||
|
- `memory_decision_total`
|
||||||
|
- `memory_decision_fallback_total`
|
||||||
|
- `memory_retrieve_hit_total`
|
||||||
|
- `memory_retrieve_dedup_drop_total`
|
||||||
|
- `memory_inject_item_total`
|
||||||
|
- `memory_rag_fallback_total`
|
||||||
|
- `memory_manage_total`
|
||||||
|
- `memory_cleanup_run_total`
|
||||||
|
- `memory_cleanup_archived_total`
|
||||||
|
5. **Agent 链路观测**:
|
||||||
|
- `newAgent` 主链路会记录 chat / plan / execute / rough_build / order_guard / deliver 等阶段日志。
|
||||||
|
- 对话时间线、确认事件、排程结果卡片、schedule preview 状态,也都能通过业务接口和 Redis 快照回看。
|
||||||
|
6. **向量库侧辅助观测**:
|
||||||
|
- Milvus 自带 `9091` 健康/指标端口。
|
||||||
|
- Attu 已在 compose 中接入,可用于观察 collection、索引与向量数据状态。
|
||||||
|
|
||||||
|
### 当前更适合关注的关键指标
|
||||||
|
|
||||||
|
现阶段最有价值的观测重点,不是“大而全”的机器监控,而是以下业务指标:
|
||||||
|
|
||||||
|
1. **Agent 可用性**:`/api/v1/health` 是否可用,`/agent/chat` 首字延迟是否异常。
|
||||||
|
2. **事件总线稳定性**:Kafka topic 是否就绪,Outbox 是否持续消费,是否出现重试堆积。
|
||||||
|
3. **memory 质量**:命中率、fallback 次数、decision fallback 次数、job retry 次数。
|
||||||
|
4. **RAG 检索质量**:Milvus 查询是否超时、是否频繁降级到 MySQL、topK/threshold 是否合理。
|
||||||
|
5. **排程链路稳定性**:rough build 失败率、order guard 是否频繁触发、preview 是否能成功落盘。
|
||||||
|
|
||||||
|
### 当前仍未完成的部分
|
||||||
|
|
||||||
|
1. 还没有把 memory 计数器正式暴露成 Prometheus `/metrics` 接口。
|
||||||
|
2. 还没有统一的 Grafana 看板、告警规则与告警分级。
|
||||||
|
3. 还没有把 Gin、Kafka、RAG、memory、newAgent 的指标统一汇聚到同一套 tracing / metrics 平台。
|
||||||
|
4. 当前更偏“开发期可诊断”而不是“生产期可运营”的观测体系。
|
||||||
|
|
||||||
|
因此,现阶段 README 中更准确的结论是:**项目已经具备基础健康检查、关键日志观测和 memory/RAG 轻量指标能力,但尚未完成完整生产级监控平台建设。**
|
||||||
|
|
||||||
# 8 快速开始
|
# 8 快速开始
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const deliverSystemPrompt = `
|
const deliverSystemPrompt = `
|
||||||
你是 SmartMate 的交付器。
|
你是 SmartMate 的交付器。你的职责是基于原始计划和执行历史,生成一份简洁、诚实的任务完成总结。
|
||||||
你的职责是基于原始计划和执行历史,生成一份简洁、诚实的任务完成总结。
|
|
||||||
|
|
||||||
请遵守以下规则:
|
请遵守以下规则:
|
||||||
1. 只基于已有历史和计划状态生成总结,不要编造未执行的操作。
|
1. 只基于已有历史和计划状态生成总结,不要编造未执行的操作。
|
||||||
2. 如果所有步骤都已完成,请自然概括每一步的主要成果。
|
2. 如果所有步骤都已完成,请自然概括每一步的主要成果。
|
||||||
@@ -19,12 +17,10 @@ const deliverSystemPrompt = `
|
|||||||
4. 使用自然、友好的语气,不要机械罗列工具过程。
|
4. 使用自然、友好的语气,不要机械罗列工具过程。
|
||||||
5. 如果用户后续还需要继续操作,可以给出一句简短建议。
|
5. 如果用户后续还需要继续操作,可以给出一句简短建议。
|
||||||
6. 只输出总结文本,不要输出 JSON,也不要输出 markdown 标题。
|
6. 只输出总结文本,不要输出 JSON,也不要输出 markdown 标题。
|
||||||
|
|
||||||
你会看到:
|
你会看到:
|
||||||
- 原始计划步骤及完成进度
|
- 原始计划步骤及完成进度
|
||||||
- 最近真实对话
|
- 最近真实对话
|
||||||
- 当前流程的收口状态
|
- 当前流程的收口状态`
|
||||||
`
|
|
||||||
|
|
||||||
// BuildDeliverSystemPrompt 返回交付阶段系统提示词。
|
// BuildDeliverSystemPrompt 返回交付阶段系统提示词。
|
||||||
func BuildDeliverSystemPrompt() string {
|
func BuildDeliverSystemPrompt() string {
|
||||||
@@ -39,7 +35,7 @@ func BuildDeliverMessages(state *newagentmodel.CommonState, ctx *newagentmodel.C
|
|||||||
StageMessagesConfig{
|
StageMessagesConfig{
|
||||||
SystemPrompt: BuildDeliverSystemPrompt(),
|
SystemPrompt: BuildDeliverSystemPrompt(),
|
||||||
Msg1Content: buildDeliverConversationMessage(ctx),
|
Msg1Content: buildDeliverConversationMessage(ctx),
|
||||||
Msg2Content: buildDeliverWorkspace(state),
|
Msg2Content: buildDeliverWorkspace(state, ctx),
|
||||||
Msg3Prefix: roughBuildPrefix,
|
Msg3Prefix: roughBuildPrefix,
|
||||||
Msg3Suffix: BuildDeliverUserPrompt(state, ctx),
|
Msg3Suffix: BuildDeliverUserPrompt(state, ctx),
|
||||||
Msg3Role: schema.User,
|
Msg3Role: schema.User,
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ import (
|
|||||||
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildDeliverConversationMessage 生成 deliver 节点看到的真实对话视图。
|
// buildDeliverConversationMessage 生成 deliver 节点看到的轻量历史提示。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 这里不再承载完整历史,也不再把旧轮次对话重新灌回 deliver;
|
||||||
|
// 2. 真正可供收口的本轮 execute 窗口放到 msg2,由工作区统一呈现;
|
||||||
|
// 3. 这里只给模型一个明确提示:历史已经折叠,请不要主动回顾旧轮次。
|
||||||
func buildDeliverConversationMessage(ctx *newagentmodel.ConversationContext) string {
|
func buildDeliverConversationMessage(ctx *newagentmodel.ConversationContext) string {
|
||||||
return buildConversationHistoryMessage(ctx, "执行对话记录")
|
return "历史视图:已折叠到交付工作区的本轮 execute 窗口,请仅依据 msg2 收口,不要回顾旧轮次。"
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDeliverRoughBuildPrefix 构造 deliver 在“粗排已完成”场景下的专属前缀。
|
// buildDeliverRoughBuildPrefix 构造 deliver 在“粗排已完成”场景下的专属前缀。
|
||||||
@@ -43,22 +48,24 @@ func buildDeliverRoughBuildPrefix(ctx *newagentmodel.ConversationContext, state
|
|||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDeliverWorkspace 渲染 deliver 节点自己的结果视图。
|
// buildDeliverWorkspace 渲染 deliver 节点自己的结果态工作区。
|
||||||
//
|
//
|
||||||
// 设计说明:
|
// 设计说明:
|
||||||
// 1. deliver 只需要结果态信息:计划简表、完成进度、收口状态;
|
// 1. 先保留 deliver 原本依赖的结果态信息:terminal outcome、计划进度、步骤简表;
|
||||||
// 2. 不再注入工具目录、任务类约束、ReAct 摘要等过程噪声;
|
// 2. 再把基于 execute_loop_closed 切出来的“本轮 execute 窗口”拼到 msg2,作为唯一的本轮事实视图;
|
||||||
// 3. 没有正式计划时,明确退回“只基于对话做总结”。
|
// 3. 没有正式计划时也保留 execute 窗口,保证 deliver 仍能基于当前轮活跃上下文诚实收口。
|
||||||
func buildDeliverWorkspace(state *newagentmodel.CommonState) string {
|
func buildDeliverWorkspace(state *newagentmodel.CommonState, ctx *newagentmodel.ConversationContext) string {
|
||||||
lines := []string{"交付工作区:"}
|
lines := []string{"交付工作区:"}
|
||||||
if state == nil {
|
if state == nil {
|
||||||
lines = append(lines, "- 当前缺少流程状态,请仅基于最近对话做诚实总结。")
|
lines = append(lines, "- 当前缺少流程状态,请仅基于可见结果态与本轮 execute 窗口诚实收口。")
|
||||||
|
lines = append(lines, "", buildDeliverExecuteWindow(ctx))
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
lines = append(lines, renderDeliverTerminalSummary(state))
|
lines = append(lines, renderDeliverTerminalSummary(state))
|
||||||
if !state.HasPlan() {
|
if !state.HasPlan() {
|
||||||
lines = append(lines, "- 当前没有正式计划,请只概括本次互动。")
|
lines = append(lines, "- 当前没有正式计划,请只概括本次互动。")
|
||||||
|
lines = append(lines, "", buildDeliverExecuteWindow(ctx))
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +74,7 @@ func buildDeliverWorkspace(state *newagentmodel.CommonState) string {
|
|||||||
lines = append(lines, fmt.Sprintf("- 计划进度:已完成 %d/%d 步。", completed, total))
|
lines = append(lines, fmt.Sprintf("- 计划进度:已完成 %d/%d 步。", completed, total))
|
||||||
lines = append(lines, "计划步骤:")
|
lines = append(lines, "计划步骤:")
|
||||||
lines = append(lines, renderDeliverStepOutline(state, completed))
|
lines = append(lines, renderDeliverStepOutline(state, completed))
|
||||||
|
lines = append(lines, "", buildDeliverExecuteWindow(ctx))
|
||||||
|
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
103
backend/newAgent/prompt/deliver_window.go
Normal file
103
backend/newAgent/prompt/deliver_window.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package newagentprompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deliverHistoryKindExecuteLoopClosed = "execute_loop_closed"
|
||||||
|
|
||||||
|
// sliceHistoryAfterLastExecuteLoopClosed 基于最后一个 execute_loop_closed 标记切出当前活跃窗口。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 先读取完整 history 快照,避免直接在 ConversationContext 原地切片,减少后续调用方误改底层数组的风险;
|
||||||
|
// 2. 从后往前找最后一个 execute_loop_closed,确保拿到的是“最近一次已正常收口”的边界;
|
||||||
|
// 3. 命中边界后只返回边界之后的消息,这样 deliver 看到的就是当前活跃轮次;
|
||||||
|
// 4. 若完全没有边界,说明会话尚未形成稳定闭环,此时退回全量 history,避免误丢当前活跃上下文。
|
||||||
|
func sliceHistoryAfterLastExecuteLoopClosed(ctx *newagentmodel.ConversationContext) []*schema.Message {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
history := ctx.HistorySnapshot()
|
||||||
|
if len(history) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cut := -1
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
if isDeliverExecuteLoopClosedMarker(history[i]) {
|
||||||
|
cut = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cut < 0 {
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
if cut+1 >= len(history) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return history[cut+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDeliverExecuteLoopClosedMarker 判断一条历史消息是否为 execute loop 正常收口边界。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 这里只识别 prompt 层真正关心的 execute_loop_closed 标记;
|
||||||
|
// 2. 不负责推断其他中断/恢复语义,避免把 confirm/ask_user 等同一轮过程误判成新边界;
|
||||||
|
// 3. 若消息结构不完整,则统一按“非边界”处理,保证切窗策略保守可回退。
|
||||||
|
func isDeliverExecuteLoopClosedMarker(msg *schema.Message) bool {
|
||||||
|
if msg == nil || msg.Extra == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
kind, ok := msg.Extra[executeHistoryKindKey].(string)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(kind) == deliverHistoryKindExecuteLoopClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDeliverExecuteWindow 基于当前活跃 history 窗口渲染 deliver 节点要看的执行事实视图。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 先按 execute_loop_closed 切掉旧轮次,只保留当前仍活跃的执行窗口;
|
||||||
|
// 2. 再分别抽取“本轮真实对话流”和“本轮 ReAct 工具事实链”,避免 deliver 回看旧 deliver 总结;
|
||||||
|
// 3. 若本轮还没有工具调用,也要明确告诉模型“当前无工具事实”,避免它擅自脑补;
|
||||||
|
// 4. 整段文本只服务 deliver.msg2,不改变四段式骨架,也不回写任何状态。
|
||||||
|
func buildDeliverExecuteWindow(ctx *newagentmodel.ConversationContext) string {
|
||||||
|
lines := []string{"本轮 execute 窗口:"}
|
||||||
|
|
||||||
|
historyWindow := sliceHistoryAfterLastExecuteLoopClosed(ctx)
|
||||||
|
if len(historyWindow) == 0 {
|
||||||
|
lines = append(lines, "- 当前没有可用的本轮执行窗口,请仅依据结果态工作区诚实收口。")
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
turns := collectExecuteConversationTurns(historyWindow)
|
||||||
|
if len(turns) == 0 {
|
||||||
|
lines = append(lines, "- 本轮对话流:暂无。")
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "本轮对话流:")
|
||||||
|
for _, turn := range turns {
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s: %q", turn.Role, turn.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loops := collectExecuteLoopRecords(historyWindow)
|
||||||
|
if len(loops) == 0 {
|
||||||
|
lines = append(lines, "- 本轮 ReAct 记录:暂无工具调用。")
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, "本轮 ReAct 记录:")
|
||||||
|
for i, loop := range loops {
|
||||||
|
lines = append(lines, fmt.Sprintf("%d. thought/reason:%s", i+1, loop.Thought))
|
||||||
|
lines = append(lines, fmt.Sprintf(" tool_call:%s", renderExecuteToolCallText(loop.ToolName, loop.ToolArgs)))
|
||||||
|
lines = append(lines, fmt.Sprintf(" observation:%s", loop.Observation))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
BIN
docs/pics/AI-工具调用中.png
Normal file
BIN
docs/pics/AI-工具调用中.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
BIN
docs/pics/AI-开启新对话.png
Normal file
BIN
docs/pics/AI-开启新对话.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
BIN
docs/pics/AI-日程微调页面.png
Normal file
BIN
docs/pics/AI-日程微调页面.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 515 KiB |
BIN
docs/pics/主页.png
Normal file
BIN
docs/pics/主页.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
BIN
docs/pics/登录页.png
Normal file
BIN
docs/pics/登录页.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
BIN
docs/pics/课程表页面.png
Normal file
BIN
docs/pics/课程表页面.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SmartFlow</title>
|
<title>SmartMate</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "smartflow-frontend",
|
"name": "smartmate-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ const showLayout = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="showLayout" class="smartflow-layout">
|
<div v-if="showLayout" class="smartmate-layout">
|
||||||
<MainSidebar />
|
<MainSidebar />
|
||||||
<div class="smartflow-content">
|
<div class="smartmate-content">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</router-view>
|
</router-view>
|
||||||
@@ -28,7 +28,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.smartflow-layout {
|
.smartmate-layout {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -41,7 +41,7 @@ body {
|
|||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.smartflow-content {
|
.smartmate-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export async function applyBatchIntoSchedule(
|
|||||||
items,
|
items,
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
'Idempotency-Key': idempotencyKey
|
'X-Idempotency-Key': idempotencyKey
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ async function handleSaveToState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 每个预览会话维持一个稳定的幂等键,避免重试或延迟导致的重复落库
|
||||||
|
const officialSaveIdempotencyKey = ref(crypto.randomUUID())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 正式保存到数据库 (MySQL)
|
* 正式保存到数据库 (MySQL)
|
||||||
*/
|
*/
|
||||||
@@ -122,13 +125,16 @@ async function handleOfficialSave() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const idempotencyKey = crypto.randomUUID()
|
|
||||||
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
|
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
|
||||||
applyBatchIntoSchedule(classId, groupItems, idempotencyKey)
|
applyBatchIntoSchedule(classId, groupItems, officialSaveIdempotencyKey.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
await Promise.all(promises)
|
await Promise.all(promises)
|
||||||
ElMessage.success('日程已正式保存到数据库')
|
ElMessage.success('日程已正式保存到数据库')
|
||||||
|
|
||||||
|
// 保存成功后刷新幂等键,虽然通常弹窗会关闭,但这是为了逻辑严密
|
||||||
|
officialSaveIdempotencyKey.value = crypto.randomUUID()
|
||||||
|
|
||||||
emit('saved')
|
emit('saved')
|
||||||
emit('close')
|
emit('close')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -41,30 +41,49 @@ const emit = defineEmits<{
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
|
margin: 12px 0;
|
||||||
margin: 8px 0;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
/* 弹出动画 */
|
||||||
|
animation: schedule-card-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes schedule-card-pop {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(10px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-result-card:hover {
|
.schedule-result-card:hover {
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
box-shadow: 0 10px 20px -5px rgba(59, 130, 246, 0.15);
|
background: #fcfdfe;
|
||||||
|
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-result-card__icon {
|
.schedule-result-card__icon {
|
||||||
width: 44px;
|
width: 48px;
|
||||||
height: 44px;
|
height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #eff6ff;
|
background: linear-gradient(135deg, #eff6ff 0%, #dbebff 100%);
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-result-card:hover .schedule-result-card__icon {
|
||||||
|
transform: rotate(-5deg) scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-result-card__content {
|
.schedule-result-card__content {
|
||||||
@@ -75,23 +94,33 @@ const emit = defineEmits<{
|
|||||||
.schedule-result-card__summary {
|
.schedule-result-card__summary {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 850;
|
||||||
color: #1e293b;
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-result-card__detail {
|
.schedule-result-card__detail {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-result-card__arrow {
|
.schedule-result-card__arrow {
|
||||||
color: #cbd5e1;
|
width: 32px;
|
||||||
transition: transform 0.3s;
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-result-card:hover .schedule-result-card__arrow {
|
.schedule-result-card:hover .schedule-result-card__arrow {
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
color: #3b82f6;
|
background: #3b82f6;
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -352,33 +352,57 @@ function formatDateLabel(value: string) {
|
|||||||
.assistant-planning__panel {
|
.assistant-planning__panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
/* 弹出动画 */
|
||||||
|
animation: planning-panel-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes planning-panel-pop {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__panel-header strong {
|
.assistant-planning__panel-header strong {
|
||||||
display: block;
|
display: block;
|
||||||
color: #1f2937;
|
color: #0f172a;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__panel-header p {
|
.assistant-planning__panel-header p {
|
||||||
margin: 6px 0 0;
|
margin: 4px 0 0;
|
||||||
color: #6b7280;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__loading,
|
.assistant-planning__loading,
|
||||||
.assistant-planning__list {
|
.assistant-planning__list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
max-height: 260px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 2px;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__loading-item {
|
.assistant-planning__loading-item {
|
||||||
height: 62px;
|
height: 60px;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(90deg, rgba(241, 245, 249, 0.9), rgba(226, 232, 240, 0.72), rgba(241, 245, 249, 0.9));
|
background: linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__item {
|
.assistant-planning__item {
|
||||||
@@ -387,66 +411,89 @@ function formatDateLabel(value: string) {
|
|||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
border: 1px solid rgba(15, 23, 42, 0.04);
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
background: #ffffff;
|
background: #f8fafc;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__item:hover {
|
.assistant-planning__item:hover {
|
||||||
border-color: rgba(57, 86, 178, 0.24);
|
border-color: rgba(59, 130, 246, 0.2);
|
||||||
background: #fafcff;
|
background: #ffffff;
|
||||||
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__item--selected {
|
.assistant-planning__item--selected {
|
||||||
border-color: rgba(57, 86, 178, 0.28);
|
border-color: #3b82f6;
|
||||||
background: #f5f8ff;
|
background: #ffffff;
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__item-check {
|
.assistant-planning__item-check {
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
border-radius: 999px;
|
border-radius: 6px;
|
||||||
border: 1.5px solid rgba(148, 163, 184, 0.8);
|
border: 2px solid #e2e8f0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__item-check--selected {
|
.assistant-planning__item-check--selected {
|
||||||
border-color: #3357c2;
|
border-color: #3b82f6;
|
||||||
background: radial-gradient(circle at center, #3357c2 0 45%, transparent 46%);
|
background: #3b82f6;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-planning__item-check--selected::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 6px;
|
||||||
|
height: 3px;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
transform: translate(-50%, -65%) rotate(-45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__item-body {
|
.assistant-planning__item-body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__item-body strong {
|
.assistant-planning__item-body strong {
|
||||||
color: #1f2430;
|
color: #1e293b;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__item-body small {
|
.assistant-planning__item-body small {
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__item-slots {
|
.assistant-planning__item-slots {
|
||||||
color: #475569;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__empty {
|
.assistant-planning__empty {
|
||||||
padding: 18px 16px;
|
padding: 30px 20px;
|
||||||
border-radius: 16px;
|
text-align: center;
|
||||||
|
border-radius: 12px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
color: #64748b;
|
color: #94a3b8;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__panel-actions {
|
.assistant-planning__panel-actions {
|
||||||
@@ -465,20 +512,31 @@ function formatDateLabel(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__panel-button--ghost {
|
.assistant-planning__panel-button--ghost {
|
||||||
background: #ffffff;
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-planning__panel-button--ghost:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-planning__panel-button--primary {
|
.assistant-planning__panel-button--primary {
|
||||||
border-color: transparent;
|
background: #3b82f6;
|
||||||
background: #3357c2;
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-planning__panel-button--primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 15px rgba(59, 130, 246, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.assistant-planning-popover) {
|
:global(.assistant-planning-popover) {
|
||||||
padding: 14px;
|
padding: 18px !important;
|
||||||
border-radius: 20px;
|
border-radius: 20px !important;
|
||||||
border: 1px solid rgba(203, 213, 225, 0.78);
|
border: 1px solid rgba(15, 23, 42, 0.08) !important;
|
||||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.14);
|
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.12) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface SidebarItem {
|
|||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
|
{ key: 'home', label: '总览', short: '总', to: '/dashboard' },
|
||||||
{ key: 'task', label: '任务', short: '任' },
|
|
||||||
{ key: 'calendar', label: '日程', short: '程', to: '/schedule' },
|
{ key: 'calendar', label: '日程', short: '程', to: '/schedule' },
|
||||||
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
|
{ key: 'ai', label: '助手', short: 'AI', to: '/assistant' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({})
|
|||||||
const conversationListItemRevealMap = reactive<Record<string, boolean>>({})
|
const conversationListItemRevealMap = reactive<Record<string, boolean>>({})
|
||||||
const scheduleResultMap = reactive<Record<string, SchedulePreviewData>>({})
|
const scheduleResultMap = reactive<Record<string, SchedulePreviewData>>({})
|
||||||
const isFineTuneModalVisible = ref(false)
|
const isFineTuneModalVisible = ref(false)
|
||||||
|
const fineTuneLoading = ref(false)
|
||||||
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
|
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
|
||||||
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
@@ -1301,9 +1302,14 @@ function scheduleScrollMessagesToBottom(smooth = false, force = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSelectedConversationAfterListLoad() {
|
async function ensureSelectedConversationAfterListLoad() {
|
||||||
|
// 1. 根据用户最新要求:进入页面时不自动加载最后一次对话。
|
||||||
|
// 2. 默认保持 selectedConversationId 为空,从而触发居中的“新会话”看板及动画过渡逻辑。
|
||||||
|
// 3. 用户若需查看历史,可从左侧列表中手动点击。
|
||||||
|
/*
|
||||||
if (!selectedConversationId.value && conversationList.value.length > 0) {
|
if (!selectedConversationId.value && conversationList.value.length > 0) {
|
||||||
await selectConversation(conversationList.value[0].conversation_id)
|
await selectConversation(conversationList.value[0].conversation_id)
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConversationListData 负责按页读取会话列表,并驱动首屏选中与懒加载状态。
|
// loadConversationListData 负责按页读取会话列表,并驱动首屏选中与懒加载状态。
|
||||||
@@ -1601,16 +1607,16 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'schedule_completed':
|
case 'schedule_completed':
|
||||||
// 标记该消息需要排程卡片
|
// 1. 标记该消息需要排程卡片。
|
||||||
// 详情通过 schedule_completed 事件触发的 getSchedulePreview 异步填充
|
// 2. 改造点:不在此处立即进行 getSchedulePreview 的异步拉取,
|
||||||
void (async () => {
|
// 避免后端还未完成落库、或者并发过高导致的 'schedule plan preview not found' 404 捕获。
|
||||||
try {
|
// 3. 这里先存入占位标志,真正的拉取推迟到用户“点击卡片”时。
|
||||||
const preview = await getSchedulePreview(conversationId)
|
scheduleResultMap[mid] = {
|
||||||
scheduleResultMap[mid] = preview
|
summary: '智能编排方案已就绪',
|
||||||
} catch {
|
conversation_id: conversationId,
|
||||||
// 吞掉,可能是过期的预览
|
hybrid_entries: [],
|
||||||
}
|
is_placeholder: true, // 内部临时标记
|
||||||
})()
|
} as any
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1761,8 +1767,34 @@ function isManualThinkingEnabled(mode: ThinkingModeType) {
|
|||||||
return mode === 'true'
|
return mode === 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFineTuneModal(data: SchedulePreviewData) {
|
async function openFineTuneModal(data: SchedulePreviewData) {
|
||||||
activeFineTuneData.value = data
|
// 1. 如果点击的是占位卡片(尚未加载详情),则触发实时拉取。
|
||||||
|
if ((data as any).is_placeholder) {
|
||||||
|
if (fineTuneLoading.value) return
|
||||||
|
|
||||||
|
fineTuneLoading.value = true
|
||||||
|
try {
|
||||||
|
const realData = await getSchedulePreview(selectedConversationId.value)
|
||||||
|
// 成功后覆盖占位符,下次点击无需再查
|
||||||
|
activeFineTuneData.value = realData
|
||||||
|
|
||||||
|
// 这里的逻辑主要是为了同步界面上的 card 状态(如果是合并消息,对应的 id 为 dm.id)
|
||||||
|
for (const mid of Object.keys(scheduleResultMap)) {
|
||||||
|
if ((scheduleResultMap[mid] as any).is_placeholder) {
|
||||||
|
scheduleResultMap[mid] = realData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Lazy load schedule failed:', error)
|
||||||
|
ElMessage.warning('编排方案正在生成中,请稍候再试...')
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
fineTuneLoading.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeFineTuneData.value = data
|
||||||
|
}
|
||||||
|
|
||||||
isFineTuneModalVisible.value = true
|
isFineTuneModalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1965,6 +1997,9 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 结构化 extra 事件(如卡片、工具调用、状态变更)处理逻辑。
|
||||||
|
// 注意:为了避免请求爆炸,不再每个事件都刷新统计信息。仅在里程碑事件(如卡片生成)处精准刷新。
|
||||||
|
|
||||||
if (extra.kind === 'confirm_request') {
|
if (extra.kind === 'confirm_request') {
|
||||||
// 1. 记录“confirm 到来前是否已存在可见正文/思考”。
|
// 1. 记录“confirm 到来前是否已存在可见正文/思考”。
|
||||||
// 2. 若已有可见前缀,后续流结束时只隐藏 confirm 相关部分,不删除整条消息。
|
// 2. 若已有可见前缀,后续流结束时只隐藏 confirm 相关部分,不删除整条消息。
|
||||||
@@ -1995,6 +2030,9 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
|||||||
buildToolDetail(extra.tool),
|
buildToolDetail(extra.tool),
|
||||||
`${extra.tool.name || ''}`,
|
`${extra.tool.name || ''}`,
|
||||||
)
|
)
|
||||||
|
if (extra.tool.status === 'done') {
|
||||||
|
void loadConversationContextStats(selectedConversationId.value, true)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2022,16 +2060,18 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (extra.kind === 'schedule_completed') {
|
if (extra.kind === 'schedule_completed') {
|
||||||
// 异步拉取详细排程方案
|
// 1. 每当“排程卡片”这种重量级里程碑出现时,刷新统计信息,让用户感知到上下文变动。
|
||||||
void (async () => {
|
void loadConversationContextStats(selectedConversationId.value, true)
|
||||||
try {
|
|
||||||
const preview = await getSchedulePreview(selectedConversationId.value)
|
// 2. 收到编排完成事件,仅在前端打上占位标记,展示展示卡片。
|
||||||
scheduleResultMap[assistantMessage.id] = preview
|
// 不再并发执行异步 fetch,防止后端落库延迟导致的 NotFound。
|
||||||
scheduleScrollMessagesToBottom(true)
|
scheduleResultMap[assistantMessage.id] = {
|
||||||
} catch (error: any) {
|
summary: '智能编排方案已就绪',
|
||||||
ElMessage.error(error.message || '获取排程方案失败')
|
conversation_id: selectedConversationId.value,
|
||||||
}
|
hybrid_entries: [],
|
||||||
})()
|
is_placeholder: true,
|
||||||
|
} as any
|
||||||
|
scheduleScrollMessagesToBottom(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2069,6 +2109,8 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
|||||||
}
|
}
|
||||||
activeStreamingMessageId.value = ''
|
activeStreamingMessageId.value = ''
|
||||||
reasoningCollapsedMap[assistantMessage.id] = true
|
reasoningCollapsedMap[assistantMessage.id] = true
|
||||||
|
// 整个 SSE 流结束信号
|
||||||
|
void loadConversationContextStats(selectedConversationId.value, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2127,6 +2169,8 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
|||||||
}
|
}
|
||||||
activeStreamingMessageId.value = ''
|
activeStreamingMessageId.value = ''
|
||||||
reasoningCollapsedMap[assistantMessage.id] = true
|
reasoningCollapsedMap[assistantMessage.id] = true
|
||||||
|
// 单条消息结束标志
|
||||||
|
void loadConversationContextStats(selectedConversationId.value, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldSuppressVisibleDelta) {
|
if (!shouldSuppressVisibleDelta) {
|
||||||
@@ -2202,6 +2246,9 @@ async function streamAssistantReply(
|
|||||||
: '暂未收到回复正文,请稍后重试。'
|
: '暂未收到回复正文,请稍后重试。'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 传输彻底结束后,做最后一次上下文统计更新,确保最终的 Token 用量胶囊是准确的。
|
||||||
|
void loadConversationContextStats(actualConversationId, true)
|
||||||
|
|
||||||
if (shouldSyncCurrentConversationMeta) {
|
if (shouldSyncCurrentConversationMeta) {
|
||||||
await syncNewConversationTitleAfterFirstReply(actualConversationId)
|
await syncNewConversationTitleAfterFirstReply(actualConversationId)
|
||||||
}
|
}
|
||||||
@@ -2383,14 +2430,7 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="assistant-shell glass-panel" :class="{ 'assistant-shell--standalone': isStandaloneMode }">
|
<aside class="assistant-shell" :class="{ 'assistant-shell--standalone': isStandaloneMode }">
|
||||||
<header class="assistant-header dashboard-item-pop" :style="{ '--anim-delay': '0s' }">
|
|
||||||
<div class="assistant-header__text">
|
|
||||||
<span class="assistant-header__eyebrow">AI 对话</span>
|
|
||||||
<strong>{{ selectedConversationTitle }}</strong>
|
|
||||||
<p>{{ selectedConversationSubtitle }}</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref="assistantBodyRef"
|
ref="assistantBodyRef"
|
||||||
@@ -2491,7 +2531,12 @@ onBeforeUnmount(() => {
|
|||||||
<span class="assistant-splitter__line" />
|
<span class="assistant-splitter__line" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="assistant-chat dashboard-item-pop" :style="{ '--anim-delay': '0.1s' }">
|
<section
|
||||||
|
class="assistant-chat dashboard-item-pop"
|
||||||
|
:class="{ 'assistant-chat--empty': !selectedMessages.length && !chatLoading }"
|
||||||
|
:style="{ '--anim-delay': '0.1s' }"
|
||||||
|
>
|
||||||
|
<div class="assistant-chat__spacer-top" />
|
||||||
<div
|
<div
|
||||||
ref="messageViewportRef"
|
ref="messageViewportRef"
|
||||||
class="assistant-messages"
|
class="assistant-messages"
|
||||||
@@ -2502,14 +2547,7 @@ onBeforeUnmount(() => {
|
|||||||
当前会话的历史消息暂时不可读,但你仍然可以继续追问;后续刷新后会自动恢复。
|
当前会话的历史消息暂时不可读,但你仍然可以继续追问;后续刷新后会自动恢复。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="fade-switch" mode="out-in">
|
<TransitionGroup v-if="selectedMessages.length" tag="div" name="message-stagger" class="assistant-message-list">
|
||||||
<div v-if="!selectedMessages.length && !chatLoading" key="empty" class="assistant-empty">
|
|
||||||
<div class="assistant-empty__halo" />
|
|
||||||
<strong>从这里开始和 AI 协作</strong>
|
|
||||||
<p>右侧采用更接近 DeepSeek 的阅读式布局,只保留用户气泡,AI 回复直接按正文流展示。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TransitionGroup v-else tag="div" name="message-stagger" class="assistant-message-list" key="list">
|
|
||||||
<article
|
<article
|
||||||
v-for="dm in displayMessages"
|
v-for="dm in displayMessages"
|
||||||
:key="dm.id"
|
:key="dm.id"
|
||||||
@@ -2723,22 +2761,34 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="assistant-actions">
|
|
||||||
<button
|
|
||||||
v-for="action in quickActions"
|
|
||||||
:key="action"
|
|
||||||
type="button"
|
|
||||||
class="assistant-actions__chip"
|
|
||||||
:disabled="chatLoading"
|
|
||||||
@click="sendMessage(action)"
|
|
||||||
>
|
|
||||||
{{ action }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="assistant-chat__interaction-group">
|
||||||
|
<!-- Welcome Content (Only in empty state) -->
|
||||||
|
<Transition name="fade-switch">
|
||||||
|
<div v-if="!selectedMessages.length && !chatLoading" class="assistant-empty">
|
||||||
|
<div class="assistant-empty__halo" />
|
||||||
|
<div class="assistant-empty__content">
|
||||||
|
<strong>SmartMate AI 伙伴</strong>
|
||||||
|
<p>我是你的智能助理,你可以从直接输入任务开始。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Suggestion Chips -->
|
||||||
|
<div v-if="quickActions.length" class="assistant-actions">
|
||||||
|
<button
|
||||||
|
v-for="action in quickActions"
|
||||||
|
:key="action"
|
||||||
|
type="button"
|
||||||
|
class="assistant-actions__chip"
|
||||||
|
:disabled="chatLoading"
|
||||||
|
@click="sendMessage(action)"
|
||||||
|
>
|
||||||
|
{{ action }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="_9a2f8e4 assistant-composer-ds" :class="{ 'assistant-composer-ds--confirm': shouldShowDialogConfirmOverlay }">
|
<div class="_9a2f8e4 assistant-composer-ds" :class="{ 'assistant-composer-ds--confirm': shouldShowDialogConfirmOverlay }">
|
||||||
<div
|
<div
|
||||||
v-if="shouldShowDialogConfirmOverlay"
|
v-if="shouldShowDialogConfirmOverlay"
|
||||||
@@ -2805,6 +2855,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="aaff8b8f">
|
<div v-else class="aaff8b8f">
|
||||||
|
<div class="assistant-chat__spacer-bottom" />
|
||||||
<div class="_77cefa5 _9996a53">
|
<div class="_77cefa5 _9996a53">
|
||||||
<div class="_020ab5b">
|
<div class="_020ab5b">
|
||||||
<TaskClassPlanningPicker
|
<TaskClassPlanningPicker
|
||||||
@@ -2916,7 +2967,9 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="assistant-chat__spacer-bottom" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -2994,10 +3047,12 @@ onBeforeUnmount(() => {
|
|||||||
.assistant-shell {
|
.assistant-shell {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f8fafc;
|
background: transparent;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
font-family: 'Inter', 'Segoe UI', Roboto, -apple-system, sans-serif;
|
font-family: 'Inter', 'Segoe UI', Roboto, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3104,11 +3159,13 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.assistant-body {
|
.assistant-body {
|
||||||
--assistant-history-width: 228px;
|
--assistant-history-width: 228px;
|
||||||
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--assistant-history-width) 8px minmax(0, 1fr);
|
grid-template-columns: var(--assistant-history-width) auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: grid-template-columns 0.35s cubic-bezier(0.4, 0, 0.2, 1); /* 核心过渡动效 */
|
transition: grid-template-columns 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-body--collapsed {
|
.assistant-body--collapsed {
|
||||||
@@ -3116,7 +3173,8 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-body--standalone {
|
.assistant-body--standalone {
|
||||||
grid-template-columns: var(--assistant-history-width) 8px minmax(0, 1fr);
|
grid-template-columns: var(--assistant-history-width) auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-body--standalone.assistant-body--collapsed {
|
.assistant-body--standalone.assistant-body--collapsed {
|
||||||
@@ -3128,10 +3186,12 @@ onBeforeUnmount(() => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
background: #f8fafc;
|
background: #ffffff;
|
||||||
border-right: 1px solid #f1f5f9;
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.05);
|
||||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-history__toolbar {
|
.assistant-history__toolbar {
|
||||||
@@ -3435,6 +3495,9 @@ onBeforeUnmount(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
width: 8px;
|
||||||
|
margin: 0 -4px;
|
||||||
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-splitter--hidden {
|
.assistant-splitter--hidden {
|
||||||
@@ -3460,8 +3523,46 @@ onBeforeUnmount(() => {
|
|||||||
.assistant-chat {
|
.assistant-chat {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: minmax(0, 1fr) auto auto auto;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.05);
|
||||||
|
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.02);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-chat__spacer-top {
|
||||||
|
flex: 0;
|
||||||
|
transition: flex 0.75s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-chat__spacer-bottom {
|
||||||
|
flex: 0;
|
||||||
|
transition: flex 0.75s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-chat--empty .assistant-chat__spacer-top {
|
||||||
|
flex: 1.2; /* 稍微偏上一点,视觉重心更舒适 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-chat--empty .assistant-chat__spacer-bottom {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-messages {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* 隐藏滚动条,保持纯净感,仅在非空时显示 */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-chat--empty .assistant-messages {
|
||||||
|
flex: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-shell--standalone .assistant-chat {
|
.assistant-shell--standalone .assistant-chat {
|
||||||
@@ -3742,32 +3843,46 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-empty {
|
.assistant-empty {
|
||||||
min-height: 260px;
|
width: 100%;
|
||||||
display: grid;
|
display: flex;
|
||||||
place-items: center;
|
flex-direction: column;
|
||||||
align-content: center;
|
align-items: center;
|
||||||
justify-items: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 10px;
|
padding-bottom: 24px;
|
||||||
color: #68778e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-empty strong {
|
.assistant-empty__content {
|
||||||
color: #162334;
|
position: relative;
|
||||||
}
|
z-index: 10;
|
||||||
|
|
||||||
.assistant-empty p {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 420px;
|
|
||||||
line-height: 1.75;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-empty__halo {
|
.assistant-empty__halo {
|
||||||
width: 74px;
|
position: absolute;
|
||||||
height: 74px;
|
top: 50%;
|
||||||
border-radius: 26px;
|
left: 50%;
|
||||||
background: radial-gradient(circle at center, rgba(37, 99, 235, 0.18), rgba(37, 99, 235, 0.02));
|
transform: translate(-50%, -50%);
|
||||||
animation: halo-breathe 2.4s ease-in-out infinite;
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: radial-gradient(circle, rgba(59, 130, 246, 0.08) 0%, transparent 70%);
|
||||||
|
filter: blur(40px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-empty strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 850;
|
||||||
|
color: #0f172a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-empty p {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 15px;
|
||||||
|
max-width: 320px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message__user-row {
|
.chat-message__user-row {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { defineStore } from 'pinia'
|
|||||||
import type { LoginPayload, RegisterPayload, TokenPair } from '@/types/api'
|
import type { LoginPayload, RegisterPayload, TokenPair } from '@/types/api'
|
||||||
import { login as loginApi, logout as logoutApi, register as registerApi } from '@/api/auth'
|
import { login as loginApi, logout as logoutApi, register as registerApi } from '@/api/auth'
|
||||||
|
|
||||||
const ACCESS_TOKEN_KEY = 'smartflow_access_token'
|
const ACCESS_TOKEN_KEY = 'smartmate_access_token'
|
||||||
const REFRESH_TOKEN_KEY = 'smartflow_refresh_token'
|
const REFRESH_TOKEN_KEY = 'smartmate_refresh_token'
|
||||||
const LAST_USERNAME_KEY = 'smartflow_last_username'
|
const LAST_USERNAME_KEY = 'smartmate_last_username'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const accessToken = ref(localStorage.getItem(ACCESS_TOKEN_KEY) ?? '')
|
const accessToken = ref(localStorage.getItem(ACCESS_TOKEN_KEY) ?? '')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只负责生成前端唯一键,不负责持久化或重试策略。
|
// 1. 只负责生成前端唯一键,不负责持久化或重试策略。
|
||||||
// 2. 优先使用浏览器原生 randomUUID,缺失时退回时间戳方案。
|
// 2. 优先使用浏览器原生 randomUUID,缺失时退回时间戳方案。
|
||||||
export function createIdempotencyKey(prefix = 'smartflow') {
|
export function createIdempotencyKey(prefix = 'smartmate') {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return `${prefix}-${crypto.randomUUID()}`
|
return `${prefix}-${crypto.randomUUID()}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ import AssistantPanel from '@/components/dashboard/AssistantPanel.vue'
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 24px;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -96,40 +96,57 @@ async function submitRegister() {
|
|||||||
<main class="auth-page">
|
<main class="auth-page">
|
||||||
<div class="page-shell auth-layout">
|
<div class="page-shell auth-layout">
|
||||||
<section class="auth-brand glass-panel">
|
<section class="auth-brand glass-panel">
|
||||||
<div class="auth-brand__badge">SmartFlow</div>
|
<div class="auth-brand__badge">SmartMate</div>
|
||||||
<h1>把任务、课程与智能规划放在同一个工作台里。</h1>
|
<h1>您的全能智能排程伙伴。</h1>
|
||||||
<p>
|
<p>
|
||||||
这一版先把登录链路跑通。后面我们会在这个基础上继续接任务管理、课表总览和智能体排程能力。
|
SmartMate 将碎片化的任务管理、精准的课表同步与大模型驱动的智能规划深度融合,助您掌控每一刻,实现效率飞跃。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="auth-brand__points">
|
<div class="auth-brand__points">
|
||||||
<article>
|
<article>
|
||||||
<strong>扁平化界面</strong>
|
<strong>智能代办规划</strong>
|
||||||
<span>去掉多余装饰,把信息层级讲清楚。</span>
|
<span>AI 助手自动分析任务优先级,平衡学业与生活,为您量身定制平衡的每日日程。</span>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<strong>登录态托管</strong>
|
<strong>多源数据融合</strong>
|
||||||
<span>统一管理 access token,后续接业务页面更轻松。</span>
|
<span>无缝对接教务系统课表与个人待办清单,打破信息孤岛,实现真正的一站式时间管理。</span>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<strong>可持续扩展</strong>
|
<strong>极简设计哲学</strong>
|
||||||
<span>路由、状态、接口层已经拆开,后面直接加页面即可。</span>
|
<span>采用现代建筑感扁平化设计,通过灵动的动效与极简层级,提升您的生产力与视觉愉悦感。</span>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="auth-card glass-panel">
|
<section class="auth-card glass-panel">
|
||||||
<div class="auth-card__header">
|
<div class="auth-card__header">
|
||||||
<div>
|
<span class="auth-card__eyebrow">SmartMate 智能日程</span>
|
||||||
<span class="auth-card__eyebrow">欢迎使用</span>
|
<h2>欢迎回来</h2>
|
||||||
<h2>账号入口</h2>
|
<p>请登录以同步您的学习与生活编排。</p>
|
||||||
</div>
|
|
||||||
<p>先登录,再进入示例工作台。</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-tabs v-model="activePanel" stretch class="auth-tabs">
|
<div class="auth-toggle">
|
||||||
<el-tab-pane label="登录" name="login">
|
<button
|
||||||
<el-form label-position="top" class="auth-form" @submit.prevent="submitLogin">
|
type="button"
|
||||||
|
:class="['auth-toggle__btn', { active: activePanel === 'login' }]"
|
||||||
|
@click="activePanel = 'login'"
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['auth-toggle__btn', { active: activePanel === 'register' }]"
|
||||||
|
@click="activePanel = 'register'"
|
||||||
|
>
|
||||||
|
注 册
|
||||||
|
</button>
|
||||||
|
<div class="auth-toggle__slider" :style="{ transform: `translateX(${activePanel === 'login' ? '0' : '100%'})` }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-form-container">
|
||||||
|
<Transition name="auth-fade" mode="out-in">
|
||||||
|
<div v-if="activePanel === 'login'" key="login">
|
||||||
|
<el-form label-position="top" class="auth-form" @submit.prevent="submitLogin">
|
||||||
<el-form-item label="用户名">
|
<el-form-item label="用户名">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="loginForm.username"
|
v-model="loginForm.username"
|
||||||
@@ -156,13 +173,13 @@ async function submitRegister() {
|
|||||||
:loading="loginLoading"
|
:loading="loginLoading"
|
||||||
@click="submitLogin"
|
@click="submitLogin"
|
||||||
>
|
>
|
||||||
登录并进入示例页
|
登 录
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-tab-pane>
|
</div>
|
||||||
|
|
||||||
<el-tab-pane label="注册" name="register">
|
<div v-else key="register">
|
||||||
<el-form label-position="top" class="auth-form" @submit.prevent="submitRegister">
|
<el-form label-position="top" class="auth-form" @submit.prevent="submitRegister">
|
||||||
<el-form-item label="用户名">
|
<el-form-item label="用户名">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="registerForm.username"
|
v-model="registerForm.username"
|
||||||
@@ -211,8 +228,9 @@ async function submitRegister() {
|
|||||||
创建账号
|
创建账号
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-tab-pane>
|
</div>
|
||||||
</el-tabs>
|
</Transition>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -223,149 +241,267 @@ async function submitRegister() {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 32px 0;
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.05) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 100% 0%, rgba(96, 165, 250, 0.08) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 100% 100%, rgba(37, 99, 235, 0.05) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.08) 0px, transparent 50%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -10%;
|
||||||
|
left: -10%;
|
||||||
|
width: 120%;
|
||||||
|
height: 120%;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.02;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-layout {
|
.auth-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.1fr) minmax(380px, 460px);
|
grid-template-columns: 1fr 440px;
|
||||||
gap: 24px;
|
gap: 40px;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
}
|
max-width: 1100px;
|
||||||
|
width: 100%;
|
||||||
.auth-brand,
|
z-index: 10;
|
||||||
.auth-card {
|
|
||||||
border-radius: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-brand {
|
.auth-brand {
|
||||||
padding: 40px;
|
padding: 60px;
|
||||||
display: flex;
|
background: transparent;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
min-height: 680px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-brand__badge {
|
.auth-brand__badge {
|
||||||
width: fit-content;
|
display: inline-block;
|
||||||
padding: 8px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 999px;
|
background: #3b82f6;
|
||||||
background: #e8f2ff;
|
color: #ffffff;
|
||||||
color: #1f5fbf;
|
border-radius: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
margin-bottom: 32px;
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-brand h1 {
|
.auth-brand h1 {
|
||||||
margin: 24px 0 16px;
|
font-size: clamp(40px, 4vw, 52px);
|
||||||
max-width: 10em;
|
font-weight: 900;
|
||||||
font-size: clamp(36px, 5vw, 56px);
|
color: #0f172a;
|
||||||
line-height: 1.08;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.03em;
|
||||||
color: var(--text-main);
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-brand p {
|
.auth-brand p {
|
||||||
margin: 0;
|
font-size: 17px;
|
||||||
max-width: 38rem;
|
color: #64748b;
|
||||||
color: var(--text-secondary);
|
line-height: 1.6;
|
||||||
font-size: 16px;
|
margin-bottom: 48px;
|
||||||
|
max-width: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-brand__points {
|
.auth-brand__points {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 20px;
|
||||||
margin-top: 36px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-brand__points article {
|
.auth-brand__points article {
|
||||||
padding: 18px 20px;
|
display: flex;
|
||||||
border-radius: 20px;
|
flex-direction: column;
|
||||||
background: rgba(255, 255, 255, 0.72);
|
gap: 4px;
|
||||||
border: 1px solid rgba(17, 24, 39, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-brand__points strong {
|
.auth-brand__points strong {
|
||||||
display: block;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-bottom: 6px;
|
font-weight: 800;
|
||||||
color: var(--text-main);
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-brand__points span {
|
.auth-brand__points span {
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
padding: 30px 30px 24px;
|
background: rgba(255, 255, 255, 0.8);
|
||||||
min-height: 680px;
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 32px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.05),
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card__header {
|
.auth-card__header {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card__header h2 {
|
.auth-card__header h2 {
|
||||||
margin: 8px 0 8px;
|
font-size: 28px;
|
||||||
font-size: 30px;
|
font-weight: 850;
|
||||||
line-height: 1.15;
|
color: #0f172a;
|
||||||
letter-spacing: -0.03em;
|
margin: 6px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card__header p {
|
.auth-card__header p {
|
||||||
margin: 0;
|
color: #64748b;
|
||||||
color: var(--text-secondary);
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card__eyebrow {
|
.auth-card__eyebrow {
|
||||||
color: #1f5fbf;
|
font-size: 12px;
|
||||||
font-size: 13px;
|
font-weight: 800;
|
||||||
font-weight: 700;
|
color: #3b82f6;
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-tabs {
|
/* Custom Toggle Switch */
|
||||||
--el-color-primary: var(--brand);
|
.auth-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 4px;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle__btn {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle__btn.active {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-toggle__slider {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
width: calc(50% - 4px);
|
||||||
|
height: calc(100% - 8px);
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 11px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-form {
|
.auth-form {
|
||||||
margin-top: 16px;
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.auth-form .el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.auth-form .el-form-item__label) {
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 750 !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
margin-bottom: 6px !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.auth-form .el-input__wrapper) {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 0 0 1px #e2e8f0 inset !important;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.auth-form .el-input__wrapper.is-focus) {
|
||||||
|
background: #ffffff !important;
|
||||||
|
box-shadow: 0 0 0 2px #3b82f6 inset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-submit {
|
.auth-submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 10px;
|
height: 52px;
|
||||||
height: 48px;
|
margin-top: 12px;
|
||||||
border-radius: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%);
|
border-radius: 14px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 750;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
.auth-submit:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.auth-fade-enter-active,
|
||||||
|
.auth-fade-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-fade-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
.auth-layout {
|
.auth-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-brand,
|
.auth-brand {
|
||||||
.auth-card {
|
text-align: center;
|
||||||
min-height: auto;
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand h1 {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand p {
|
||||||
|
margin: 0 auto 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand__points {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 480px) {
|
||||||
.auth-page {
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-brand,
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
padding: 22px;
|
padding: 32px 24px;
|
||||||
border-radius: 22px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const quadrantMeta: Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageTitleDate = computed(() => formatHeaderDate(new Date()))
|
const pageTitleDate = computed(() => formatHeaderDate(new Date()))
|
||||||
const greetingName = computed(() => authStore.lastUsername || 'SmartFlow 用户')
|
const greetingName = computed(() => authStore.lastUsername || 'SmartMate 用户')
|
||||||
|
|
||||||
const groupedTasks = computed(() => {
|
const groupedTasks = computed(() => {
|
||||||
const groups: Record<number, TaskItem[]> = { 1: [], 2: [], 3: [], 4: [] }
|
const groups: Record<number, TaskItem[]> = { 1: [], 2: [], 3: [], 4: [] }
|
||||||
@@ -325,7 +325,8 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
width: calc(100% / var(--dashboard-main-scale));
|
width: calc(100% / var(--dashboard-main-scale));
|
||||||
height: calc(100% / var(--dashboard-main-scale));
|
height: calc(100% / var(--dashboard-main-scale));
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: auto 1fr;
|
||||||
|
align-content: start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
transform: scale(var(--dashboard-main-scale));
|
transform: scale(var(--dashboard-main-scale));
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
@@ -340,6 +341,8 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid rgba(15, 23, 42, 0.05);
|
border: 1px solid rgba(15, 23, 42, 0.05);
|
||||||
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.03);
|
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.03);
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 72px; /* 锁定高度,防止在布局缩放时发生形变 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar__brandline { display: flex; align-items: center; gap: 14px; }
|
.dashboard-topbar__brandline { display: flex; align-items: center; gap: 14px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user