Compare commits
2 Commits
9ac898acbb
...
7a144cea8a
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a144cea8a | |||
| 351af8870d |
@ -65,6 +65,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
||||
| `skills` | Skills loading, management, and prompt building | `SkillsLoader`, `Skill` |
|
||||
| `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` |
|
||||
| `scheduler` | Cron-based job scheduling, next-run computation | `Scheduler`, `Schedule`, `next_run_for_schedule()` |
|
||||
| `work` | Session-scoped active plan and concurrently executable checklist items | `WorkManager`, `TaskPlan`, `TaskItem` |
|
||||
| `observability` | Observer pattern for agent/tool telemetry events | `Observer` trait, `ObserverEvent`, `MultiObserver` |
|
||||
| `protocol` | WebSocket protocol message types | `WsInbound`, `WsOutbound`, `SessionSummary`, `HistoryMessage` |
|
||||
| `config` | Config loading, env substitution, path resolution | `Config`, `LLMProviderConfig` |
|
||||
@ -76,10 +77,12 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
||||
- **Channels** only send/receive messages via `MessageBus`; they know nothing about sessions or LLM
|
||||
- **MessageBus** owns bounded inbound/outbound/control queues; outbound routing and retries belong to `OutboundDispatcher`, not the queue
|
||||
- **SessionManager** owns session state, dialog operations, context construction, per-session work queues, and persistence coordination
|
||||
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
|
||||
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
|
||||
- **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result
|
||||
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
||||
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
|
||||
- **WebUI chat rendering** sanitizes Markdown before inserting HTML; session history must preserve structured tool-call metadata so calls and results remain independently collapsible
|
||||
- **Providers** are pure HTTP clients; no bus/session/channel awareness
|
||||
- **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
|
||||
|
||||
- 在终端里和本地 AI 助手持续对话。
|
||||
- 在浏览器中聊天,并查看日志、任务和记忆,修改运行配置与助手档案。
|
||||
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent;聊天页侧栏实时显示进度。
|
||||
- 将同一套 Agent 能力接入飞书/Lark。
|
||||
- 让 Agent 使用本地文件、Shell、搜索、HTTP、浏览器、MCP 工具完成任务。
|
||||
- 把长期偏好、事实和历史摘要存成可检索记忆。
|
||||
@ -102,8 +103,10 @@ http://127.0.0.1:19876/
|
||||
|
||||
WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供:
|
||||
|
||||
- 在线聊天、会话创建/切换、历史回放,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
|
||||
- 在线聊天、会话创建/切换、历史回放、Markdown 消息、可折叠工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
|
||||
- 可持久化的浅色/深色主题,首次访问时跟随系统偏好。
|
||||
- Cron 定时任务、最近运行记录和后台子任务状态。
|
||||
- 当前聊天 session 的可展开 Todo 侧栏;计划变化时自动展开,其他 session 的变化显示未读提示。
|
||||
- Knowledge/Timeline 记忆的分类与全文检索。
|
||||
- 本地滚动日志的尾部查看、过滤和自动刷新。
|
||||
- `config.json`、`~/.picobot/USER.md`、`~/.picobot/AGENTS.md` 编辑。
|
||||
@ -180,6 +183,7 @@ TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因
|
||||
| `tools` | Agent 可调用工具集合 |
|
||||
| `storage` | SQLite schema、CRUD、消息和任务持久化 |
|
||||
| `scheduler` | 领取定时任务,执行普通/巡检 Agent,并按投递策略记录或发送结果 |
|
||||
| `work` | 管理 session 级单 active plan、并行子项状态和 WebSocket 变更事件 |
|
||||
| `skills` | 加载 Skill,并把 Skill 指南注入系统提示 |
|
||||
| `mcp` | 连接 MCP Server,将远端工具包装成普通 Tool |
|
||||
| `task_supervisor` | 统一管理 Gateway 后台任务的取消和有界关停 |
|
||||
@ -217,6 +221,7 @@ Session ID 使用三段式:
|
||||
| `/dump` | 导出当前 dialog 为 Markdown |
|
||||
| `/mcp` | 查看 MCP 服务器和工具状态 |
|
||||
| `/stop` | 停止当前任务并清空队列 |
|
||||
| `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 |
|
||||
| `/?`, `/help` | 查看帮助 |
|
||||
|
||||
### 记忆
|
||||
@ -244,6 +249,7 @@ PicoBot 有两类记忆:
|
||||
| `get_skill` | 列出或读取本地 Skill |
|
||||
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
|
||||
| `delegate` | 启动 inline、background 或 parallel 子 Agent |
|
||||
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
|
||||
| `send_message` | 向指定渠道发送消息 |
|
||||
| `chat_manager` | 查看渠道、会话和历史消息 |
|
||||
| `cron_add/list/remove/enable/disable/update` | 管理定时任务 |
|
||||
@ -354,6 +360,7 @@ src/
|
||||
observability/ Agent/tool telemetry observer
|
||||
providers/ OpenAI 兼容和 Anthropic provider
|
||||
scheduler/ 定时任务运行时
|
||||
work/ Session 级任务计划与并行子项状态机
|
||||
session/ 会话生命周期、dialog 命令、持久化集成
|
||||
skills/ Skill 加载和内置 Skill 安装
|
||||
storage/ SQLite schema 和 CRUD
|
||||
|
||||
@ -65,6 +65,7 @@ flowchart LR
|
||||
| `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
|
||||
| `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 |
|
||||
| `scheduler` | 领取到期任务、执行普通/巡检 Agent、应用投递策略、原子记录结果 | 复用聊天会话历史、直接感知 Channel |
|
||||
| `work` | session 级单 active plan、并行子项状态机、版本和变更事件 | 执行模型调用、持有 Channel/WebSocket |
|
||||
| `task_supervisor` | 后台任务注册、取消、限时回收 | 业务级重试和结果语义 |
|
||||
|
||||
## 4. 消息与控制数据流
|
||||
@ -148,7 +149,9 @@ Session ID 格式为:
|
||||
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
|
||||
6. 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
|
||||
|
||||
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline 和当前消息历史。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。
|
||||
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,在历史压缩之后追加,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。
|
||||
|
||||
每个 session 最多有一个 active plan,但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 不获得 `todo` 或 `delegate`。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
|
||||
|
||||
## 6. 持久化
|
||||
|
||||
@ -159,7 +162,7 @@ SessionManager 负责组装会话上下文:系统提示、Skills、召回的 K
|
||||
- 5 秒 busy timeout。
|
||||
- schema version 迁移。
|
||||
|
||||
持久化范围包括 sessions、messages、memories、scheduled jobs、job runs 和 background tasks。修改 schema 时应:
|
||||
持久化范围包括 sessions、messages、memories、task plans/items、scheduled jobs、job runs 和 background tasks。修改 schema 时应:
|
||||
|
||||
1. 更新集中式 schema/迁移逻辑。
|
||||
2. 保留已有数据库的升级路径。
|
||||
@ -194,7 +197,7 @@ WebSocket 每个客户端的 writer task 是连接局部任务,由连接 handl
|
||||
|
||||
### WebUI 与管理 API
|
||||
|
||||
Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。
|
||||
Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在本轮完成后刷新历史并将其渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。
|
||||
|
||||
同源 `/api/*` 管理接口只提供显式白名单能力:
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
||||
| `memory` | 长期记忆存储与检索 |
|
||||
| `mcp` | MCP(Model Context Protocol)工具集成 |
|
||||
| `task_supervisor` | Gateway 后台任务注册、取消、限时等待和强制回收 |
|
||||
| `work` | 每个 session 的单 active plan、并行子项状态、版本与变更事件 |
|
||||
|
||||
## 功能边界
|
||||
|
||||
@ -43,6 +44,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
||||
- Tools 接收原始参数,返回字符串结果
|
||||
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
|
||||
- 子 Agent 由 `delegate` 工具创建,复用 provider 配置和按需过滤后的工具集;后台任务结果通过 MessageBus 发回原会话
|
||||
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
|
||||
- WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
|
||||
- WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
||||
|
||||
@ -243,6 +245,10 @@ Gateway 初始化时读取 `config.mcp.servers`:
|
||||
|
||||
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册,受 `gateway.max_concurrent_background_tasks` 限制;Gateway 关停时先收到取消信号,再在总宽限期内清理。
|
||||
|
||||
## Session Todo 计划
|
||||
|
||||
每个 session 最多有一个 active plan;普通闲聊没有计划上下文。计划和子项分别持久化到 `task_plans`、`task_items`,历史压缩后仍从权威状态生成精简摘要。WebUI 聊天页通过 `session_plan` 和 `plan_updated` WebSocket 帧显示默认隐藏的侧栏,当前 session 变化时自动展开,其他 session 只标记未读。
|
||||
|
||||
---
|
||||
|
||||
## 出站投递与关停
|
||||
|
||||
@ -66,6 +66,12 @@ delegate 后台子任务表。`session_id` 不使用数据库外键,因为 ses
|
||||
| `finished_at` | INTEGER | 结束时间 |
|
||||
| `created_at` | INTEGER | 创建时间 |
|
||||
|
||||
## task_plans / task_items 表
|
||||
|
||||
`task_plans` 保存 session 级任务计划,通过部分唯一索引保证每个 session 最多一个 `status='active'` 的计划。`version` 在任何子项变化时递增,用于 WebSocket 快照排序和乐观并发检查。
|
||||
|
||||
`task_items` 以 `(plan_id, id)` 为复合主键,因此每个计划都可使用 `T1`、`T2` 等稳定显示 ID。子项状态为 `pending`、`in_progress`、`completed` 或 `blocked`;`executor_kind` 和 `execution_id` 将并行子 Agent 执行绑定到具体子项。只有 active plan 且 execution ID 匹配时,异步结果才能提交。
|
||||
|
||||
## memories 表
|
||||
|
||||
长期记忆存储。
|
||||
|
||||
@ -143,9 +143,14 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
|
||||
| `timeout_secs` | 否 | 超时秒数,默认 3600 |
|
||||
| `tasks` | parallel 必填 | 并行子任务数组 |
|
||||
| `task_id` | 查询/取消必填 | 后台任务 ID |
|
||||
| `plan_item_id` | 否 | 将 inline/background 子 Agent 绑定到当前计划子项;parallel 数组中的每项也可分别绑定 |
|
||||
|
||||
默认只读工具集:`file_read`、`file_search`、`content_search`、`web_fetch`、`http_request`、`calculator`。
|
||||
|
||||
## todo — Session 任务计划
|
||||
|
||||
仅用于明确的复杂、多轮或并行任务。`create` 创建当前 session 唯一的 active plan;`view` 查看;`append` 增加子项;`update` 修改子项状态;`close` 完成或取消计划。普通闲聊和单步操作不应创建计划。子 Agent 始终被过滤掉 `todo` 和 `delegate`,计划结构只由主 Agent 管理。
|
||||
|
||||
---
|
||||
|
||||
## browser — 浏览器自动化
|
||||
|
||||
@ -47,6 +47,8 @@ pub struct SubAgentConfig {
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
pub max_iterations: Option<usize>,
|
||||
pub timeout_secs: Option<u64>,
|
||||
pub plan_item_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@ -122,6 +124,7 @@ pub struct SubAgentManager {
|
||||
notify_tx: tokio::sync::mpsc::UnboundedSender<TaskNotification>,
|
||||
max_concurrent_background_tasks: usize,
|
||||
skills_loader: Option<Arc<SkillsLoader>>,
|
||||
work_manager: Option<Arc<crate::work::WorkManager>>,
|
||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||
}
|
||||
|
||||
@ -144,10 +147,16 @@ impl SubAgentManager {
|
||||
notify_tx,
|
||||
max_concurrent_background_tasks,
|
||||
skills_loader,
|
||||
work_manager: None,
|
||||
task_supervisor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_work_manager(mut self, work_manager: Arc<crate::work::WorkManager>) -> Self {
|
||||
self.work_manager = Some(work_manager);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn filter_tools(&self, allowed: &Option<Vec<String>>) -> Arc<ToolRegistry> {
|
||||
let allowed_set: HashSet<&str> = match allowed {
|
||||
Some(list) => list.iter().map(|s| s.as_str()).collect(),
|
||||
@ -155,7 +164,7 @@ impl SubAgentManager {
|
||||
};
|
||||
let filtered = ToolRegistry::new();
|
||||
for (name, tool) in self.full_tools.iter() {
|
||||
if allowed_set.contains(name.as_str()) && name != "delegate" {
|
||||
if allowed_set.contains(name.as_str()) && name != "delegate" && name != "todo" {
|
||||
filtered.register_raw(name, tool);
|
||||
}
|
||||
}
|
||||
@ -230,6 +239,7 @@ impl SubAgentManager {
|
||||
let agent = self
|
||||
.build_sub_agent(&config, tools)
|
||||
.map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?;
|
||||
self.assign_work_item(&config, &task_id).await?;
|
||||
|
||||
let history = vec![
|
||||
ChatMessage::system(system_prompt),
|
||||
@ -246,7 +256,7 @@ impl SubAgentManager {
|
||||
|
||||
let duration_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
match result {
|
||||
let result = match result {
|
||||
Ok(Ok(agent_result)) => {
|
||||
let (content, truncated) =
|
||||
truncate_sub_agent_result(&agent_result.final_response.content);
|
||||
@ -260,35 +270,37 @@ impl SubAgentManager {
|
||||
.iter()
|
||||
.filter(|m| m.role == "assistant" && m.tool_calls.is_some())
|
||||
.count();
|
||||
Ok(SubAgentResult {
|
||||
task_id,
|
||||
SubAgentResult {
|
||||
task_id: task_id.clone(),
|
||||
content,
|
||||
content_truncated: truncated,
|
||||
status: TaskStatus::Completed,
|
||||
tool_calls_count,
|
||||
iterations,
|
||||
duration_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Ok(SubAgentResult {
|
||||
task_id,
|
||||
Ok(Err(e)) => SubAgentResult {
|
||||
task_id: task_id.clone(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
status: TaskStatus::Failed(e.to_string()),
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
duration_ms,
|
||||
}),
|
||||
Err(_elapsed) => Ok(SubAgentResult {
|
||||
task_id,
|
||||
},
|
||||
Err(_elapsed) => SubAgentResult {
|
||||
task_id: task_id.clone(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
status: TaskStatus::TimedOut,
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
duration_ms,
|
||||
}),
|
||||
}
|
||||
},
|
||||
};
|
||||
self.finish_work_item(&config, &result).await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn run_parallel(
|
||||
@ -319,11 +331,12 @@ impl SubAgentManager {
|
||||
.map_err(|_| SubAgentError::TooManyTasks(self.max_concurrent_background_tasks))?;
|
||||
|
||||
let task_id = generate_task_id();
|
||||
let mut work_config = config.clone();
|
||||
if work_config.session_id.is_none() {
|
||||
work_config.session_id = Some(ctx.session_id.clone());
|
||||
}
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
self.active_tasks
|
||||
.insert(task_id.clone(), cancel_token.clone());
|
||||
|
||||
// Write DB: pending
|
||||
if let Some(ref storage) = self.storage {
|
||||
let allowed_tools_json = config
|
||||
@ -351,6 +364,28 @@ impl SubAgentManager {
|
||||
.await
|
||||
.map_err(|e| SubAgentError::Storage(e.to_string()))?;
|
||||
}
|
||||
if let Err(error) = self.assign_work_item(&work_config, &task_id).await {
|
||||
if let Some(ref storage) = self.storage {
|
||||
let _ = storage
|
||||
.update_background_task_status(
|
||||
&task_id,
|
||||
crate::storage::background_task::BackgroundTaskUpdate {
|
||||
status: "cancelled",
|
||||
result: None,
|
||||
error: Some("plan item assignment failed"),
|
||||
started_at: None,
|
||||
finished_at: Some(chrono::Utc::now().timestamp_millis()),
|
||||
tool_calls_count: None,
|
||||
iterations: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
self.active_tasks
|
||||
.insert(task_id.clone(), cancel_token.clone());
|
||||
|
||||
let tools = self.filter_tools(&config.allowed_tools);
|
||||
let timeout_secs = config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
@ -381,6 +416,9 @@ impl SubAgentManager {
|
||||
let ch = ctx.channel.clone();
|
||||
let cid = ctx.chat_id.clone();
|
||||
let prompt = config.prompt.clone();
|
||||
let work_manager = self.work_manager.clone();
|
||||
let work_item_id = work_config.plan_item_id.clone();
|
||||
let work_session_id = work_config.session_id.clone();
|
||||
|
||||
let spawned = self.task_supervisor.spawn_graceful(
|
||||
format!("sub-agent:{task_id}"),
|
||||
@ -510,6 +548,23 @@ impl SubAgentManager {
|
||||
TaskStatus::TimedOut => ("failed".to_string(), Some("timeout".to_string())),
|
||||
};
|
||||
|
||||
if let (Some(manager), Some(session_id), Some(item_id)) =
|
||||
(work_manager.as_ref(), work_session_id.as_deref(), work_item_id.as_deref())
|
||||
{
|
||||
let completed = matches!(result.status, TaskStatus::Completed);
|
||||
let summary = if completed {
|
||||
Some(result.content.as_str())
|
||||
} else {
|
||||
error_val.as_deref().or(Some("子 Agent 未完成任务"))
|
||||
};
|
||||
if let Err(error) = manager
|
||||
.finish_sub_agent(session_id, item_id, &tid, completed, summary)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(task_id = %tid, item_id, error = %error, "Failed to update plan item");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref s) = storage {
|
||||
let _ = s
|
||||
.update_background_task_status(&tid, crate::storage::background_task::BackgroundTaskUpdate {
|
||||
@ -554,6 +609,21 @@ impl SubAgentManager {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let (Some(manager), Some(session_id), Some(item_id)) = (
|
||||
self.work_manager.as_ref(),
|
||||
work_config.session_id.as_deref(),
|
||||
work_config.plan_item_id.as_deref(),
|
||||
) {
|
||||
let _ = manager
|
||||
.finish_sub_agent(
|
||||
session_id,
|
||||
item_id,
|
||||
&task_id,
|
||||
false,
|
||||
Some("gateway shutdown"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(SubAgentError::Other(
|
||||
"gateway is shutting down and cannot accept background tasks".to_string(),
|
||||
));
|
||||
@ -632,6 +702,51 @@ impl SubAgentManager {
|
||||
pub fn active_task_count(&self) -> usize {
|
||||
self.active_tasks.len()
|
||||
}
|
||||
|
||||
async fn assign_work_item(
|
||||
&self,
|
||||
config: &SubAgentConfig,
|
||||
task_id: &str,
|
||||
) -> Result<(), SubAgentError> {
|
||||
if let (Some(manager), Some(session_id), Some(item_id)) = (
|
||||
self.work_manager.as_ref(),
|
||||
config.session_id.as_deref(),
|
||||
config.plan_item_id.as_deref(),
|
||||
) {
|
||||
manager
|
||||
.assign_sub_agent(session_id, item_id, task_id)
|
||||
.await
|
||||
.map_err(|error| SubAgentError::Storage(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finish_work_item(&self, config: &SubAgentConfig, result: &SubAgentResult) {
|
||||
let (Some(manager), Some(session_id), Some(item_id)) = (
|
||||
self.work_manager.as_ref(),
|
||||
config.session_id.as_deref(),
|
||||
config.plan_item_id.as_deref(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let completed = matches!(result.status, TaskStatus::Completed);
|
||||
let summary = if completed {
|
||||
Some(result.content.as_str())
|
||||
} else {
|
||||
match &result.status {
|
||||
TaskStatus::Failed(error) => Some(error.as_str()),
|
||||
TaskStatus::TimedOut => Some("子 Agent 执行超时"),
|
||||
TaskStatus::Cancelled => Some("子 Agent 已取消"),
|
||||
TaskStatus::Completed => None,
|
||||
}
|
||||
};
|
||||
if let Err(error) = manager
|
||||
.finish_sub_agent(session_id, item_id, &result.task_id, completed, summary)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(task_id = %result.task_id, item_id, error = %error, "Failed to update plan item");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_task_id() -> String {
|
||||
@ -722,6 +837,8 @@ mod tests {
|
||||
allowed_tools: None,
|
||||
max_iterations: None,
|
||||
timeout_secs: Some(1),
|
||||
plan_item_id: None,
|
||||
session_id: None,
|
||||
},
|
||||
DelegateContext {
|
||||
session_id: "cli:test:dialog".into(),
|
||||
|
||||
@ -46,6 +46,7 @@ impl SystemPromptBuilder {
|
||||
Box::new(SafetySection),
|
||||
Box::new(CrossChannelSection),
|
||||
Box::new(MemorySection),
|
||||
Box::new(WorkManagementSection),
|
||||
Box::new(DelegationSection),
|
||||
],
|
||||
}
|
||||
@ -319,6 +320,26 @@ impl PromptSection for MemorySection {
|
||||
/// Sub-agent delegation principles.
|
||||
pub struct DelegationSection;
|
||||
|
||||
/// Optional session-scoped planning guidance. The actual active plan is
|
||||
/// injected dynamically only when one exists.
|
||||
pub struct WorkManagementSection;
|
||||
|
||||
impl PromptSection for WorkManagementSection {
|
||||
fn name(&self) -> &str {
|
||||
"work_management"
|
||||
}
|
||||
|
||||
fn build(&self, _ctx: &PromptContext<'_>) -> String {
|
||||
"## 任务追踪\n\n\
|
||||
- 普通闲聊、问答和单步操作不要创建计划。\n\
|
||||
- 用户明确要求规划,或任务需要至少三个可验证步骤、跨多个轮次、后台等待或并行委托时,使用 todo 创建计划。\n\
|
||||
- 计划存在时按真实进展更新子项;不要重复执行已分配给子 Agent 的子项。\n\
|
||||
- 子 Agent 可以并行执行不同子项;主 Agent 负责计划、整合、验证和关闭计划。\n\
|
||||
- 阻塞时记录原因,所有子项完成后才能关闭为 completed。"
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSection for DelegationSection {
|
||||
fn name(&self) -> &str {
|
||||
"delegation"
|
||||
@ -452,7 +473,11 @@ pub fn build_system_prompt(workspace_dir: &Path, model_name: &str, tools: &ToolR
|
||||
}
|
||||
|
||||
/// Build a runtime context tail that should be appended to the latest user message.
|
||||
pub fn build_runtime_context(session_id: Option<&str>, memory_context: Option<&str>) -> String {
|
||||
pub fn build_runtime_context(
|
||||
session_id: Option<&str>,
|
||||
memory_context: Option<&str>,
|
||||
work_context: Option<&str>,
|
||||
) -> String {
|
||||
let mut sections = Vec::new();
|
||||
let now = chrono::Local::now();
|
||||
|
||||
@ -470,6 +495,10 @@ pub fn build_runtime_context(session_id: Option<&str>, memory_context: Option<&s
|
||||
sections.push(format!("### 记忆上下文\n\n{}", context));
|
||||
}
|
||||
|
||||
if let Some(context) = work_context.filter(|s| !s.trim().is_empty()) {
|
||||
sections.push(context.to_string());
|
||||
}
|
||||
|
||||
if sections.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@ -574,7 +603,8 @@ mod tests {
|
||||
|
||||
let _ = (temp_dir, tools);
|
||||
|
||||
let prompt = build_runtime_context(Some("session-123"), Some("- user_pref: Prefers Rust"));
|
||||
let prompt =
|
||||
build_runtime_context(Some("session-123"), Some("- user_pref: Prefers Rust"), None);
|
||||
assert!(prompt.contains("## 运行时上下文"));
|
||||
assert!(prompt.contains("session-123"));
|
||||
assert!(prompt.contains("Prefers Rust"));
|
||||
@ -587,7 +617,7 @@ mod tests {
|
||||
|
||||
let _ = (temp_dir, tools);
|
||||
|
||||
let prompt = build_runtime_context(None, None);
|
||||
let prompt = build_runtime_context(None, None, None);
|
||||
assert!(prompt.contains("## 运行时上下文"));
|
||||
assert!(prompt.contains("当前日期与时间"));
|
||||
}
|
||||
|
||||
@ -98,6 +98,29 @@ impl CliChatChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a structured task-plan update to the WebSocket client that owns
|
||||
/// the session. Other channels remain unaffected.
|
||||
pub async fn publish_plan_changed(&self, event: crate::work::PlanChanged) {
|
||||
let Some(session_id) = UnifiedSessionId::parse(&event.session_id) else {
|
||||
return;
|
||||
};
|
||||
if session_id.channel != "cli_chat" {
|
||||
return;
|
||||
}
|
||||
let client = self.clients.lock().await.get(&session_id.chat_id).cloned();
|
||||
if let Some(client) = client {
|
||||
let _ = client
|
||||
.sender
|
||||
.send(WsOutbound::PlanUpdated {
|
||||
session_id: event.session_id,
|
||||
reason: event.reason,
|
||||
changed_item_ids: event.changed_item_ids,
|
||||
plan: event.plan,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an inbound message from a client
|
||||
pub(crate) async fn handle_inbound(&self, client: Arc<Client>, raw_msg: &str) {
|
||||
match parse_inbound(raw_msg) {
|
||||
@ -366,13 +389,22 @@ impl CliChatChannel {
|
||||
})) => {
|
||||
let messages = messages
|
||||
.into_iter()
|
||||
.filter(|message| !message.content.is_empty())
|
||||
.filter(|message| {
|
||||
!message.content.is_empty()
|
||||
|| message.tool_calls.is_some()
|
||||
|| message.role == "tool"
|
||||
})
|
||||
.map(|message| HistoryMessage {
|
||||
id: message.id,
|
||||
seq: message.seq,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
created_at: message.created_at,
|
||||
tool_call_id: message.tool_call_id,
|
||||
tool_name: message.tool_name,
|
||||
tool_calls: message
|
||||
.tool_calls
|
||||
.and_then(|calls| serde_json::from_str(&calls).ok()),
|
||||
})
|
||||
.collect();
|
||||
let _ = client
|
||||
@ -390,6 +422,31 @@ impl CliChatChannel {
|
||||
}
|
||||
}
|
||||
}
|
||||
WsInbound::GetSessionPlan { session_id } => {
|
||||
let unified_id = Self::parse_client_session(&client, &session_id)?;
|
||||
let (reply_tx, mut reply_rx) = mpsc::channel(1);
|
||||
bus.publish_control(ControlMessage {
|
||||
op: SessionCommand::GetTaskPlan {
|
||||
session_id: unified_id,
|
||||
},
|
||||
reply_tx,
|
||||
})
|
||||
.await?;
|
||||
match reply_rx.recv().await {
|
||||
Some(Ok(SessionEvent::TaskPlan { session_id, plan })) => {
|
||||
let _ = client
|
||||
.sender
|
||||
.send(WsOutbound::SessionPlan {
|
||||
session_id: session_id.to_string(),
|
||||
plan,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => return Err(error),
|
||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
||||
}
|
||||
}
|
||||
WsInbound::RenameSession { session_id, title } => {
|
||||
let target = session_id
|
||||
.or(current_session_guard.clone())
|
||||
|
||||
@ -194,6 +194,9 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
|
||||
session_id,
|
||||
messages,
|
||||
} => app.set_history(&session_id, messages),
|
||||
// The first Todo UI is WebUI-only. CLI keeps receiving ordinary task
|
||||
// notifications and may inspect plans through /todo.
|
||||
WsOutbound::SessionPlan { .. } | WsOutbound::PlanUpdated { .. } => {}
|
||||
WsOutbound::SessionRenamed { session_id, title } => {
|
||||
if let Some(session) = app
|
||||
.sessions
|
||||
|
||||
@ -211,6 +211,22 @@ impl GatewayState {
|
||||
let bus_for_outbound = bus.clone();
|
||||
let session_manager = self.session_manager.clone();
|
||||
|
||||
// Relay structured plan changes to WebSocket clients. This remains
|
||||
// separate from chat messages, so task UI updates never pollute history.
|
||||
let mut plan_events = self.session_manager.work_manager().subscribe();
|
||||
let cli_chat = self.cli_chat_channel();
|
||||
self.task_supervisor.spawn("task-plan-events", async move {
|
||||
loop {
|
||||
match plan_events.recv().await {
|
||||
Ok(event) => cli_chat.publish_plan_changed(event).await,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!(skipped, "Task plan event relay lagged");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn unified message processor
|
||||
// This handles both inbound AI messages and control messages in one loop
|
||||
self.task_supervisor.spawn("message-processor", async move {
|
||||
@ -362,6 +378,11 @@ impl GatewayState {
|
||||
messages,
|
||||
})
|
||||
.map_err(|e| ChannelError::Other(e.to_string())),
|
||||
GetTaskPlan { session_id } => session_manager
|
||||
.get_task_plan(&session_id)
|
||||
.await
|
||||
.map(|plan| SessionEvent::TaskPlan { session_id, plan })
|
||||
.map_err(|e| ChannelError::Other(e.to_string())),
|
||||
RenameDialog { session_id, title } => session_manager
|
||||
.rename_dialog(&session_id, &title)
|
||||
.await
|
||||
|
||||
@ -18,3 +18,4 @@ pub mod storage;
|
||||
pub mod task_supervisor;
|
||||
pub mod tools;
|
||||
pub mod util;
|
||||
pub mod work;
|
||||
|
||||
@ -26,6 +26,12 @@ pub struct HistoryMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub created_at: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<crate::providers::ToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -66,6 +72,8 @@ pub enum WsInbound {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "get_session_plan")]
|
||||
GetSessionPlan { session_id: String },
|
||||
#[serde(rename = "rename_session")]
|
||||
RenameSession {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@ -122,6 +130,18 @@ pub enum WsOutbound {
|
||||
session_id: String,
|
||||
messages: Vec<HistoryMessage>,
|
||||
},
|
||||
#[serde(rename = "session_plan")]
|
||||
SessionPlan {
|
||||
session_id: String,
|
||||
plan: Option<crate::work::TaskPlan>,
|
||||
},
|
||||
#[serde(rename = "plan_updated")]
|
||||
PlanUpdated {
|
||||
session_id: String,
|
||||
reason: String,
|
||||
changed_item_ids: Vec<String>,
|
||||
plan: Option<crate::work::TaskPlan>,
|
||||
},
|
||||
#[serde(rename = "session_renamed")]
|
||||
SessionRenamed { session_id: String, title: String },
|
||||
#[serde(rename = "session_archived")]
|
||||
|
||||
@ -26,6 +26,8 @@ pub enum SessionCommand {
|
||||
session_id: UnifiedSessionId,
|
||||
limit: u32,
|
||||
},
|
||||
/// Load the active task plan for a dialog.
|
||||
GetTaskPlan { session_id: UnifiedSessionId },
|
||||
/// Get the current dialog for a chat
|
||||
GetCurrentDialog { channel: String, chat_id: String },
|
||||
/// Rename a dialog
|
||||
|
||||
@ -36,6 +36,11 @@ pub enum SessionEvent {
|
||||
session_id: UnifiedSessionId,
|
||||
messages: Vec<crate::storage::message::MessageMeta>,
|
||||
},
|
||||
/// Active task plan for a dialog, if present.
|
||||
TaskPlan {
|
||||
session_id: UnifiedSessionId,
|
||||
plan: Option<crate::work::TaskPlan>,
|
||||
},
|
||||
/// Dialog renamed
|
||||
DialogRenamed {
|
||||
session_id: UnifiedSessionId,
|
||||
|
||||
@ -120,6 +120,7 @@ struct AgentTask {
|
||||
struct AgentWorkerDeps {
|
||||
bus: Arc<MessageBus>,
|
||||
memory_manager: Arc<crate::memory::MemoryManager>,
|
||||
work_manager: Arc<crate::work::WorkManager>,
|
||||
skills_loader: Arc<SkillsLoader>,
|
||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||
}
|
||||
@ -893,6 +894,7 @@ pub struct SessionManager {
|
||||
storage: Arc<Storage>,
|
||||
pub(super) bus: Arc<MessageBus>,
|
||||
memory_manager: Arc<crate::memory::MemoryManager>,
|
||||
work_manager: Arc<crate::work::WorkManager>,
|
||||
sub_agent_manager: Arc<crate::agent::SubAgentManager>,
|
||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||
}
|
||||
@ -982,6 +984,11 @@ pub static SLASH_COMMANDS: &[SlashCommand] = &[
|
||||
description: "停止当前正在执行的任务并清空消息队列",
|
||||
aliases: &["/stop"],
|
||||
},
|
||||
SlashCommand {
|
||||
name: "todo",
|
||||
description: "查看、完成或取消当前任务计划",
|
||||
aliases: &["/todo"],
|
||||
},
|
||||
];
|
||||
|
||||
fn resolve_slash_command(command: &str) -> Option<&'static SlashCommand> {
|
||||
@ -1000,6 +1007,7 @@ impl SessionManager {
|
||||
AgentWorkerDeps {
|
||||
bus: self.bus.clone(),
|
||||
memory_manager: self.memory_manager.clone(),
|
||||
work_manager: self.work_manager.clone(),
|
||||
skills_loader: self.skills_loader.clone(),
|
||||
task_supervisor: self.task_supervisor.clone(),
|
||||
}
|
||||
@ -1019,24 +1027,29 @@ impl SessionManager {
|
||||
skills_loader.set_workspace_skills_dir(provider_config.workspace_dir.clone());
|
||||
let skills_loader = Arc::new(skills_loader);
|
||||
|
||||
let work_manager = Arc::new(crate::work::WorkManager::new(storage.clone()));
|
||||
let tools = Arc::new(create_default_tools(
|
||||
skills_loader.clone(),
|
||||
memory_manager.clone(),
|
||||
work_manager.clone(),
|
||||
None, // SubAgentManager created below
|
||||
browser_config.as_ref(),
|
||||
));
|
||||
|
||||
// Create SubAgentManager and register DelegateTool
|
||||
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let sub_agent_manager = Arc::new(crate::agent::SubAgentManager::new(
|
||||
provider_config.clone(),
|
||||
tools.clone(),
|
||||
Some(storage.clone()),
|
||||
notify_tx,
|
||||
max_concurrent_background_tasks,
|
||||
Some(skills_loader.clone()),
|
||||
task_supervisor.clone(),
|
||||
));
|
||||
let sub_agent_manager = Arc::new(
|
||||
crate::agent::SubAgentManager::new(
|
||||
provider_config.clone(),
|
||||
tools.clone(),
|
||||
Some(storage.clone()),
|
||||
notify_tx,
|
||||
max_concurrent_background_tasks,
|
||||
Some(skills_loader.clone()),
|
||||
task_supervisor.clone(),
|
||||
)
|
||||
.with_work_manager(work_manager.clone()),
|
||||
);
|
||||
tools.register(crate::tools::DelegateTool::new(sub_agent_manager.clone()));
|
||||
|
||||
// Start background task notification consumer
|
||||
@ -1045,13 +1058,17 @@ impl SessionManager {
|
||||
while let Some(notif) = notify_rx.recv().await {
|
||||
let content =
|
||||
format_task_notification(¬if.task_id, ¬if.status, ¬if.result_summary);
|
||||
let metadata = HashMap::from([
|
||||
("_type".to_string(), "notification".to_string()),
|
||||
("_session_id".to_string(), notif.session_id),
|
||||
]);
|
||||
let outbound = OutboundMessage {
|
||||
channel: notif.channel,
|
||||
chat_id: notif.chat_id,
|
||||
content,
|
||||
reply_to: None,
|
||||
media: vec![],
|
||||
metadata: std::collections::HashMap::new(),
|
||||
metadata,
|
||||
delivery: None,
|
||||
};
|
||||
let _ = sm_bus.publish_outbound(outbound).await;
|
||||
@ -1088,6 +1105,7 @@ impl SessionManager {
|
||||
storage,
|
||||
bus,
|
||||
memory_manager,
|
||||
work_manager,
|
||||
sub_agent_manager,
|
||||
task_supervisor,
|
||||
})
|
||||
@ -1104,6 +1122,10 @@ impl SessionManager {
|
||||
self.tools.clone()
|
||||
}
|
||||
|
||||
pub fn work_manager(&self) -> Arc<crate::work::WorkManager> {
|
||||
self.work_manager.clone()
|
||||
}
|
||||
|
||||
/// 为定时任务创建一个无 session 绑定的 AgentLoop
|
||||
pub fn create_cron_agent(&self) -> Result<AgentLoop, AgentError> {
|
||||
let provider = create_provider(self.provider_config.clone())
|
||||
@ -1452,6 +1474,49 @@ impl SessionManager {
|
||||
};
|
||||
Ok((None, resp))
|
||||
}
|
||||
"todo" => {
|
||||
let sid = current_session_id
|
||||
.ok_or_else(|| AgentError::Other("no active session".to_string()))?;
|
||||
let action = args.map(str::trim).filter(|value| !value.is_empty());
|
||||
match action {
|
||||
Some("cancel") => self
|
||||
.work_manager
|
||||
.close_plan(&sid.to_string(), "cancelled", None)
|
||||
.await
|
||||
.map(|plan| (None, format!("任务计划已取消:{}", plan.objective)))
|
||||
.map_err(|error| AgentError::Other(error.to_string())),
|
||||
Some("done") => self
|
||||
.work_manager
|
||||
.close_plan(&sid.to_string(), "completed", None)
|
||||
.await
|
||||
.map(|plan| (None, format!("任务计划已完成:{}", plan.objective)))
|
||||
.map_err(|error| AgentError::Other(error.to_string())),
|
||||
Some(_) => Err(AgentError::Other("Usage: /todo [done|cancel]".to_string())),
|
||||
None => match self.work_manager.active_plan(&sid.to_string()).await {
|
||||
Ok(Some(plan)) => {
|
||||
let mut lines = vec![format!(
|
||||
"任务计划:{}(version {})",
|
||||
plan.objective, plan.version
|
||||
)];
|
||||
for item in plan.items {
|
||||
let icon = match item.status.as_str() {
|
||||
"completed" => "✓",
|
||||
"in_progress" => "●",
|
||||
"blocked" => "!",
|
||||
_ => "○",
|
||||
};
|
||||
lines.push(format!(
|
||||
"{icon} {} [{}] {}",
|
||||
item.id, item.status, item.title
|
||||
));
|
||||
}
|
||||
Ok((None, lines.join("\n")))
|
||||
}
|
||||
Ok(None) => Ok((None, "当前 session 没有 active plan。".to_string())),
|
||||
Err(error) => Err(AgentError::Other(error.to_string())),
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => Err(AgentError::Other(format!(
|
||||
"未知命令:/{}。输入 /? 获取帮助。",
|
||||
cmd.name
|
||||
@ -1674,6 +1739,21 @@ impl SessionManager {
|
||||
.map_err(|e| AgentError::Other(format!("failed to load dialog history: {e}")))
|
||||
}
|
||||
|
||||
pub async fn get_task_plan(
|
||||
&self,
|
||||
session_id: &UnifiedSessionId,
|
||||
) -> Result<Option<crate::work::TaskPlan>, AgentError> {
|
||||
let session_id = session_id.to_string();
|
||||
self.storage
|
||||
.get_session(&session_id)
|
||||
.await
|
||||
.map_err(|error| AgentError::Other(format!("failed to load dialog: {error}")))?;
|
||||
self.work_manager
|
||||
.plan_for_session(&session_id)
|
||||
.await
|
||||
.map_err(|error| AgentError::Other(format!("failed to load task plan: {error}")))
|
||||
}
|
||||
|
||||
pub async fn list_dialogs(
|
||||
&self,
|
||||
channel: &str,
|
||||
@ -2084,6 +2164,7 @@ fn spawn_agent_worker(
|
||||
let AgentWorkerDeps {
|
||||
bus,
|
||||
memory_manager,
|
||||
work_manager,
|
||||
skills_loader,
|
||||
task_supervisor,
|
||||
} = deps;
|
||||
@ -2224,8 +2305,19 @@ fn spawn_agent_worker(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let runtime_context =
|
||||
build_runtime_context(Some(unified_str.as_str()), memory_context.as_deref());
|
||||
let work_context = match work_manager.active_plan(&unified_str).await {
|
||||
Ok(Some(plan)) => Some(plan.compact_context()),
|
||||
Ok(None) => None,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "Failed to load active task plan");
|
||||
None
|
||||
}
|
||||
};
|
||||
let runtime_context = build_runtime_context(
|
||||
Some(unified_str.as_str()),
|
||||
memory_context.as_deref(),
|
||||
work_context.as_deref(),
|
||||
);
|
||||
|
||||
let system_prompt_out = {
|
||||
let guard = session.lock().await;
|
||||
|
||||
@ -14,7 +14,7 @@ use sqlx::{Pool, Row, Sqlite};
|
||||
use std::path::Path;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 2;
|
||||
const SCHEMA_VERSION: i64 = 3;
|
||||
const INSERT_MESSAGE_SQL: &str = r#"
|
||||
INSERT INTO messages (id, session_id, seq, role, content, reasoning_content, media_refs, tool_call_id, tool_name, tool_calls, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@ -170,6 +170,64 @@ impl Storage {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Session-scoped task plans. A session may have at most one active plan,
|
||||
// while independent items can be executed concurrently.
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS task_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
objective TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
closed_at INTEGER
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_plans_active_session
|
||||
ON task_plans(session_id) WHERE status = 'active'
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS task_items (
|
||||
id TEXT NOT NULL,
|
||||
plan_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
executor_kind TEXT,
|
||||
execution_id TEXT,
|
||||
result_summary TEXT,
|
||||
error TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(plan_id, id),
|
||||
FOREIGN KEY(plan_id) REFERENCES task_plans(id) ON DELETE CASCADE,
|
||||
UNIQUE(plan_id, ordinal)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_items_plan ON task_items(plan_id, ordinal)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
@ -1556,6 +1614,16 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(schema_version, SCHEMA_VERSION);
|
||||
for table in ["task_plans", "task_items"] {
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
)
|
||||
.bind(table)
|
||||
.fetch_one(storage.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(exists, 1, "missing migrated table {table}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -71,6 +71,10 @@ impl Tool for DelegateTool {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "该子任务的工具列表"
|
||||
},
|
||||
"plan_item_id": {
|
||||
"type": "string",
|
||||
"description": "可选,绑定当前计划中的子项 ID(如 T2)"
|
||||
}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
@ -79,6 +83,10 @@ impl Tool for DelegateTool {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "后台任务ID(action=check_task/cancel_task 时必填)"
|
||||
},
|
||||
"plan_item_id": {
|
||||
"type": "string",
|
||||
"description": "inline/background 模式可选,绑定当前计划中的子项 ID"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
@ -133,6 +141,8 @@ impl DelegateTool {
|
||||
allowed_tools,
|
||||
max_iterations,
|
||||
timeout_secs,
|
||||
plan_item_id: args["plan_item_id"].as_str().map(str::to_string),
|
||||
session_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -156,7 +166,14 @@ impl DelegateTool {
|
||||
|
||||
match mode {
|
||||
ExecutionMode::Inline => {
|
||||
let config = self.parse_config_from_args(args)?;
|
||||
let mut config = self.parse_config_from_args(args)?;
|
||||
if config.plan_item_id.is_some() {
|
||||
config.session_id = Some(
|
||||
crate::agent::sub_agent::get_delegate_context()
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.session_id,
|
||||
);
|
||||
}
|
||||
let result = self
|
||||
.sub_agent_manager
|
||||
.run_inline(config)
|
||||
@ -187,10 +204,11 @@ impl DelegateTool {
|
||||
}
|
||||
}
|
||||
ExecutionMode::Background => {
|
||||
let config = self.parse_config_from_args(args)?;
|
||||
let mut config = self.parse_config_from_args(args)?;
|
||||
let ctx = crate::agent::sub_agent::get_delegate_context().map_err(|_| {
|
||||
anyhow::anyhow!("delegate context not available: not in an agent worker")
|
||||
})?;
|
||||
config.session_id = Some(ctx.session_id.clone());
|
||||
|
||||
let task_id = self
|
||||
.sub_agent_manager
|
||||
@ -209,6 +227,7 @@ impl DelegateTool {
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("parallel mode requires 'tasks' array"))?;
|
||||
|
||||
let ctx = crate::agent::sub_agent::get_delegate_context().ok();
|
||||
let mut configs = Vec::new();
|
||||
for task in tasks {
|
||||
let prompt = task["prompt"]
|
||||
@ -228,6 +247,8 @@ impl DelegateTool {
|
||||
allowed_tools,
|
||||
max_iterations: args["max_iterations"].as_u64().map(|v| v as usize),
|
||||
timeout_secs: args["timeout_secs"].as_u64(),
|
||||
plan_item_id: task["plan_item_id"].as_str().map(str::to_string),
|
||||
session_id: ctx.as_ref().map(|ctx| ctx.session_id.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ pub mod pty;
|
||||
pub mod registry;
|
||||
pub mod schema;
|
||||
pub mod send_message;
|
||||
pub mod todo;
|
||||
pub mod traits;
|
||||
pub mod web_fetch;
|
||||
|
||||
@ -39,6 +40,7 @@ pub use memory::{MemoryForgetTool, MemoryRecallTool, MemoryStoreTool, TimelineRe
|
||||
pub use pty::{PtyManager, PtyTool};
|
||||
pub use registry::ToolRegistry;
|
||||
pub use send_message::SendMessageTool;
|
||||
pub use todo::TodoTool;
|
||||
pub use traits::{OutboundMessenger, Tool, ToolResult};
|
||||
pub use web_fetch::WebFetchTool;
|
||||
|
||||
@ -54,6 +56,7 @@ use std::sync::Arc;
|
||||
pub fn create_default_tools(
|
||||
skills_loader: Arc<SkillsLoader>,
|
||||
memory: Arc<MemoryManager>,
|
||||
work_manager: Arc<crate::work::WorkManager>,
|
||||
sub_agent_manager: Option<Arc<SubAgentManager>>,
|
||||
browser_config: Option<&BrowserConfig>,
|
||||
) -> ToolRegistry {
|
||||
@ -79,6 +82,7 @@ pub fn create_default_tools(
|
||||
registry.register(MemoryRecallTool::new(memory.clone()));
|
||||
registry.register(TimelineRecallTool::new(memory.clone()));
|
||||
registry.register(MemoryForgetTool::new(memory.clone()));
|
||||
registry.register(TodoTool::new(work_manager));
|
||||
|
||||
if let Some(cfg) = browser_config
|
||||
&& cfg.enabled
|
||||
|
||||
163
src/tools/todo.rs
Normal file
163
src/tools/todo.rs
Normal file
@ -0,0 +1,163 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use crate::work::{TaskPlan, WorkManager};
|
||||
|
||||
pub struct TodoTool {
|
||||
work_manager: Arc<WorkManager>,
|
||||
}
|
||||
|
||||
impl TodoTool {
|
||||
pub fn new(work_manager: Arc<WorkManager>) -> Self {
|
||||
Self { work_manager }
|
||||
}
|
||||
|
||||
fn context() -> anyhow::Result<crate::agent::DelegateContext> {
|
||||
crate::agent::sub_agent::get_delegate_context()
|
||||
.map_err(|_| anyhow::anyhow!("todo context not available outside a session worker"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TodoTool {
|
||||
fn name(&self) -> &str {
|
||||
"todo"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"管理当前 session 的复杂任务计划。仅用于明确需要多步骤、跨轮次或并行委托的任务;闲聊、问答和单步操作不要创建计划。"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["create", "view", "append", "update", "close"]
|
||||
},
|
||||
"objective": { "type": "string", "description": "create 时的总体目标" },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "create 时的有序子项"
|
||||
},
|
||||
"item_id": { "type": "string", "description": "update 时的 T1/T2..." },
|
||||
"title": { "type": "string", "description": "append 时的新子项标题" },
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "completed", "blocked", "cancelled"],
|
||||
"description": "update 的子项状态或 close 的计划状态"
|
||||
},
|
||||
"summary": { "type": "string", "description": "完成结果或阻塞原因" },
|
||||
"expected_version": { "type": "integer", "description": "可选的计划版本并发校验" }
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn read_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let action = args["action"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing action"))?;
|
||||
let ctx = Self::context()?;
|
||||
let expected = args["expected_version"].as_i64();
|
||||
let result = match action {
|
||||
"create" => {
|
||||
let objective = required_str(&args, "objective")?;
|
||||
let items = args["items"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("create requires items"))?
|
||||
.iter()
|
||||
.filter_map(|value| value.as_str().map(str::to_string))
|
||||
.collect::<Vec<_>>();
|
||||
self.work_manager
|
||||
.create_plan(&ctx.session_id, objective, &items)
|
||||
.await
|
||||
}
|
||||
"view" => match self.work_manager.active_plan(&ctx.session_id).await? {
|
||||
Some(plan) => Ok(plan),
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: "当前 session 没有 active plan".to_string(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
},
|
||||
"append" => {
|
||||
self.work_manager
|
||||
.append_item(&ctx.session_id, required_str(&args, "title")?, expected)
|
||||
.await
|
||||
}
|
||||
"update" => {
|
||||
self.work_manager
|
||||
.update_item(
|
||||
&ctx.session_id,
|
||||
required_str(&args, "item_id")?,
|
||||
required_str(&args, "status")?,
|
||||
args["summary"].as_str(),
|
||||
expected,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"close" => {
|
||||
self.work_manager
|
||||
.close_plan(&ctx.session_id, required_str(&args, "status")?, expected)
|
||||
.await
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("unknown todo action: {action}")),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(plan) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: render_plan(&plan),
|
||||
error: None,
|
||||
}),
|
||||
Err(error) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn required_str<'a>(args: &'a serde_json::Value, key: &str) -> anyhow::Result<&'a str> {
|
||||
args[key]
|
||||
.as_str()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: {key}"))
|
||||
}
|
||||
|
||||
fn render_plan(plan: &TaskPlan) -> String {
|
||||
let mut output = format!(
|
||||
"计划 {}({},version {})\n目标:{}\n",
|
||||
plan.id, plan.status, plan.version, plan.objective
|
||||
);
|
||||
for item in &plan.items {
|
||||
let icon = match item.status.as_str() {
|
||||
"completed" => "✓",
|
||||
"in_progress" => "●",
|
||||
"blocked" => "!",
|
||||
_ => "○",
|
||||
};
|
||||
output.push_str(&format!(
|
||||
"{icon} {} [{}] {}",
|
||||
item.id, item.status, item.title
|
||||
));
|
||||
if let Some(summary) = item.result_summary.as_deref().or(item.error.as_deref()) {
|
||||
output.push_str(&format!(" — {summary}"));
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
output.trim_end().to_string()
|
||||
}
|
||||
689
src/work/mod.rs
Normal file
689
src/work/mod.rs
Normal file
@ -0,0 +1,689 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::storage::{Storage, StorageError};
|
||||
|
||||
const MAX_OBJECTIVE_CHARS: usize = 2_000;
|
||||
const MAX_ITEM_TITLE_CHARS: usize = 500;
|
||||
const MAX_INITIAL_ITEMS: usize = 50;
|
||||
const MAX_ITEMS: usize = 100;
|
||||
const MAX_SUMMARY_CHARS: usize = 4_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TaskItem {
|
||||
pub id: String,
|
||||
pub ordinal: i64,
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub executor_kind: Option<String>,
|
||||
pub execution_id: Option<String>,
|
||||
pub result_summary: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub version: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TaskPlan {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub objective: String,
|
||||
pub status: String,
|
||||
pub version: i64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub closed_at: Option<i64>,
|
||||
pub items: Vec<TaskItem>,
|
||||
}
|
||||
|
||||
impl TaskPlan {
|
||||
pub fn compact_context(&self) -> String {
|
||||
let completed = self
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.status == "completed")
|
||||
.count();
|
||||
let mut lines = vec![
|
||||
"## 当前任务计划".to_string(),
|
||||
format!("- 目标:{}", self.objective),
|
||||
format!("- 进度:{completed}/{}", self.items.len()),
|
||||
];
|
||||
let running: Vec<_> = self
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.status == "in_progress")
|
||||
.take(8)
|
||||
.map(|item| {
|
||||
let executor = item.executor_kind.as_deref().unwrap_or("main_agent");
|
||||
format!(" - {} {}({})", item.id, item.title, executor)
|
||||
})
|
||||
.collect();
|
||||
if !running.is_empty() {
|
||||
lines.push("- 正在执行:".to_string());
|
||||
lines.extend(running);
|
||||
}
|
||||
let blocked: Vec<_> = self
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.status == "blocked")
|
||||
.take(8)
|
||||
.map(|item| {
|
||||
format!(
|
||||
" - {} {}:{}",
|
||||
item.id,
|
||||
item.title,
|
||||
item.error.as_deref().unwrap_or("原因未知")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if !blocked.is_empty() {
|
||||
lines.push("- 阻塞:".to_string());
|
||||
lines.extend(blocked);
|
||||
}
|
||||
if let Some(next) = self.items.iter().find(|item| item.status == "pending") {
|
||||
lines.push(format!("- 下一待处理:{} {}", next.id, next.title));
|
||||
}
|
||||
lines.push(format!("- 计划版本:{}", self.version));
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanChanged {
|
||||
pub session_id: String,
|
||||
pub reason: String,
|
||||
pub changed_item_ids: Vec<String>,
|
||||
pub plan: Option<TaskPlan>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkManager {
|
||||
storage: Arc<Storage>,
|
||||
events: broadcast::Sender<PlanChanged>,
|
||||
active_cache: Arc<DashMap<String, Option<TaskPlan>>>,
|
||||
}
|
||||
|
||||
impl WorkManager {
|
||||
pub fn new(storage: Arc<Storage>) -> Self {
|
||||
let (events, _) = broadcast::channel(128);
|
||||
Self {
|
||||
storage,
|
||||
events,
|
||||
active_cache: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<PlanChanged> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
pub async fn active_plan(&self, session_id: &str) -> Result<Option<TaskPlan>, StorageError> {
|
||||
if let Some(cached) = self.active_cache.get(session_id) {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
let row = sqlx::query(
|
||||
"SELECT id, session_id, objective, status, version, created_at, updated_at, closed_at \
|
||||
FROM task_plans WHERE session_id = ? AND status = 'active' LIMIT 1",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(self.storage.pool())
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
self.active_cache.insert(session_id.to_string(), None);
|
||||
return Ok(None);
|
||||
};
|
||||
let plan = self.plan_from_row(row).await?;
|
||||
self.cache_active_plan(&plan);
|
||||
Ok(Some(plan))
|
||||
}
|
||||
|
||||
pub async fn plan_for_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Option<TaskPlan>, StorageError> {
|
||||
self.active_plan(session_id).await
|
||||
}
|
||||
|
||||
async fn plan_from_row(&self, row: sqlx::sqlite::SqliteRow) -> Result<TaskPlan, StorageError> {
|
||||
let plan_id: String = row.get("id");
|
||||
let item_rows = sqlx::query(
|
||||
"SELECT id, ordinal, title, status, executor_kind, execution_id, result_summary, \
|
||||
error, version, updated_at FROM task_items WHERE plan_id = ? ORDER BY ordinal",
|
||||
)
|
||||
.bind(&plan_id)
|
||||
.fetch_all(self.storage.pool())
|
||||
.await?;
|
||||
Ok(TaskPlan {
|
||||
id: plan_id,
|
||||
session_id: row.get("session_id"),
|
||||
objective: row.get("objective"),
|
||||
status: row.get("status"),
|
||||
version: row.get("version"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
closed_at: row.get("closed_at"),
|
||||
items: item_rows.into_iter().map(item_from_row).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn plan_by_id(&self, plan_id: &str) -> Result<TaskPlan, StorageError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, session_id, objective, status, version, created_at, updated_at, closed_at \
|
||||
FROM task_plans WHERE id = ?",
|
||||
)
|
||||
.bind(plan_id)
|
||||
.fetch_optional(self.storage.pool())
|
||||
.await?
|
||||
.ok_or_else(|| StorageError::NotFound(plan_id.to_string()))?;
|
||||
self.plan_from_row(row).await
|
||||
}
|
||||
|
||||
pub async fn create_plan(
|
||||
&self,
|
||||
session_id: &str,
|
||||
objective: &str,
|
||||
items: &[String],
|
||||
) -> Result<TaskPlan, StorageError> {
|
||||
if objective.trim().is_empty() || items.is_empty() {
|
||||
return Err(StorageError::Conflict(
|
||||
"计划目标不能为空,且至少需要一个子项".to_string(),
|
||||
));
|
||||
}
|
||||
validate_length("计划目标", objective, MAX_OBJECTIVE_CHARS)?;
|
||||
if items.len() > MAX_INITIAL_ITEMS {
|
||||
return Err(StorageError::Conflict(format!(
|
||||
"初始计划最多包含 {MAX_INITIAL_ITEMS} 个子项"
|
||||
)));
|
||||
}
|
||||
if self.active_plan(session_id).await?.is_some() {
|
||||
return Err(StorageError::Conflict(
|
||||
"当前 session 已有 active plan".to_string(),
|
||||
));
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let plan_id = format!("plan-{}", crate::util::short_id());
|
||||
let mut tx = self.storage.pool().begin().await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO task_plans (id, session_id, objective, status, version, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, 'active', 1, ?, ?)",
|
||||
)
|
||||
.bind(&plan_id)
|
||||
.bind(session_id)
|
||||
.bind(objective.trim())
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for (index, title) in items.iter().enumerate() {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(StorageError::Conflict("计划子项不能为空".to_string()));
|
||||
}
|
||||
validate_length("计划子项", title, MAX_ITEM_TITLE_CHARS)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO task_items (id, plan_id, ordinal, title, status, version, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, 'pending', 1, ?, ?)",
|
||||
)
|
||||
.bind(format!("T{}", index + 1))
|
||||
.bind(&plan_id)
|
||||
.bind(index as i64 + 1)
|
||||
.bind(title)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
let plan = self.plan_by_id(&plan_id).await?;
|
||||
self.cache_active_plan(&plan);
|
||||
self.emit("plan_created", vec![], Some(plan.clone()));
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub async fn append_item(
|
||||
&self,
|
||||
session_id: &str,
|
||||
title: &str,
|
||||
expected_version: Option<i64>,
|
||||
) -> Result<TaskPlan, StorageError> {
|
||||
let plan = self.require_active(session_id, expected_version).await?;
|
||||
if title.trim().is_empty() {
|
||||
return Err(StorageError::Conflict("计划子项不能为空".to_string()));
|
||||
}
|
||||
if plan.items.len() >= MAX_ITEMS {
|
||||
return Err(StorageError::Conflict(format!(
|
||||
"计划最多包含 {MAX_ITEMS} 个子项"
|
||||
)));
|
||||
}
|
||||
validate_length("计划子项", title, MAX_ITEM_TITLE_CHARS)?;
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let ordinal = plan.items.last().map_or(1, |item| item.ordinal + 1);
|
||||
let item_id = format!("T{ordinal}");
|
||||
let mut tx = self.storage.pool().begin().await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO task_items (id, plan_id, ordinal, title, status, version, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, 'pending', 1, ?, ?)",
|
||||
)
|
||||
.bind(&item_id)
|
||||
.bind(&plan.id)
|
||||
.bind(ordinal)
|
||||
.bind(title.trim())
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
bump_plan(&mut tx, &plan.id, now).await?;
|
||||
tx.commit().await?;
|
||||
let updated = self.plan_by_id(&plan.id).await?;
|
||||
self.cache_active_plan(&updated);
|
||||
self.emit("item_added", vec![item_id], Some(updated.clone()));
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn update_item(
|
||||
&self,
|
||||
session_id: &str,
|
||||
item_id: &str,
|
||||
status: &str,
|
||||
summary: Option<&str>,
|
||||
expected_version: Option<i64>,
|
||||
) -> Result<TaskPlan, StorageError> {
|
||||
validate_item_status(status)?;
|
||||
let plan = self.require_active(session_id, expected_version).await?;
|
||||
let current = plan
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.id == item_id)
|
||||
.ok_or_else(|| StorageError::NotFound(item_id.to_string()))?;
|
||||
validate_transition(¤t.status, status)?;
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let summary = summary.map(|value| truncate_chars(value, MAX_SUMMARY_CHARS));
|
||||
let error = (status == "blocked")
|
||||
.then_some(summary.as_deref())
|
||||
.flatten();
|
||||
let result_summary = (status == "completed")
|
||||
.then_some(summary.as_deref())
|
||||
.flatten();
|
||||
let executor = match status {
|
||||
"in_progress" => Some("main_agent"),
|
||||
"completed" | "blocked" => current.executor_kind.as_deref(),
|
||||
_ => None,
|
||||
};
|
||||
let execution_id = matches!(status, "completed" | "blocked")
|
||||
.then_some(current.execution_id.as_deref())
|
||||
.flatten();
|
||||
let mut tx = self.storage.pool().begin().await?;
|
||||
let update = sqlx::query(
|
||||
"UPDATE task_items SET status = ?, executor_kind = ?, execution_id = ?, \
|
||||
result_summary = ?, error = ?, \
|
||||
version = version + 1, updated_at = ? \
|
||||
WHERE plan_id = ? AND id = ? AND status = ? AND version = ?",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(executor)
|
||||
.bind(execution_id)
|
||||
.bind(result_summary)
|
||||
.bind(error)
|
||||
.bind(now)
|
||||
.bind(&plan.id)
|
||||
.bind(item_id)
|
||||
.bind(¤t.status)
|
||||
.bind(current.version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if update.rows_affected() != 1 {
|
||||
return Err(StorageError::Conflict(format!(
|
||||
"子项 {item_id} 已被其他执行更新,请读取最新计划后重试"
|
||||
)));
|
||||
}
|
||||
bump_plan(&mut tx, &plan.id, now).await?;
|
||||
tx.commit().await?;
|
||||
let updated = self.plan_by_id(&plan.id).await?;
|
||||
self.cache_active_plan(&updated);
|
||||
self.emit(
|
||||
"item_updated",
|
||||
vec![item_id.to_string()],
|
||||
Some(updated.clone()),
|
||||
);
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn assign_sub_agent(
|
||||
&self,
|
||||
session_id: &str,
|
||||
item_id: &str,
|
||||
execution_id: &str,
|
||||
) -> Result<TaskPlan, StorageError> {
|
||||
let plan = self.require_active(session_id, None).await?;
|
||||
let item = plan
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.id == item_id)
|
||||
.ok_or_else(|| StorageError::NotFound(item_id.to_string()))?;
|
||||
if item.status != "pending" {
|
||||
return Err(StorageError::Conflict(format!(
|
||||
"子项 {item_id} 当前状态为 {},不能重复委托",
|
||||
item.status
|
||||
)));
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let mut tx = self.storage.pool().begin().await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE task_items SET status = 'in_progress', executor_kind = 'sub_agent', \
|
||||
execution_id = ?, error = NULL, version = version + 1, updated_at = ? \
|
||||
WHERE plan_id = ? AND id = ? AND status = 'pending'",
|
||||
)
|
||||
.bind(execution_id)
|
||||
.bind(now)
|
||||
.bind(&plan.id)
|
||||
.bind(item_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(StorageError::Conflict(format!(
|
||||
"子项 {item_id} 已被其他执行领取"
|
||||
)));
|
||||
}
|
||||
bump_plan(&mut tx, &plan.id, now).await?;
|
||||
tx.commit().await?;
|
||||
let updated = self.plan_by_id(&plan.id).await?;
|
||||
self.cache_active_plan(&updated);
|
||||
self.emit(
|
||||
"item_assigned",
|
||||
vec![item_id.to_string()],
|
||||
Some(updated.clone()),
|
||||
);
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn finish_sub_agent(
|
||||
&self,
|
||||
session_id: &str,
|
||||
item_id: &str,
|
||||
execution_id: &str,
|
||||
completed: bool,
|
||||
summary: Option<&str>,
|
||||
) -> Result<Option<TaskPlan>, StorageError> {
|
||||
let Some(plan) = self.active_plan(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let item = plan.items.iter().find(|item| item.id == item_id);
|
||||
if !item.is_some_and(|item| {
|
||||
item.status == "in_progress" && item.execution_id.as_deref() == Some(execution_id)
|
||||
}) {
|
||||
return Ok(Some(plan));
|
||||
}
|
||||
let status = if completed { "completed" } else { "blocked" };
|
||||
let summary = summary.map(|value| truncate_chars(value, MAX_SUMMARY_CHARS));
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let mut tx = self.storage.pool().begin().await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE task_items SET status = ?, result_summary = ?, error = ?, \
|
||||
version = version + 1, updated_at = ? \
|
||||
WHERE plan_id = ? AND id = ? AND execution_id = ? AND status = 'in_progress'",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(completed.then_some(summary.as_deref()).flatten())
|
||||
.bind((!completed).then_some(summary.as_deref()).flatten())
|
||||
.bind(now)
|
||||
.bind(&plan.id)
|
||||
.bind(item_id)
|
||||
.bind(execution_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() != 1 {
|
||||
drop(tx);
|
||||
return Ok(Some(self.plan_by_id(&plan.id).await?));
|
||||
}
|
||||
bump_plan(&mut tx, &plan.id, now).await?;
|
||||
tx.commit().await?;
|
||||
let updated = self.plan_by_id(&plan.id).await?;
|
||||
self.cache_active_plan(&updated);
|
||||
self.emit(
|
||||
if completed {
|
||||
"item_completed"
|
||||
} else {
|
||||
"item_blocked"
|
||||
},
|
||||
vec![item_id.to_string()],
|
||||
Some(updated.clone()),
|
||||
);
|
||||
Ok(Some(updated))
|
||||
}
|
||||
|
||||
pub async fn close_plan(
|
||||
&self,
|
||||
session_id: &str,
|
||||
status: &str,
|
||||
expected_version: Option<i64>,
|
||||
) -> Result<TaskPlan, StorageError> {
|
||||
if !matches!(status, "completed" | "cancelled") {
|
||||
return Err(StorageError::Conflict(
|
||||
"计划只能 completed 或 cancelled".to_string(),
|
||||
));
|
||||
}
|
||||
let plan = self.require_active(session_id, expected_version).await?;
|
||||
if status == "completed" && plan.items.iter().any(|item| item.status != "completed") {
|
||||
return Err(StorageError::Conflict(
|
||||
"仍有未完成子项,不能关闭计划".to_string(),
|
||||
));
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
sqlx::query(
|
||||
"UPDATE task_plans SET status = ?, version = version + 1, updated_at = ?, closed_at = ? \
|
||||
WHERE id = ? AND status = 'active'",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(&plan.id)
|
||||
.execute(self.storage.pool())
|
||||
.await?;
|
||||
let closed = self.plan_by_id(&plan.id).await?;
|
||||
self.active_cache.insert(session_id.to_string(), None);
|
||||
self.emit("plan_closed", vec![], Some(closed.clone()));
|
||||
Ok(closed)
|
||||
}
|
||||
|
||||
async fn require_active(
|
||||
&self,
|
||||
session_id: &str,
|
||||
expected_version: Option<i64>,
|
||||
) -> Result<TaskPlan, StorageError> {
|
||||
let plan = self
|
||||
.active_plan(session_id)
|
||||
.await?
|
||||
.ok_or_else(|| StorageError::NotFound("active plan".to_string()))?;
|
||||
if expected_version.is_some_and(|version| version != plan.version) {
|
||||
return Err(StorageError::Conflict(format!(
|
||||
"计划版本已变化:expected {}, actual {}",
|
||||
expected_version.unwrap_or_default(),
|
||||
plan.version
|
||||
)));
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn emit(&self, reason: &str, changed_item_ids: Vec<String>, plan: Option<TaskPlan>) {
|
||||
let Some(session_id) = plan.as_ref().map(|plan| plan.session_id.clone()) else {
|
||||
return;
|
||||
};
|
||||
let _ = self.events.send(PlanChanged {
|
||||
session_id,
|
||||
reason: reason.to_string(),
|
||||
changed_item_ids,
|
||||
plan,
|
||||
});
|
||||
}
|
||||
|
||||
fn cache_active_plan(&self, plan: &TaskPlan) {
|
||||
let session_id = plan.session_id.clone();
|
||||
match self.active_cache.entry(session_id) {
|
||||
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
|
||||
let should_replace = entry
|
||||
.get()
|
||||
.as_ref()
|
||||
.is_none_or(|cached| cached.id != plan.id || cached.version <= plan.version);
|
||||
if should_replace {
|
||||
entry.insert(Some(plan.clone()));
|
||||
}
|
||||
}
|
||||
dashmap::mapref::entry::Entry::Vacant(entry) => {
|
||||
entry.insert(Some(plan.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn item_from_row(row: sqlx::sqlite::SqliteRow) -> TaskItem {
|
||||
TaskItem {
|
||||
id: row.get("id"),
|
||||
ordinal: row.get("ordinal"),
|
||||
title: row.get("title"),
|
||||
status: row.get("status"),
|
||||
executor_kind: row.get("executor_kind"),
|
||||
execution_id: row.get("execution_id"),
|
||||
result_summary: row.get("result_summary"),
|
||||
error: row.get("error"),
|
||||
version: row.get("version"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn bump_plan(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
plan_id: &str,
|
||||
now: i64,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE task_plans SET version = version + 1, updated_at = ? WHERE id = ?")
|
||||
.bind(now)
|
||||
.bind(plan_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_item_status(status: &str) -> Result<(), StorageError> {
|
||||
if matches!(status, "pending" | "in_progress" | "completed" | "blocked") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StorageError::Conflict(format!("未知子项状态:{status}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_transition(from: &str, to: &str) -> Result<(), StorageError> {
|
||||
let allowed = matches!(
|
||||
(from, to),
|
||||
("pending", "in_progress")
|
||||
| ("pending", "completed")
|
||||
| ("pending", "blocked")
|
||||
| ("in_progress", "completed")
|
||||
| ("in_progress", "blocked")
|
||||
| ("blocked", "pending")
|
||||
| ("blocked", "in_progress")
|
||||
| ("blocked", "completed")
|
||||
) || from == to;
|
||||
if allowed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StorageError::Conflict(format!(
|
||||
"不允许的状态迁移:{from} -> {to}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_length(label: &str, value: &str, max_chars: usize) -> Result<(), StorageError> {
|
||||
if value.chars().count() <= max_chars {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StorageError::Conflict(format!(
|
||||
"{label}不能超过 {max_chars} 个字符"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||
value.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn manager() -> (WorkManager, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let storage = Arc::new(Storage::new(&dir.path().join("work.db")).await.unwrap());
|
||||
(WorkManager::new(storage), dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_active_plan_per_session_and_parallel_items() {
|
||||
let (manager, _dir) = manager().await;
|
||||
let plan = manager
|
||||
.create_plan("cli:a:d1", "ship it", &["one".into(), "two".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(plan.items.len(), 2);
|
||||
let other = manager
|
||||
.create_plan("cli:b:d2", "other", &["one".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(other.items[0].id, "T1");
|
||||
assert!(
|
||||
manager
|
||||
.create_plan("cli:a:d1", "another", &["x".into()])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
manager
|
||||
.assign_sub_agent("cli:a:d1", "T1", "run-1")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
manager
|
||||
.assign_sub_agent("cli:a:d1", "T1", "run-duplicate")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let plan = manager
|
||||
.assign_sub_agent("cli:a:d1", "T2", "run-2")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plan.items
|
||||
.iter()
|
||||
.filter(|item| item.status == "in_progress")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn late_sub_agent_result_does_not_mutate_cancelled_plan() {
|
||||
let (manager, _dir) = manager().await;
|
||||
manager
|
||||
.create_plan("cli:a:d1", "ship", &["one".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
manager
|
||||
.assign_sub_agent("cli:a:d1", "T1", "run-1")
|
||||
.await
|
||||
.unwrap();
|
||||
manager
|
||||
.close_plan("cli:a:d1", "cancelled", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
manager
|
||||
.finish_sub_agent("cli:a:d1", "T1", "run-1", true, Some("done"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
use picobot::protocol::{HistoryMessage, SessionSummary, WsInbound, WsOutbound};
|
||||
use picobot::providers::{ChatCompletionRequest, Message};
|
||||
use picobot::providers::{ChatCompletionRequest, Message, ToolCall};
|
||||
use picobot::work::{TaskItem, TaskPlan};
|
||||
|
||||
/// Test that message with special characters is properly escaped
|
||||
#[test]
|
||||
@ -135,6 +136,9 @@ fn test_bounded_session_history_protocol() {
|
||||
role: "user".to_string(),
|
||||
content: "你好".to_string(),
|
||||
created_at: 123,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
}],
|
||||
};
|
||||
let decoded: WsOutbound =
|
||||
@ -147,3 +151,89 @@ fn test_bounded_session_history_protocol() {
|
||||
other => panic!("unexpected decoded variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_history_preserves_tool_call_metadata() {
|
||||
let outbound = WsOutbound::SessionHistory {
|
||||
session_id: "cli_chat:client:dialog".to_string(),
|
||||
messages: vec![HistoryMessage {
|
||||
id: "m-tool".to_string(),
|
||||
seq: 2,
|
||||
role: "assistant".to_string(),
|
||||
content: String::new(),
|
||||
created_at: 124,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
tool_calls: Some(vec![ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
arguments: serde_json::json!({ "path": "README.md" }),
|
||||
}]),
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&outbound).unwrap();
|
||||
assert!(json.contains(r#""tool_calls""#));
|
||||
assert!(json.contains(r#""read_file""#));
|
||||
let decoded: WsOutbound = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
WsOutbound::SessionHistory { messages, .. } => {
|
||||
let calls = messages[0].tool_calls.as_ref().unwrap();
|
||||
assert_eq!(calls[0].name, "read_file");
|
||||
assert_eq!(calls[0].arguments["path"], "README.md");
|
||||
}
|
||||
other => panic!("unexpected decoded variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_plan_protocol_is_structured_and_versioned() {
|
||||
let inbound = WsInbound::GetSessionPlan {
|
||||
session_id: "cli_chat:client:dialog".to_string(),
|
||||
};
|
||||
assert!(
|
||||
serde_json::to_string(&inbound)
|
||||
.unwrap()
|
||||
.contains(r#""type":"get_session_plan""#)
|
||||
);
|
||||
|
||||
let plan = TaskPlan {
|
||||
id: "plan-1".to_string(),
|
||||
session_id: "cli_chat:client:dialog".to_string(),
|
||||
objective: "实现 Todo".to_string(),
|
||||
status: "active".to_string(),
|
||||
version: 3,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
closed_at: None,
|
||||
items: vec![TaskItem {
|
||||
id: "T1".to_string(),
|
||||
ordinal: 1,
|
||||
title: "实现协议".to_string(),
|
||||
status: "in_progress".to_string(),
|
||||
executor_kind: Some("sub_agent".to_string()),
|
||||
execution_id: Some("run-1".to_string()),
|
||||
result_summary: None,
|
||||
error: None,
|
||||
version: 2,
|
||||
updated_at: 2,
|
||||
}],
|
||||
};
|
||||
let outbound = WsOutbound::PlanUpdated {
|
||||
session_id: plan.session_id.clone(),
|
||||
reason: "item_assigned".to_string(),
|
||||
changed_item_ids: vec!["T1".to_string()],
|
||||
plan: Some(plan),
|
||||
};
|
||||
let decoded: WsOutbound =
|
||||
serde_json::from_str(&serde_json::to_string(&outbound).unwrap()).unwrap();
|
||||
match decoded {
|
||||
WsOutbound::PlanUpdated { plan, reason, .. } => {
|
||||
let plan = plan.unwrap();
|
||||
assert_eq!(plan.version, 3);
|
||||
assert_eq!(plan.items[0].execution_id.as_deref(), Some("run-1"));
|
||||
assert_eq!(reason, "item_assigned");
|
||||
}
|
||||
other => panic!("unexpected decoded variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#0b0d10" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<title>PicoBot Console</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
23
webui/package-lock.json
generated
23
webui/package-lock.json
generated
@ -9,6 +9,8 @@
|
||||
"version": "1.1.2",
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.0.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"marked": "^18.0.6",
|
||||
"svelte": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -1081,6 +1083,15 @@
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/esbuild/-/esbuild-0.28.1.tgz",
|
||||
@ -1217,6 +1228,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.6.tgz",
|
||||
"integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/mri/-/mri-1.2.0.tgz",
|
||||
|
||||
@ -13,6 +13,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.0.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"marked": "^18.0.6",
|
||||
"svelte": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
let menuOpen = $state(false);
|
||||
let online = $state(false);
|
||||
let version = $state("Gateway");
|
||||
let theme = $state("dark");
|
||||
let toast;
|
||||
const meta = $derived(pages.find(([name]) => name === current) || pages[0]);
|
||||
|
||||
@ -38,7 +39,17 @@
|
||||
menuOpen = false;
|
||||
}
|
||||
|
||||
function applyTheme(next) {
|
||||
theme = next;
|
||||
document.documentElement.dataset.theme = next;
|
||||
document.documentElement.style.colorScheme = next;
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", next === "dark" ? "#0d1117" : "#f6f7f9");
|
||||
localStorage.setItem("picobot-theme", next);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const saved = localStorage.getItem("picobot-theme");
|
||||
applyTheme(saved === "light" || saved === "dark" ? saved : (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
|
||||
health();
|
||||
const timer = setInterval(health, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
@ -60,6 +71,9 @@
|
||||
<header class="topbar">
|
||||
<button class="menu" aria-label="菜单" onclick={() => (menuOpen = !menuOpen)}>☰</button>
|
||||
<div><h1>{meta[2]}</h1><p>{meta[3]}</p></div>
|
||||
<button class="theme-toggle" aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} onclick={() => applyTheme(theme === "dark" ? "light" : "dark")}>
|
||||
<span aria-hidden="true">{theme === "dark" ? "☀" : "☾"}</span><span>{theme === "dark" ? "浅色" : "深色"}</span>
|
||||
</button>
|
||||
</header>
|
||||
{#if current === "chat"}<ChatPage notify={(text, error) => toast.show(text, error)} />
|
||||
{:else if current === "tasks"}<TasksPage />
|
||||
|
||||
20
webui/src/lib/Markdown.svelte
Normal file
20
webui/src/lib/Markdown.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script>
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
let { content = "" } = $props();
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true });
|
||||
|
||||
function renderMarkdown(value) {
|
||||
const rendered = marked.parse(value || "", { async: false });
|
||||
return DOMPurify.sanitize(typeof rendered === "string" ? rendered : "", {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_ATTR: ["target", "rel"]
|
||||
});
|
||||
}
|
||||
|
||||
const html = $derived(renderMarkdown(content));
|
||||
</script>
|
||||
|
||||
<div class="markdown-body">{@html html}</div>
|
||||
32
webui/src/lib/ToolCallCard.svelte
Normal file
32
webui/src/lib/ToolCallCard.svelte
Normal file
@ -0,0 +1,32 @@
|
||||
<script>
|
||||
import Markdown from "./Markdown.svelte";
|
||||
|
||||
let { call, result = null } = $props();
|
||||
let open = $state(false);
|
||||
|
||||
const argumentsText = $derived.by(() => {
|
||||
try {
|
||||
return JSON.stringify(call?.arguments ?? {}, null, 2);
|
||||
} catch {
|
||||
return String(call?.arguments ?? "");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<article class:open class="tool-call">
|
||||
<button type="button" class="tool-call-summary" onclick={() => open = !open} aria-expanded={open}>
|
||||
<span class="tool-call-icon" aria-hidden="true">⌘</span>
|
||||
<span class="tool-call-title"><small>工具调用</small><strong>{call?.name || result?.tool_name || "未知工具"}</strong></span>
|
||||
<span class="tool-call-status">{result ? "已完成" : "已调用"}</span>
|
||||
<span class="tool-call-chevron" aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="tool-call-details">
|
||||
<div><span>参数</span><pre>{argumentsText}</pre></div>
|
||||
{#if result}
|
||||
<div><span>结果</span><div class="tool-result"><Markdown content={result.content || "无返回内容"} /></div></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
import { onMount, tick } from "svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import { clientId, formatTime } from "../lib/api.js";
|
||||
import Markdown from "../lib/Markdown.svelte";
|
||||
import ToolCallCard from "../lib/ToolCallCard.svelte";
|
||||
|
||||
let { notify } = $props();
|
||||
let socket = $state(null);
|
||||
@ -15,11 +17,16 @@
|
||||
let selectedCommand = $state(0);
|
||||
let commandMenuDismissed = $state(false);
|
||||
let thinking = $state(false);
|
||||
let plansBySession = $state({});
|
||||
let unseenPlanSessions = $state({});
|
||||
let todoOpen = $state(false);
|
||||
let messageBox;
|
||||
let input;
|
||||
let reconnectTimer;
|
||||
let stopped = false;
|
||||
const currentSession = $derived(sessions.find((item) => item.session_id === currentId));
|
||||
const currentPlan = $derived(currentId ? plansBySession[currentId] || null : null);
|
||||
const completedItems = $derived(currentPlan?.items?.filter((item) => item.status === "completed").length || 0);
|
||||
const filteredSessions = $derived(sessions.filter((item) => item.title.toLowerCase().includes(search.toLowerCase())));
|
||||
const commandQuery = $derived(
|
||||
!commandMenuDismissed && draft.startsWith("/") && !/[\s]/.test(draft)
|
||||
@ -50,6 +57,9 @@
|
||||
socket = ws;
|
||||
ws.onopen = () => {
|
||||
connected = true;
|
||||
plansBySession = {};
|
||||
unseenPlanSessions = {};
|
||||
todoOpen = false;
|
||||
send({ type: "list_sessions", include_archived: false });
|
||||
send({ type: "get_slash_commands" });
|
||||
};
|
||||
@ -77,20 +87,48 @@
|
||||
case "session_loaded":
|
||||
currentId = frame.session_id;
|
||||
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
|
||||
send({ type: "get_session_plan", session_id: currentId });
|
||||
break;
|
||||
case "session_history":
|
||||
if (frame.session_id === currentId) {
|
||||
messages = (frame.messages || []).filter((item) => ["user", "assistant"].includes(item.role));
|
||||
messages = frame.messages || [];
|
||||
scrollToBottom();
|
||||
}
|
||||
break;
|
||||
case "session_plan":
|
||||
if (frame.plan) {
|
||||
const previous = plansBySession[frame.session_id];
|
||||
if (frame.plan.id !== previous?.id || frame.plan.version >= (previous?.version || 0)) {
|
||||
plansBySession[frame.session_id] = frame.plan;
|
||||
}
|
||||
} else if (plansBySession[frame.session_id]?.status !== "active") {
|
||||
plansBySession[frame.session_id] = null;
|
||||
}
|
||||
break;
|
||||
case "plan_updated": {
|
||||
const previousPlan = plansBySession[frame.session_id];
|
||||
const previousVersion = previousPlan?.version || 0;
|
||||
const nextVersion = frame.plan?.version || 0;
|
||||
if (frame.plan?.id === previousPlan?.id && nextVersion && nextVersion <= previousVersion) break;
|
||||
plansBySession[frame.session_id] = frame.plan;
|
||||
if (frame.session_id === currentId) {
|
||||
todoOpen = true;
|
||||
unseenPlanSessions[frame.session_id] = false;
|
||||
} else {
|
||||
unseenPlanSessions[frame.session_id] = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "slash_commands_list":
|
||||
commands = frame.commands || [];
|
||||
selectedCommand = 0;
|
||||
break;
|
||||
case "assistant_response":
|
||||
thinking = false;
|
||||
if (!frame.session_id || frame.session_id === currentId) appendMessage(frame.role || "assistant", frame.content);
|
||||
if (!frame.session_id || frame.session_id === currentId) {
|
||||
appendMessage(frame.role || "assistant", frame.content);
|
||||
if (currentId) send({ type: "get_session_history", session_id: currentId, limit: 1000 });
|
||||
}
|
||||
send({ type: "list_sessions", include_archived: false });
|
||||
break;
|
||||
case "system_notification":
|
||||
@ -115,9 +153,22 @@
|
||||
if (!id) return;
|
||||
currentId = id;
|
||||
messages = [];
|
||||
todoOpen = Boolean(unseenPlanSessions[id]);
|
||||
unseenPlanSessions[id] = false;
|
||||
send({ type: "load_session", session_id: id });
|
||||
}
|
||||
|
||||
function itemIcon(status) {
|
||||
if (status === "completed") return "✓";
|
||||
if (status === "in_progress") return "●";
|
||||
if (status === "blocked") return "!";
|
||||
return "○";
|
||||
}
|
||||
|
||||
function toolResult(callId) {
|
||||
return messages.find((message) => message.role === "tool" && message.tool_call_id === callId) || null;
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const content = draft.trim();
|
||||
if (!content || !connected) return;
|
||||
@ -200,14 +251,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="page active chat-layout">
|
||||
<section class:todo-open={todoOpen && currentPlan} class="page active chat-layout">
|
||||
<aside class="sessions-panel">
|
||||
<button class="primary full" onclick={() => send({ type: "create_session", title: null })}>+ 新建对话</button>
|
||||
<label class="search"><span>⌕</span><input bind:value={search} placeholder="搜索对话" /></label>
|
||||
<div class="session-list">
|
||||
{#each filteredSessions as session (session.session_id)}
|
||||
<button class:active={session.session_id === currentId} class="session-item" onclick={() => loadSession(session.session_id)}>
|
||||
<strong>{session.title}</strong><small><span>{session.message_count} 条消息</span><span>{formatTime(session.last_active_at).split(" ")[0]}</span></small>
|
||||
<strong>{session.title}{#if unseenPlanSessions[session.session_id]}<i class="plan-unread" aria-label="任务计划有更新"></i>{/if}</strong><small><span>{session.message_count} 条消息</span><span>{formatTime(session.last_active_at).split(" ")[0]}</span></small>
|
||||
</button>
|
||||
{:else}<div class="empty-card compact">暂无对话</div>{/each}
|
||||
</div>
|
||||
@ -215,19 +266,38 @@
|
||||
<div class="chat-panel">
|
||||
<div class="chat-heading">
|
||||
<div><strong>{currentSession?.title || "新对话"}</strong><small>WebUI 会话</small></div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => send({ type: "list_sessions", include_archived: false })}>↻</Tooltip.Trigger>
|
||||
<Tooltip.Portal><Tooltip.Content class="tooltip" sideOffset={7}>刷新会话<Tooltip.Arrow class="tooltip-arrow" /></Tooltip.Content></Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<div class="chat-heading-actions">
|
||||
{#if currentPlan}
|
||||
<button class:active={todoOpen} class="todo-toggle" onclick={() => todoOpen = !todoOpen} aria-expanded={todoOpen}>
|
||||
任务 {completedItems}/{currentPlan.items.length}
|
||||
</button>
|
||||
{/if}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => { send({ type: "list_sessions", include_archived: false }); if (currentId) send({ type: "get_session_plan", session_id: currentId }); }}>↻</Tooltip.Trigger>
|
||||
<Tooltip.Portal><Tooltip.Content class="tooltip" sideOffset={7}>刷新会话<Tooltip.Arrow class="tooltip-arrow" /></Tooltip.Content></Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" bind:this={messageBox}>
|
||||
{#if messages.length === 0}
|
||||
<div class="empty"><div class="empty-logo">P</div><h2>今天想做些什么?</h2><p>消息与 CLI 客户端使用同一套会话、记忆和工具能力。</p></div>
|
||||
{/if}
|
||||
{#each messages as message (message.id)}
|
||||
<div class:user={message.role === "user"} class:assistant={message.role !== "user"} class="message">
|
||||
<div class="avatar">{message.role === "user" ? "你" : "P"}</div><div class="bubble">{message.content}</div>
|
||||
</div>
|
||||
{#if message.role !== "tool" && (message.content || message.tool_calls?.length)}
|
||||
<div class:user={message.role === "user"} class:assistant={message.role !== "user"} class:has-tools={message.tool_calls?.length} class="message">
|
||||
<div class="avatar">{message.role === "user" ? "你" : "P"}</div>
|
||||
<div class="message-content">
|
||||
{#if message.content}<div class="bubble"><Markdown content={message.content} /></div>{/if}
|
||||
{#if message.tool_calls?.length}
|
||||
<div class="tool-calls">
|
||||
{#each message.tool_calls as call (call.id)}
|
||||
<ToolCallCard {call} result={toolResult(call.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if thinking}<div class="message assistant typing"><div class="avatar">P</div><div class="bubble"><span class="pulse"></span>正在思考…</div></div>{/if}
|
||||
</div>
|
||||
@ -267,4 +337,25 @@
|
||||
<small><span class:online={connected}>{connected ? "已连接" : "已断开,正在重连"}</span><span>/ 打开命令 · Shift+Enter 换行</span></small>
|
||||
</form>
|
||||
</div>
|
||||
{#if currentPlan}
|
||||
<aside class="todo-panel" aria-label="当前任务计划">
|
||||
<div class="todo-heading">
|
||||
<div><small>当前计划</small><strong>{currentPlan.objective}</strong></div>
|
||||
<button class="icon-button" aria-label="关闭任务侧栏" onclick={() => todoOpen = false}>×</button>
|
||||
</div>
|
||||
<div class="todo-progress"><span style={`width:${currentPlan.items.length ? completedItems / currentPlan.items.length * 100 : 0}%`}></span></div>
|
||||
<div class="todo-summary"><span>{completedItems}/{currentPlan.items.length} 已完成</span><span>v{currentPlan.version}</span></div>
|
||||
<div class="todo-items">
|
||||
{#each currentPlan.items as item (item.id)}
|
||||
<article class:blocked={item.status === "blocked"} class:running={item.status === "in_progress"} class:done={item.status === "completed"} class="todo-item">
|
||||
<span class="todo-icon">{itemIcon(item.status)}</span>
|
||||
<div><strong>{item.id} · {item.title}</strong><small>{item.executor_kind === "sub_agent" ? "子 Agent" : item.status === "in_progress" ? "主 Agent" : item.status}</small>
|
||||
{#if item.result_summary}<p>{item.result_summary}</p>{/if}
|
||||
{#if item.error}<p class="error-text">{item.error}</p>{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@ -1,52 +1,98 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
color: #edf1f5;
|
||||
background: #0b0d10;
|
||||
color: #e8edf2;
|
||||
background: #0d1117;
|
||||
font-synthesis: none;
|
||||
--bg: #0b0d10;
|
||||
--panel: #111419;
|
||||
--panel-2: #171b21;
|
||||
--line: #242a32;
|
||||
--muted: #89919d;
|
||||
--text: #edf1f5;
|
||||
--accent: #c8ff52;
|
||||
--danger: #ff6b6b;
|
||||
--radius: 14px;
|
||||
--bg: #0d1117;
|
||||
--panel: #151a21;
|
||||
--panel-2: #1b222c;
|
||||
--sidebar: #10151c;
|
||||
--header: rgb(13 17 23 / 82%);
|
||||
--line: #28313d;
|
||||
--muted: #8b96a5;
|
||||
--text: #e8edf2;
|
||||
--text-soft: #c2cad4;
|
||||
--accent: #8b72ff;
|
||||
--accent-hover: #9e89ff;
|
||||
--accent-contrast: #ffffff;
|
||||
--accent-soft: #231f3d;
|
||||
--accent-border: #4d4089;
|
||||
--danger: #ff7b86;
|
||||
--danger-soft: #321d23;
|
||||
--warning: #f1c75b;
|
||||
--warning-soft: #302a18;
|
||||
--success-soft: #1d3029;
|
||||
--overlay: #1d2530;
|
||||
--code-bg: #0b0f14;
|
||||
--user-bubble: #26213f;
|
||||
--shadow: 0 16px 45px rgb(0 0 0 / 35%);
|
||||
--radius: 13px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color: #1c2430;
|
||||
background: #f6f7f9;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f1f3f6;
|
||||
--sidebar: #fbfbfc;
|
||||
--header: rgb(246 247 249 / 86%);
|
||||
--line: #dde2e8;
|
||||
--muted: #687386;
|
||||
--text: #1c2430;
|
||||
--text-soft: #4f5b6b;
|
||||
--accent: #6748e8;
|
||||
--accent-hover: #5739d4;
|
||||
--accent-contrast: #ffffff;
|
||||
--accent-soft: #eeeaff;
|
||||
--accent-border: #c9befd;
|
||||
--danger: #d94354;
|
||||
--danger-soft: #fff0f2;
|
||||
--warning: #9b6b00;
|
||||
--warning-soft: #fff7df;
|
||||
--success-soft: #e8f7ef;
|
||||
--overlay: #ffffff;
|
||||
--code-bg: #f4f5f7;
|
||||
--user-bubble: #eeeaff;
|
||||
--shadow: 0 16px 45px rgb(31 41 55 / 12%);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; background: radial-gradient(circle at 85% -20%, #273219 0, transparent 32%), var(--bg); }
|
||||
body { margin: 0; min-width: 320px; color: var(--text); background: var(--bg); transition: color .2s, background .2s; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.shell { height: 100vh; display: grid; grid-template-columns: 232px 1fr; }
|
||||
.sidebar { border-right: 1px solid var(--line); background: #0d1014; display: flex; flex-direction: column; padding: 20px 14px; }
|
||||
.sidebar { border-right: 1px solid var(--line); background: var(--sidebar); display: flex; flex-direction: column; padding: 20px 14px; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; padding: 2px 8px 26px; }
|
||||
.brand-mark, .empty-logo { display: grid; place-items: center; background: var(--accent); color: #10130b; font-weight: 900; border-radius: 10px; }
|
||||
.brand-mark, .empty-logo { display: grid; place-items: center; background: var(--accent); color: var(--accent-contrast); font-weight: 900; border-radius: 10px; }
|
||||
.brand-mark { width: 35px; height: 35px; }
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand small { color: var(--muted); font-size: 11px; margin-top: 2px; }
|
||||
.sidebar nav { display: grid; gap: 5px; }
|
||||
.sidebar nav button, .settings-nav button { border: 0; background: transparent; color: var(--muted); text-align: left; padding: 11px 13px; border-radius: 9px; cursor: pointer; }
|
||||
.sidebar nav button span { display: inline-block; width: 25px; }
|
||||
.sidebar nav button:hover, .sidebar nav button.active, .settings-nav button:hover, .settings-nav button[data-state="active"] { background: #1b201d; color: var(--text); }
|
||||
.sidebar nav button:hover, .sidebar nav button.active, .settings-nav button:hover, .settings-nav button[data-state="active"] { background: var(--accent-soft); color: var(--text); }
|
||||
.sidebar nav button.active { color: var(--accent); }
|
||||
.gateway-status { margin-top: auto; border-top: 1px solid var(--line); padding: 18px 8px 2px; display: flex; gap: 10px; align-items: center; }
|
||||
.gateway-status i { width: 9px; height: 9px; border-radius: 50%; background: #d8a444; box-shadow: 0 0 12px currentColor; }
|
||||
.gateway-status i.online { background: var(--accent); }
|
||||
.gateway-status i { width: 9px; height: 9px; border-radius: 50%; color: var(--warning); background: currentColor; box-shadow: 0 0 12px currentColor; }
|
||||
.gateway-status i.online { color: #48b985; background: currentColor; }
|
||||
.gateway-status b, .gateway-status small { display: block; font-size: 12px; }
|
||||
.gateway-status small { color: var(--muted); margin-top: 2px; }
|
||||
main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
||||
.topbar { height: 76px; flex: 0 0 76px; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 26px; background: rgb(11 13 16 / 76%); backdrop-filter: blur(16px); }
|
||||
.topbar { height: 76px; flex: 0 0 76px; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 26px; background: var(--header); backdrop-filter: blur(16px); }
|
||||
.topbar h1 { font-size: 18px; margin: 0; }
|
||||
.topbar p { font-size: 12px; color: var(--muted); margin: 4px 0 0; }
|
||||
.topbar .menu { display: none; background: none; border: 0; color: var(--text); font-size: 20px; margin-right: 12px; }
|
||||
.theme-toggle { margin-left: auto; display: flex; align-items: center; gap: 7px; height: 34px; padding: 0 11px; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); background: var(--panel); cursor: pointer; font-size: 12px; }
|
||||
.theme-toggle:hover { color: var(--text); border-color: var(--accent-border); }
|
||||
.page { min-height: 0; flex: 1; }
|
||||
.chat-layout { display: grid; grid-template-columns: 270px 1fr; }
|
||||
.sessions-panel { border-right: 1px solid var(--line); padding: 16px; background: rgb(14 17 21 / 66%); overflow: auto; }
|
||||
.chat-layout { display: grid; grid-template-columns: 270px minmax(0, 1fr); }
|
||||
.chat-layout.todo-open { grid-template-columns: 270px minmax(0, 1fr) 320px; }
|
||||
.sessions-panel { border-right: 1px solid var(--line); padding: 16px; background: color-mix(in srgb, var(--sidebar) 72%, transparent); overflow: auto; }
|
||||
.primary, .secondary { border-radius: 9px; padding: 9px 14px; font-weight: 650; cursor: pointer; }
|
||||
.primary { border: 1px solid var(--accent); background: var(--accent); color: #11160a; }
|
||||
.primary:hover { background: #ddff91; }
|
||||
.primary { border: 1px solid var(--accent); background: var(--accent); color: var(--accent-contrast); }
|
||||
.primary:hover { border-color: var(--accent-hover); background: var(--accent-hover); }
|
||||
.secondary { border: 1px solid var(--line); background: var(--panel-2); color: var(--text); }
|
||||
.full { width: 100%; padding: 11px; }
|
||||
.search { height: 39px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); display: flex; align-items: center; padding: 0 11px; color: var(--muted); }
|
||||
@ -57,15 +103,19 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
||||
.session-item:hover, .session-item.active { background: var(--panel-2); border-color: var(--line); }
|
||||
.session-item.active { border-left-color: var(--accent); }
|
||||
.session-item strong { font-size: 13px; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.plan-unread { display: inline-block; width: 7px; height: 7px; margin-left: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 9px var(--accent); }
|
||||
.session-item small { color: var(--muted); font-size: 11px; display: flex; justify-content: space-between; margin-top: 6px; }
|
||||
.chat-panel { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.chat-heading { height: 57px; flex: 0 0 57px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; padding: 0 20px; }
|
||||
.chat-heading strong, .chat-heading small { display: block; }
|
||||
.chat-heading strong { font-size: 13px; }
|
||||
.chat-heading-actions { display: flex; align-items: center; gap: 9px; }
|
||||
.todo-toggle { border: 1px solid var(--line); border-radius: 8px; padding: 6px 9px; background: var(--panel-2); color: var(--muted); font-size: 11px; cursor: pointer; }
|
||||
.todo-toggle.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.chat-heading small { font-size: 10px; color: var(--muted); margin-top: 3px; }
|
||||
.icon-button { border: 0; background: none; color: var(--muted); font-size: 20px; cursor: pointer; border-radius: 6px; }
|
||||
.tooltip { z-index: 50; border: 1px solid #3c4652; background: #222831; color: var(--text); padding: 6px 9px; border-radius: 7px; font-size: 11px; box-shadow: 0 8px 24px #0008; }
|
||||
.tooltip-arrow { fill: #222831; }
|
||||
.tooltip { z-index: 50; border: 1px solid var(--line); background: var(--overlay); color: var(--text); padding: 6px 9px; border-radius: 7px; font-size: 11px; box-shadow: var(--shadow); }
|
||||
.tooltip-arrow { fill: var(--overlay); }
|
||||
.messages { flex: 1; min-height: 0; overflow-y: auto; padding: 26px max(24px, calc((100% - 850px) / 2)) 18px; }
|
||||
.empty { text-align: center; color: var(--muted); padding-top: 12vh; }
|
||||
.empty-logo { width: 54px; height: 54px; margin: auto; font-size: 24px; }
|
||||
@ -74,24 +124,85 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
||||
.message { display: flex; gap: 12px; margin: 15px 0; align-items: flex-start; }
|
||||
.message.user { flex-direction: row-reverse; }
|
||||
.avatar { width: 28px; height: 28px; flex: 0 0 28px; border: 1px solid var(--line); border-radius: 8px; display: grid; place-items: center; font-size: 11px; background: var(--panel-2); }
|
||||
.message.assistant .avatar { background: var(--accent); color: #11160a; border-color: var(--accent); font-weight: bold; }
|
||||
.bubble { max-width: min(78%, 720px); padding: 11px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); font-size: 14px; line-height: 1.62; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.message.user .bubble { background: #20271a; border-color: #3a4726; }
|
||||
.message.assistant .avatar { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); font-weight: bold; }
|
||||
.message-content { display: grid; gap: 8px; max-width: min(82%, 760px); min-width: 0; }
|
||||
.message.user .message-content { justify-items: end; }
|
||||
.bubble { max-width: 100%; padding: 11px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); font-size: 14px; line-height: 1.62; overflow-wrap: anywhere; }
|
||||
.message.user .bubble { background: var(--user-bubble); border-color: var(--accent-border); }
|
||||
.typing .bubble { color: var(--muted); }
|
||||
.pulse { display: inline-block; width: 6px; height: 6px; margin-right: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.1s infinite; }
|
||||
@keyframes pulse { 50% { opacity: .25; transform: scale(.8); } }
|
||||
.composer { position: relative; margin: 0 max(18px, calc((100% - 850px) / 2)) 18px; border: 1px solid #343d47; background: var(--panel-2); border-radius: 14px; padding: 10px 11px 6px; display: grid; grid-template-columns: 1fr 38px; box-shadow: 0 10px 35px #0006; }
|
||||
.markdown-body { min-width: 0; }
|
||||
.markdown-body > :first-child { margin-top: 0; }
|
||||
.markdown-body > :last-child { margin-bottom: 0; }
|
||||
.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body pre, .markdown-body table { margin: .65em 0; }
|
||||
.markdown-body ul, .markdown-body ol { padding-left: 1.4em; }
|
||||
.markdown-body li + li { margin-top: .2em; }
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { margin: 1em 0 .45em; line-height: 1.3; }
|
||||
.markdown-body h1 { font-size: 1.45em; }
|
||||
.markdown-body h2 { font-size: 1.25em; }
|
||||
.markdown-body h3 { font-size: 1.1em; }
|
||||
.markdown-body a { color: var(--accent); text-underline-offset: 2px; }
|
||||
.markdown-body blockquote { padding-left: 12px; border-left: 3px solid var(--accent-border); color: var(--muted); }
|
||||
.markdown-body code { padding: .14em .35em; border: 1px solid var(--line); border-radius: 5px; color: var(--text); background: var(--code-bg); font: .88em/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.markdown-body pre { max-width: 100%; padding: 12px 13px; overflow-x: auto; border: 1px solid var(--line); border-radius: 9px; background: var(--code-bg); }
|
||||
.markdown-body pre code { padding: 0; border: 0; background: transparent; }
|
||||
.markdown-body table { display: block; max-width: 100%; border-collapse: collapse; overflow-x: auto; }
|
||||
.markdown-body th, .markdown-body td { padding: 7px 10px; border: 1px solid var(--line); text-align: left; }
|
||||
.markdown-body th { background: var(--panel-2); }
|
||||
.markdown-body hr { border: 0; border-top: 1px solid var(--line); }
|
||||
.markdown-body img { max-width: 100%; border-radius: 9px; }
|
||||
.tool-calls { display: grid; gap: 7px; width: min(100%, 680px); }
|
||||
.tool-call { overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); }
|
||||
.tool-call.open { border-color: var(--accent-border); }
|
||||
.tool-call-summary { width: 100%; min-height: 48px; display: grid; grid-template-columns: 30px 1fr auto 18px; align-items: center; gap: 9px; padding: 7px 11px; border: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
|
||||
.tool-call-summary:hover { background: var(--panel-2); }
|
||||
.tool-call-icon { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; color: var(--accent); background: var(--accent-soft); font-size: 13px; }
|
||||
.tool-call-title small, .tool-call-title strong { display: block; }
|
||||
.tool-call-title small { margin-bottom: 1px; color: var(--muted); font-size: 9px; font-weight: 500; }
|
||||
.tool-call-title strong { font: 600 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.tool-call-status { color: var(--muted); font-size: 10px; }
|
||||
.tool-call-chevron { color: var(--muted); transition: transform .18s; }
|
||||
.tool-call.open .tool-call-chevron { transform: rotate(180deg); }
|
||||
.tool-call-details { display: grid; gap: 12px; padding: 12px; border-top: 1px solid var(--line); background: color-mix(in srgb, var(--panel-2) 55%, transparent); }
|
||||
.tool-call-details > div > span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.tool-call-details pre, .tool-result { max-height: 300px; margin: 0; padding: 10px 11px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font: 11px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.tool-result .markdown-body { font: inherit; }
|
||||
.composer { position: relative; margin: 0 max(18px, calc((100% - 850px) / 2)) 18px; border: 1px solid var(--line); background: var(--panel); border-radius: 14px; padding: 10px 11px 6px; display: grid; grid-template-columns: 1fr 38px; box-shadow: var(--shadow); }
|
||||
.composer textarea { resize: none; max-height: 180px; background: transparent; border: 0; outline: 0; color: var(--text); padding: 7px; line-height: 1.5; }
|
||||
.send { width: 36px; height: 36px; border-radius: 10px; background: var(--accent); border: 0; font-size: 19px; cursor: pointer; }
|
||||
.send { width: 36px; height: 36px; border-radius: 10px; color: var(--accent-contrast); background: var(--accent); border: 0; font-size: 19px; cursor: pointer; }
|
||||
.composer small { grid-column: 1 / -1; display: flex; justify-content: space-between; padding: 3px 7px; color: var(--muted); font-size: 10px; }
|
||||
.composer small .online { color: var(--accent); }
|
||||
.command-menu { position: absolute; z-index: 5; left: 0; right: 0; bottom: calc(100% + 8px); max-height: min(360px, 48vh); overflow-y: auto; padding: 6px; border: 1px solid #3b454f; border-radius: 13px; background: #14181d; box-shadow: 0 18px 50px #000b; }
|
||||
.command-menu-heading { position: sticky; top: -6px; z-index: 1; display: flex; justify-content: space-between; gap: 16px; padding: 9px 10px 8px; color: var(--muted); background: #14181df2; font-size: 10px; }
|
||||
.command-menu-heading kbd { color: #89919a; font: inherit; }
|
||||
.todo-panel { display: none; min-width: 0; border-left: 1px solid var(--line); background: var(--sidebar); overflow: hidden; }
|
||||
.todo-open .todo-panel { display: flex; flex-direction: column; }
|
||||
.todo-heading { min-height: 78px; padding: 14px 14px 12px 16px; border-bottom: 1px solid var(--line); display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.todo-heading small, .todo-heading strong { display: block; }
|
||||
.todo-heading small { color: var(--muted); font-size: 10px; margin-bottom: 5px; }
|
||||
.todo-heading strong { font-size: 13px; line-height: 1.4; }
|
||||
.todo-progress { height: 3px; background: var(--panel-2); }
|
||||
.todo-progress span { display: block; height: 100%; background: var(--accent); transition: width .2s; }
|
||||
.todo-summary { display: flex; justify-content: space-between; padding: 11px 16px; color: var(--muted); font-size: 10px; }
|
||||
.todo-items { min-height: 0; overflow-y: auto; padding: 4px 12px 18px; }
|
||||
.todo-item { display: grid; grid-template-columns: 24px 1fr; gap: 8px; padding: 10px 8px; border-radius: 9px; color: var(--text-soft); }
|
||||
.todo-item + .todo-item { margin-top: 3px; }
|
||||
.todo-item.running { background: var(--accent-soft); color: var(--text); }
|
||||
.todo-item.blocked { background: var(--danger-soft); }
|
||||
.todo-item.done { color: var(--muted); }
|
||||
.todo-icon { width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; font-size: 11px; }
|
||||
.todo-item.running .todo-icon { color: var(--accent); border-color: var(--accent-border); }
|
||||
.todo-item.blocked .todo-icon { color: var(--danger); border-color: var(--danger); }
|
||||
.todo-item.done .todo-icon { color: var(--accent); }
|
||||
.todo-item strong, .todo-item small { display: block; }
|
||||
.todo-item strong { font-size: 12px; line-height: 1.45; }
|
||||
.todo-item small { color: var(--muted); font-size: 10px; margin-top: 3px; }
|
||||
.todo-item p { margin: 6px 0 0; color: var(--text-soft); font-size: 10px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.command-menu { position: absolute; z-index: 5; left: 0; right: 0; bottom: calc(100% + 8px); max-height: min(360px, 48vh); overflow-y: auto; padding: 6px; border: 1px solid var(--line); border-radius: 13px; background: var(--overlay); box-shadow: var(--shadow); }
|
||||
.command-menu-heading { position: sticky; top: -6px; z-index: 1; display: flex; justify-content: space-between; gap: 16px; padding: 9px 10px 8px; color: var(--muted); background: var(--overlay); font-size: 10px; }
|
||||
.command-menu-heading kbd { color: var(--muted); font: inherit; }
|
||||
.command-menu button { display: grid; grid-template-columns: minmax(100px, auto) 1fr; gap: 14px; width: 100%; padding: 9px 10px; border: 0; border-radius: 8px; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
|
||||
.command-menu button:hover, .command-menu button.selected { background: #252d22; }
|
||||
.command-menu button:hover, .command-menu button.selected { background: var(--accent-soft); }
|
||||
.command-menu code { color: var(--accent); font: 600 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.command-menu button span { color: #b8bec5; font-size: 12px; line-height: 1.4; }
|
||||
.command-menu button span { color: var(--text-soft); font-size: 12px; line-height: 1.4; }
|
||||
.content-page { padding: 22px 26px; overflow: auto; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; gap: 10px; }
|
||||
.tabs { display: flex; background: var(--panel); padding: 3px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
@ -103,12 +214,12 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 15px 17px; }
|
||||
.card-row { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; }
|
||||
.card h3 { font-size: 14px; margin: 0 0 7px; }
|
||||
.card p { color: #c4c9d0; font-size: 12px; line-height: 1.55; margin: 5px 0; white-space: pre-wrap; }
|
||||
.card p { color: var(--text-soft); font-size: 12px; line-height: 1.55; margin: 5px 0; white-space: pre-wrap; }
|
||||
.meta { display: flex; gap: 14px; flex-wrap: wrap; color: var(--muted); font-size: 11px; }
|
||||
.badge { border-radius: 99px; padding: 4px 8px; font-size: 10px; background: #222831; color: #bac1cb; white-space: nowrap; }
|
||||
.badge.ok { background: #26331b; color: var(--accent); }
|
||||
.badge.fail { background: #351d20; color: #ff9a9a; }
|
||||
.badge.run { background: #302a18; color: #ffd66b; }
|
||||
.badge { border-radius: 99px; padding: 4px 8px; font-size: 10px; background: var(--panel-2); color: var(--text-soft); white-space: nowrap; }
|
||||
.badge.ok { background: var(--success-soft); color: #38a877; }
|
||||
.badge.fail { background: var(--danger-soft); color: var(--danger); }
|
||||
.badge.run { background: var(--warning-soft); color: var(--warning); }
|
||||
.details { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
|
||||
.run-row + .run-row { margin-top: 7px; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px; }
|
||||
@ -117,41 +228,44 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
||||
.metric small { color: var(--muted); }
|
||||
.memory-content { font-size: 13px !important; }
|
||||
.memory-key { color: var(--accent); font-family: ui-monospace, monospace; }
|
||||
.log-view { margin: 0; background: #090b0e; border: 1px solid var(--line); border-radius: 12px; padding: 16px; color: #b9c1ca; min-height: calc(100vh - 190px); font: 11px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow: auto; }
|
||||
.log-view { margin: 0; background: var(--code-bg); border: 1px solid var(--line); border-radius: 12px; padding: 16px; color: var(--text-soft); min-height: calc(100vh - 190px); font: 11px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow: auto; }
|
||||
.log-meta { color: var(--muted); font-size: 11px; margin: -8px 0 8px; }
|
||||
.switch-label { color: var(--muted); font-size: 12px; display: flex; gap: 7px; align-items: center; cursor: pointer; }
|
||||
.switch { width: 34px; height: 20px; padding: 2px; border: 1px solid var(--line); border-radius: 99px; background: var(--panel); cursor: pointer; }
|
||||
.switch[data-state="checked"] { background: var(--accent); border-color: var(--accent); }
|
||||
.switch-thumb { display: block; width: 14px; height: 14px; background: var(--muted); border-radius: 50%; transition: transform .15s, background .15s; }
|
||||
.switch-thumb[data-state="checked"] { transform: translateX(14px); background: #11160a; }
|
||||
.switch-thumb[data-state="checked"] { transform: translateX(14px); background: var(--accent-contrast); }
|
||||
.settings-grid { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 18px; }
|
||||
.settings-nav { display: flex; flex-direction: column; gap: 4px; }
|
||||
.editor-card { border: 1px solid var(--line); background: var(--panel); border-radius: var(--radius); overflow: hidden; }
|
||||
.editor-head { height: 62px; padding: 0 15px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--line); }
|
||||
.editor-head strong, .editor-head small { display: block; }
|
||||
.editor-head small { color: var(--muted); font-size: 10px; margin-top: 4px; }
|
||||
.editor-card textarea { display: block; width: 100%; height: calc(100vh - 275px); min-height: 420px; resize: vertical; border: 0; outline: 0; padding: 18px; background: #0a0c0f; color: #d5dae0; font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
|
||||
.editor-card textarea { display: block; width: 100%; height: calc(100vh - 275px); min-height: 420px; resize: vertical; border: 0; outline: 0; padding: 18px; background: var(--code-bg); color: var(--text); font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
|
||||
.notice { padding: 11px 15px; color: var(--muted); font-size: 11px; border-top: 1px solid var(--line); }
|
||||
code { color: var(--accent); }
|
||||
#toast { position: fixed; z-index: 100; right: 22px; bottom: 22px; background: #222831; border: 1px solid #3c4652; border-radius: 10px; padding: 11px 15px; font-size: 12px; opacity: 0; transform: translateY(8px); pointer-events: none; transition: .2s; }
|
||||
#toast { position: fixed; z-index: 100; right: 22px; bottom: 22px; color: var(--text); background: var(--overlay); border: 1px solid var(--line); border-radius: 10px; padding: 11px 15px; font-size: 12px; opacity: 0; transform: translateY(8px); pointer-events: none; transition: .2s; box-shadow: var(--shadow); }
|
||||
#toast.show { opacity: 1; transform: none; }
|
||||
#toast.error { border-color: #6b3030; color: #ffaaaa; }
|
||||
#toast.error { border-color: var(--danger); color: var(--danger); }
|
||||
.loading, .empty-card { text-align: center; color: var(--muted); padding: 50px; }
|
||||
.empty-card.compact { padding: 24px 8px; }
|
||||
.error-text { color: #ff9a9a !important; }
|
||||
.error-text { color: var(--danger) !important; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: fixed; z-index: 10; inset: 0 auto 0 0; width: 232px; transform: translateX(-100%); transition: .2s; box-shadow: 20px 0 50px #000; }
|
||||
.sidebar { position: fixed; z-index: 10; inset: 0 auto 0 0; width: 232px; transform: translateX(-100%); transition: .2s; box-shadow: var(--shadow); }
|
||||
.sidebar.open { transform: none; }
|
||||
.topbar .menu { display: block; }
|
||||
.chat-layout { grid-template-columns: 1fr; }
|
||||
.chat-layout.todo-open { grid-template-columns: 1fr; }
|
||||
.sessions-panel { display: none; }
|
||||
.todo-open .todo-panel { position: fixed; z-index: 20; top: 76px; right: 0; bottom: 0; width: min(340px, 92vw); box-shadow: var(--shadow); }
|
||||
.content-page { padding: 16px; }
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
.settings-nav { flex-direction: row; overflow: auto; }
|
||||
.metrics { grid-template-columns: 1fr; }
|
||||
.filters { align-items: stretch; flex-wrap: wrap; }
|
||||
.grow { max-width: none; width: 100%; flex-basis: 100%; }
|
||||
.bubble { max-width: 88%; }
|
||||
.message-content { max-width: 88%; }
|
||||
.theme-toggle span:last-child { display: none; }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user