初始化 graph_memory 分支

graph_memory
Bifang 2026-06-24 15:09:26 +08:00
commit 77e0732648
906 changed files with 109856 additions and 0 deletions

13
.env.example 100644
View File

@ -0,0 +1,13 @@
# OpenAI-compatible API 配置
# 适配任意兼容 /v1/chat/completions 且支持 tools/function calling 的服务。
OPENAI_API_KEY=your-api-key-here
OPENAI_BASE_URL=https://api.openai.com/v1
MODEL_NAME=gpt-4.1-mini
# 可选配置
OPENAI_TIMEOUT=120
OPENAI_TEMPERATURE=0.2
CORE_AGENT_MAX_ITERATIONS=12
USER_NAME=User
AGENT_NAME=MeetingAgent

3
.gitignore vendored 100644
View File

@ -0,0 +1,3 @@
.env
__pycache__/
data/

4
.vscode/settings.json vendored 100644
View File

@ -0,0 +1,4 @@
{
"python-envs.defaultEnvManager": "ms-python.python:conda",
"python-envs.defaultPackageManager": "ms-python.python:conda"
}

View File

@ -0,0 +1,17 @@
{
"schema_version": 1,
"pinned_keys": [],
"archived_keys": [],
"title_overrides": {},
"project_name_overrides": {},
"tags_by_key": {},
"collapsed_groups": {},
"view": {
"density": "comfortable",
"show_previews": false,
"show_timestamps": false,
"show_archived": false,
"sort": "updated_desc"
},
"updated_at": "2026-06-24T11:01:42"
}

220
README.md 100644
View File

@ -0,0 +1,220 @@
# Meeting Knowledge Interactive Agent
这是一个以“大模型交互式 Agent”为主体的会议知识库项目。用户在 CLI 里自然语言对话Agent 自己决定什么时候调用工具;`导入会议转录` 和 `查询周会台账` 只是 Agent 可用的工具,不是项目主体入口。
工程结构按 `D:\github_project\my_code\my_agent` 的 Agent 骨架组织,方便后续扩展 provider、tools、skills、memory 和任务调度。
## 目录结构
```text
meeting_knowledge/
├─ agent.py # 顶层 Agent 装配入口
├─ main_cli.py # 交互式 CLI 主入口
├─ main.py # 兼容入口,转到 main_cli.py
├─ prompts.py # system prompt 组装
├─ dispatch.py # 任务调度扩展点
├─ core_agent/
│ ├─ config.py # .env / 环境变量配置
│ ├─ compression.py # 长上下文压缩
│ └─ session.py # 多轮会话与工具调用循环
├─ providers/
│ ├─ base.py # Provider 抽象
│ ├─ openai_compatible.py # OpenAI-compatible 模型适配
│ ├─ rule_based.py # 离线规则 Provider仅用于 --offline 演示
│ └─ prompt_loader.py
├─ tools/
│ ├─ registry.py # 工具注册中心
│ ├─ default_tools.py # 默认工具集合(通用工具 + 会议工具)
│ ├─ general_tools.py # 读写文件、目录浏览、shell、Python 等基础工具
│ ├─ meeting_tools.py # 会议导入/查询工具
│ └─ tool_trace.py # 工具调用轨迹本地存储
├─ meeting_memory/ # 会议台账业务层
│ ├─ meeting_digest_agent.py # 会议整理子 Agent负责把原文整理成结构化结果
│ ├─ ledger.py # Markdown/JSON 台账存储
│ └─ service.py # 会议知识库业务服务
├─ prompt/zh/base.yaml # 中文基础提示词
├─ .env.example # 大模型配置模板
├─ .env # 本地大模型配置,需填写真实值
├─ webui/ # Web 页面静态资源
├─ webui_server.py # FastAPI Web 服务入口
└─ requirements.txt
```
## 安装依赖
```powershell
pip install -r requirements.txt
```
## 配置大模型
本项目适配 OpenAI-compatible API也就是兼容 `/v1/chat/completions` 的接口,并要求模型支持 tools/function calling。
复制或直接编辑 `.env`
```text
OPENAI_API_KEY=你的key
OPENAI_BASE_URL=https://你的服务地址/v1
MODEL_NAME=你的模型名
OPENAI_TIMEOUT=120
OPENAI_TEMPERATURE=0.2
CORE_AGENT_MAX_ITERATIONS=12
```
也兼容这些别名:
```text
API_KEY -> OPENAI_API_KEY
BASE_URL -> OPENAI_BASE_URL
CORE_AGENT_MODEL / MODEL -> MODEL_NAME
```
## 运行交互式 Agent
默认运行会读取 `.env` 并调用你的 OpenAI-compatible 模型:
```powershell
python .\main_cli.py
```
进入后可以直接说:
```text
导入 D:\github_project\my_code\meeting_agent\examples\huiyi.txt 知识库 合川分公司
```
然后继续问:
```text
专线护航还有哪些待办?知识库 合川分公司
```
## 运行 Web UI
当前 Web 前端源码已经内置在本项目的 `frontend_nanobot/` 中,构建后的静态文件统一输出到 `webui/dist/`,由 `webui_server.py` 直接托管,不再依赖外部 `nanobot` 目录。
### 1. 安装前端依赖
```powershell
cd .\frontend_nanobot
npm install
```
### 2. 构建前端
```powershell
npm run build
```
构建产物会输出到:
```text
.\webui\dist
```
### 3. 启动 Web 网关
```powershell
cd ..
python .\webui_server.py
```
默认地址:
```text
http://127.0.0.1:8010
```
如果只想离线演示整条链路,不调用大模型:
```powershell
python .\webui_server.py --offline
```
## 离线演示模式
只有想验证工具链、不调用大模型时,才使用:
```powershell
python .\main_cli.py --offline
```
单条离线导入测试:
```powershell
python .\main_cli.py --offline --once "导入 D:\github_project\my_code\meeting_agent\examples\huiyi.txt 知识库 合川分公司"
```
## 知识库选择约定
当前只是演示 demo不做团队隔离。`data/` 下每个子文件夹视作一个独立知识库,例如:
```text
data/
├─ 合川分公司/
└─ 另一个知识库/
```
未来 Web 界面可以列出这些文件夹,让用户选择一个知识库后,把文件夹名作为 `knowledge_base_id` 传给工具进行针对性提问。`team_id` 只是旧兼容参数,后续推荐使用 `knowledge_base_id`
在当前这版 Web 里,系统会把每个知识库自动映射成一个可进入的会话入口;选中对应会话后提问,就会优先基于该知识库回答。
## Agent 工具
- `current_time`:获取当前时间。
- `list_directory`:浏览工作区目录。
- `search_files`:按文件名搜索工作区文件。
- `read_file`:读取工作区内文本文件。
- `write_file`:写入工作区内文本文件。
- `execute_shell`:执行 shell 命令做环境检查或调试。
- `run_python`:执行简短 Python 代码做快速处理。
- `tool_trace_query`:按需查询本地保存的工具调用轨迹。
- `import_meeting_transcript`:导入会议原文,交给会议整理子 Agent 做项目化整理,并更新指定知识库的 Markdown 台账。
- `query_knowledge`:查询指定知识库周会台账。
- `list_knowledge_bases`:列出 `data/` 下已有知识库。
LLM Agent 会在对话中自动选择这些工具。会议整理本身由 `prompt/zh/meeting_digest.yaml` 驱动的子 Agent 完成,主 Agent 只负责调工具和查询台账。
## 多轮会话机制
当前多轮会话参考了 `D:\github_project\my_code\my_agent` 的实现思路:
- 每个会话保留最近 `20` 轮用户/助手对话并本地落盘。
- 超出窗口后,会通过 `core_agent/compression.py` 做滚动摘要压缩,而不是无限堆上下文。
- 工具调用的参数、思考、结果会单独保存到本地 `tool_trace.json`
- 默认不会把大量工具过程长期塞回主上下文;需要时由 Agent 调用 `tool_trace_query` 再查。
CLI 默认使用会话 `cli_default`。Web 侧每个聊天或知识库会映射到独立的本地会话目录。
## 输出数据
默认写入:
```text
data/
└─ 合川分公司/ # 一个知识库示例
├─ state.json
├─ team_ledger.md
└─ meetings/
```
`team_ledger.md` 是面向人和 Agent 共同读取的主文档;`state.json` 和 `meetings/*.json` 是可复用的结构化中间结果。
当前整理逻辑是:
- 每个知识库只维护一份 `team_ledger.md` 主台账。
- 每次导入只处理一份会议原文。
- 会议原文会先交给会议整理子 Agent按“项目”抽取重要信息再更新同一份 Markdown。
- 如果本次会议提到历史项目,就更新已有项目状态;如果出现新项目,就新增项目条目。
多轮会话和工具轨迹默认写入:
```text
agent_memory/
└─ sessions/
└─ <session_id>/
├─ recent_history.json
└─ tool_trace.json
```

50
agent.py 100644
View File

@ -0,0 +1,50 @@
from __future__ import annotations
from pathlib import Path
from typing import List, Optional
from core_agent.session import AgentRunResult, ConversationSession
from providers.base import AgentProvider
from tools.registry import ToolRegistry
class CoreAgent:
"""Top-level assembly layer, aligned with the referenced my_agent project."""
def __init__(
self,
*,
provider: AgentProvider,
workspace: str | Path,
skill_dirs: Optional[List[str | Path]] = None,
tool_registry: Optional[ToolRegistry] = None,
system_prompt: Optional[str] = None,
max_iterations: int = 12,
) -> None:
self.provider = provider
self.workspace = Path(workspace).resolve()
self.skill_dirs = list(skill_dirs or [self.workspace / "skills"])
self.tool_registry = tool_registry
self.system_prompt = system_prompt
self.max_iterations = max_iterations
def run(self, user_message: str, *, active_skills: Optional[List[str]] = None) -> AgentRunResult:
return self.new_session(active_skills=active_skills).ask(user_message)
def new_session(self, *, active_skills: Optional[List[str]] = None, session_id: str = "default") -> ConversationSession:
return ConversationSession(
provider=self.provider,
workspace=self.workspace,
session_id=session_id,
skill_dirs=self.skill_dirs,
tool_registry=self.tool_registry,
system_prompt=self.system_prompt,
max_iterations=self.max_iterations,
active_skills=active_skills,
)
if __name__ == "__main__":
from main_cli import main
main()

View File

@ -0,0 +1,14 @@
{
"session_id": "chat_2b0479c17171",
"messages": [
{
"role": "user",
"content": "这个知识库想帮我清空一下内容"
},
{
"role": "assistant",
"content": "thought\n“会议测试”知识库的内容已成功清空\n\n1. **项目台账** (`team_ledger.md`):已重置为初始状态。\n2. **会议记录** (`meetings/` 目录):已清空。\n3. **状态文件** (`state.json`):已重置为空对象。\n\n现在该知识库已处于干净的状态可以开始导入新的会议内容。"
}
],
"max_persisted_turns": 20
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,14 @@
{
"session_id": "chat_380095ce5521",
"messages": [
{
"role": "user",
"content": "为啥我的知识库没了?你检查一下呢"
},
{
"role": "assistant",
"content": "非常抱歉,我深入检查后发现:\n\n虽然 `data/` 目录下确实存在一个名为 **“会议测试”** 的文件夹,但这个文件夹目前是**空的**(里面没有任何文件,也没有项目台账 Markdown。\n\n**目前的结论是:**\n1. **文件夹结构还在**:系统识别到了“会议测试”这个库。\n2. **内容确实丢失了**:该库下没有存放任何会议记录或项目台账。\n\n**建议下一步操作:**\n* 如果您之前导入过会议请检查一下原始的转录文件ASR文本是否还在您的本地电脑上如果有您可以**重新调用导入功能**,我会重新为您生成并维护台账。\n* 如果您记得之前操作过其他知识库,请告诉我名字,我再帮您全局搜索一下。"
}
],
"max_persisted_turns": 20
}

View File

@ -0,0 +1,61 @@
{
"max_turns": 20,
"next_turn_id": 3,
"active_turn": {
"turn_id": 2,
"started_at": "2026-06-24T11:59:08.318523",
"completed_at": "",
"user_message": "帮我把这个会议添加进去\n\n附加文本文件已保存可直接读取或导入\n- transcript_704.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_380095ce5521\\1782273548315_1_transcript_704.md",
"assistant_final": "",
"tool_calls": []
},
"turns": [
{
"turn_id": 1,
"started_at": "2026-06-24T11:58:16.700065",
"completed_at": "2026-06-24T11:58:42.343354",
"user_message": "为啥我的知识库没了?你检查一下呢",
"assistant_final": "非常抱歉,我深入检查后发现:\n\n虽然 `data/` 目录下确实存在一个名为 **“会议测试”** 的文件夹,但这个文件夹目前是**空的**(里面没有任何文件,也没有项目台账 Markdown。\n\n**目前的结论是:**\n1. **文件夹结构还在**:系统识别到了“会议测试”这个库。\n2. **内容确实丢失了**:该库下没有存放任何会议记录或项目台账。\n\n**建议下一步操作:**\n* 如果您之前导入过会议请检查一下原始的转录文件ASR文本是否还在您的本地电脑上如果有您可以**重新调用导入功能**,我会重新为您生成并维护台账。\n* 如果您记得之前操作过其他知识库,请告诉我名字,我再帮您全局搜索一下。",
"tool_calls": [
{
"tool_name": "list_knowledge_bases",
"arguments": {},
"thought": "The user is asking \"Why is my knowledge base gone? Can you check it?\"\nThis implies the user expects to see knowledge bases that were previously there, but they are currently missing or they can't find them.\n\nTo investigate this, I should:\n1. List the available knowledge bases using `list_knowledge_bases`.\n2. Check the `data/` directory directly using `list_directory` to see if there are any folders that `list_knowledge_bases` might have missed or if the directory structure is empty.\n\nLet's start by calling `list_knowledge_bases`.",
"result": "{\n \"knowledge_bases\": [],\n \"count\": 0\n}",
"tool_call_id": "chatcmpl-tool-9d0e8831de348787",
"created_at": "2026-06-24T11:58:22.291269"
},
{
"tool_name": "list_directory",
"arguments": {
"path": "data"
},
"thought": "",
"result": "{\n \"success\": true,\n \"path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\",\n \"entries\": [\n {\n \"name\": \"会议测试\",\n \"path\": \"data\\\\会议测试\",\n \"type\": \"directory\",\n \"size\": null,\n \"modified_at\": \"2026-06-24T11:57:20\"\n }\n ]\n}",
"tool_call_id": "chatcmpl-tool-82afe91230ca6ba5",
"created_at": "2026-06-24T11:58:23.952764"
},
{
"tool_name": "list_directory",
"arguments": {
"path": "data/会议测试"
},
"thought": "The user is asking why their knowledge base is gone.\nI previously called `list_knowledge_bases` which returned an empty list.\nThen I called `list_directory` on `data/` and found one directory: `data\\会议测试`.\nWait, `list_knowledge_bases` returned `{\"knowledge_bases\": [], \"count\": 0}` but `list_directory` on `data/` returned `{\"success\": true, \"path\": \"...\", \"entries\": [{\"name\": \"会议测试\", ...}]}`.\n\nThis is a discrepancy. `list_knowledge_bases` should ideally return what's in `data/`.\nLet's check why `list_knowledge_bases` might have returned empty.\nMaybe `list_knowledge_bases` implementation looks for specific patterns or is bugged, or maybe the environment is different.\nActually, looking at the tool output, `list_knowledge_bases` returned `[]`.\nBut `list_directory` on `data` shows `会议测试`.\n\nI should explain to the user that I found a directory named `会议测试` in the `data` folder, which might be their knowledge base, even though the \"list_knowledge_bases\" command initially didn't show it.\n\nWait, I should verify if `会议测试` is actually a valid knowledge base by trying to query it or looking inside.\nLet's try `query_knowledge` with `knowledge_base_id=\"会议测试\"` to see if it works. Or just list files inside `data/会议测试`.\n\nFirst, I will inform the user what I found.",
"result": "{\n \"success\": true,\n \"path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\会议测试\",\n \"entries\": []\n}",
"tool_call_id": "chatcmpl-tool-b0fa76520ddbddb5",
"created_at": "2026-06-24T11:58:36.450296"
},
{
"tool_name": "list_directory",
"arguments": {
"path": "data/会议测试"
},
"thought": "",
"result": "{\n \"success\": true,\n \"path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\会议测试\",\n \"entries\": []\n}",
"tool_call_id": "chatcmpl-tool-a90dc4853426e28f",
"created_at": "2026-06-24T11:58:37.155216"
}
]
}
]
}

View File

@ -0,0 +1,14 @@
{
"session_id": "chat_4010afc8b71d",
"messages": [
{
"role": "user",
"content": "帮我将这个会议整理到知识库里\n\n附加文本文件已保存可直接读取或导入\n- transcript_704.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_4010afc8b71d\\1782279966209_1_transcript_704.md"
},
{
"role": "assistant",
"content": "I 检查了当前的知识库列表,发现目前还没有建立任何知识库。\n\n为了能顺利将这次会议内容整理进去请告诉我\n1. 你想为这个会议创建一个什么样的**知识库名称**(例如:`研发项目组` 或 `市场部`\n2. 或者,如果你已经有现成的知识库,请告知我它的名称。\n\n一旦确定了知识库名称我就会立即调用导入工具将 `transcript_704.md` 的内容整理到该知识库的项目台账中。"
}
],
"max_persisted_turns": 20
}

View File

@ -0,0 +1,31 @@
{
"max_turns": 20,
"next_turn_id": 3,
"active_turn": {
"turn_id": 2,
"started_at": "2026-06-24T13:46:34.094819",
"completed_at": "",
"user_message": "会议测试",
"assistant_final": "",
"tool_calls": []
},
"turns": [
{
"turn_id": 1,
"started_at": "2026-06-24T13:46:06.228573",
"completed_at": "2026-06-24T13:46:25.086646",
"user_message": "帮我将这个会议整理到知识库里\n\n附加文本文件已保存可直接读取或导入\n- transcript_704.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_4010afc8b71d\\1782279966209_1_transcript_704.md",
"assistant_final": "I 检查了当前的知识库列表,发现目前还没有建立任何知识库。\n\n为了能顺利将这次会议内容整理进去请告诉我\n1. 你想为这个会议创建一个什么样的**知识库名称**(例如:`研发项目组` 或 `市场部`\n2. 或者,如果你已经有现成的知识库,请告知我它的名称。\n\n一旦确定了知识库名称我就会立即调用导入工具将 `transcript_704.md` 的内容整理到该知识库的项目台账中。",
"tool_calls": [
{
"tool_name": "list_knowledge_bases",
"arguments": {},
"thought": "The user wants to import a meeting transcript into a knowledge base.\nThe transcript file path is provided: `D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_4010afc8b71d\\1782279966209_1_transcript_704.md`.\nThe user hasn't specified a `knowledge_base_id`.\nAccording to the system instructions, I should first check if there are any existing knowledge bases.\nIf there is only one knowledge base, the tool `import_meeting_transcript` will automatically use it.\nIf there are multiple, I should ask the user to specify which one.\n\nFirst step: List knowledge bases to see what's available.",
"result": "{\n \"knowledge_bases\": [],\n \"count\": 0\n}",
"tool_call_id": "chatcmpl-tool-a58495f5f3ab062e",
"created_at": "2026-06-24T13:46:12.611470"
}
]
}
]
}

View File

@ -0,0 +1,22 @@
{
"session_id": "chat_8b877198aabf",
"messages": [
{
"role": "user",
"content": "帮我将这个会议创建一个知识库储存\n\n附加文本文件已保存可直接读取或导入\n- transcript_710.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_8b877198aabf\\1782281323021_1_transcript_710.md"
},
{
"role": "assistant",
"content": "已成功为您创建知识库 `meeting_710` 并完成了会议内容的导入。\n\n**处理结果:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议摘要**本次会议主要围绕云桌面业务的渠道赋能、AI 产品转型、售后体系建设、产品方案完整性及销售售前协作五个核心维度进行了深度复盘。\n- **统计数据**\n - 识别到项目5 个\n - 待办事项 (Actions)4 项\n - 风险点 (Risks)2 项\n - 追踪项 (Follow-ups)4 项\n\n您现在可以针对该知识库询问具体的项目进度、待办事项或风险详情。例如“查询 meeting_710 中关于 AI 转型的待办事项”。"
},
{
"role": "user",
"content": "我现在有哪些代办事项?"
},
{
"role": "assistant",
"content": "在 `meeting_710` 知识库中,共识别到以下 **4 项** 待办事项:\n\n| 事项 | 责任对象 | 当前状态 | 下一步动作 | 所属项目 |\n| :--- | :--- | :--- | :--- | :--- |\n| **职责划分细化** | 销售/售前管理层 | 待跟进 | 组织销售与售前座谈会,明确职责边界 | 销售与售前协作流程优化 |\n| **联络官人选评估** | 售前团队 | 待跟进 | 评估合适人员并提交建议 | 省代赋能与渠道管理 |\n| **项目分级制度建立** | 售后团队 | 进行中 | 明确重点与非重点项目的服务边界 | 售后服务体系建设 |\n| **Next X 市场机会研究** | 产品/销售团队 | 待跟进 | 提交 Next X 怎么卖及市场机会报告 | AI 云桌面 (智听云/Next X) 研发 |\n\n**台账路径**`data/meeting_710/team_ledger.md`"
}
],
"max_persisted_turns": 20
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,22 @@
{
"session_id": "cli_default",
"messages": [
{
"role": "user",
"content": "测试测试测试"
},
{
"role": "assistant",
"content": "系统正常运行 ✅\n\n目前 `data/` 下已有 **1 个知识库**\n- 📁 **会议测试**`data\\会议测试`\n\n我可以帮你做以下事情\n\n| 功能 | 说明 |\n|------|------|\n| 📥 导入会议转录 | 传入 ASR 转录文件,自动抽取指标、待办、风险,更新周会台账 |\n| 🔍 查询知识库 | 查指标、待办、风险、完成情况、追踪项等 |\n| 📋 列知识库列表 | 查看所有可用的知识库 |\n| 📂 文件浏览 | 查看工作区文件结构 |\n\n有什么需要我帮你处理的吗比如导入一份会议记录或者查询某个台账内容 😊"
},
{
"role": "user",
"content": "测试测试"
},
{
"role": "assistant",
"content": "收到,测试通过!✅\n\n目前我已准备就绪。如果你想开始正式使用可以尝试\n\n1. **导入数据**`import_meeting_transcript(file_path=\"path/to/your/asr.txt\", knowledge_base_id=\"会议测试\")`\n2. **查询内容**`query_knowledge(query=\"最近有哪些待办事项?\", knowledge_base_id=\"会议测试\")`\n\n请随时下达指令。 😊"
}
],
"max_persisted_turns": 20
}

View File

@ -0,0 +1,58 @@
{
"max_turns": 20,
"next_turn_id": 7,
"active_turn": null,
"turns": [
{
"turn_id": 1,
"started_at": "2026-06-23T17:18:38.118377",
"completed_at": "2026-06-23T17:18:42.122584",
"user_message": "测试测试测试",
"assistant_final": "大模型调用失败Connection error.\n当前 MODEL_NAME='deepseek-v4-flash', OPENAI_BASE_URL=https://api.openmodel.app/v1。",
"tool_calls": []
},
{
"turn_id": 2,
"started_at": "2026-06-23T17:19:58.153667",
"completed_at": "2026-06-23T17:20:00.194734",
"user_message": "测试测试测试",
"assistant_final": "大模型调用失败Error code: 404 - {'success': False, 'data': None, 'error': {'code': 'NOT_FOUND', 'msg': 'route not found'}}\n当前 MODEL_NAME='deepseek-v4-flash', OPENAI_BASE_URL=https://api.openmodel.ai/v1/responses。",
"tool_calls": []
},
{
"turn_id": 3,
"started_at": "2026-06-23T17:20:21.992077",
"completed_at": "2026-06-23T17:20:24.667988",
"user_message": "测试测试测试",
"assistant_final": "大模型调用失败Error code: 404 - {'success': False, 'data': None, 'error': {'code': 'NOT_FOUND', 'msg': 'route not found'}}\n当前 MODEL_NAME='deepseek-v4-flash', OPENAI_BASE_URL=https://api.openmodel.ai/v1。",
"tool_calls": []
},
{
"turn_id": 4,
"started_at": "2026-06-23T17:27:42.969133",
"completed_at": "2026-06-23T17:27:51.053781",
"user_message": "测试测试测试",
"assistant_final": "系统正常运行 ✅\n\n目前 `data/` 下已有 **1 个知识库**\n- 📁 **会议测试**`data\\会议测试`\n\n我可以帮你做以下事情\n\n| 功能 | 说明 |\n|------|------|\n| 📥 导入会议转录 | 传入 ASR 转录文件,自动抽取指标、待办、风险,更新周会台账 |\n| 🔍 查询知识库 | 查指标、待办、风险、完成情况、追踪项等 |\n| 📋 列知识库列表 | 查看所有可用的知识库 |\n| 📂 文件浏览 | 查看工作区文件结构 |\n\n有什么需要我帮你处理的吗比如导入一份会议记录或者查询某个台账内容 😊",
"tool_calls": [
{
"tool_name": "list_directory",
"arguments": {
"path": "data"
},
"thought": "用户说\"测试测试测试\",这看起来像是测试对话。我需要先看看当前工作区的状态,了解 data/ 下有哪些知识库。让我先看看目录结构。",
"result": "{\n \"success\": true,\n \"path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\",\n \"entries\": [\n {\n \"name\": \"会议测试\",\n \"path\": \"data\\\\会议测试\",\n \"type\": \"directory\",\n \"size\": null,\n \"modified_at\": \"2026-06-23T16:33:42\"\n }\n ]\n}",
"tool_call_id": "call_4d8ebe0a5b8c4d2baab83c46",
"created_at": "2026-06-23T17:27:47.410215"
}
]
},
{
"turn_id": 6,
"started_at": "2026-06-23T17:29:58.459992",
"completed_at": "2026-06-23T17:30:05.543550",
"user_message": "测试测试",
"assistant_final": "收到,测试通过!✅\n\n目前我已准备就绪。如果你想开始正式使用可以尝试\n\n1. **导入数据**`import_meeting_transcript(file_path=\"path/to/your/asr.txt\", knowledge_base_id=\"会议测试\")`\n2. **查询内容**`query_knowledge(query=\"最近有哪些待办事项?\", knowledge_base_id=\"会议测试\")`\n\n请随时下达指令。 😊",
"tool_calls": []
}
]
}

View File

@ -0,0 +1,54 @@
{
"session_id": "default",
"messages": [
{
"role": "user",
"content": "帮我看看最近都开了哪些会?"
},
{
"role": "assistant",
"content": "根据“会议测试”知识库的台账记录,最近的一次会议信息如下:\n\n* **会议日期**2026-06-23 (注:台账中显示的最近日期为 06-23但关联的转录文件日期为 06-11)\n* **会议主题/来源**:文件会议 06-11 15_52\n* **关键内容摘要**\n * **指标**:弱光指标为 0.51(目标 3000状态为达标。\n * **待办**:有一项由“运维”负责的追踪项,时限为“今天”。\n * **风险/已完成**:各抽取到 1 条相关线索。\n\n**台账路径**`data\\会议测试\\team_ledger.md`"
},
{
"role": "user",
"content": "不对吧我明明记得开了不下3次会了"
},
{
"role": "assistant",
"content": "抱歉,刚才的回答确实不全面。通过查看完整的台账记录,我发现您确实开了多次会。\n\n在“会议测试”知识库中最近导入了 **5 次** 会议记录,具体如下:\n\n| 会议日期 | 会议 ID (部分) | 来源文件 |\n| :--- | :--- | :--- |\n| 2026-06-23 | `meeting_b3e96c3ab0` | `transcript_704.md` |\n| 2026-06-23 | `meeting_f7a0963764` | `transcript_710.md` |\n| 2026-06-23 | `meeting_71c6a8ce1b` | `transcript_711.md` |\n| 2026-06-23 | `meeting_9b9a7e718a` | `transcript_712.md` |\n| 2026-06-23 | `meeting_f4029f272b` | `文件会议 06-11 15_52-Transcript.md` |\n\n**台账路径**`data\\会议测试\\team_ledger.md`\n**发现问题**:由于这些会议的导入日期在台账中都标注为 `2026-06-23`,导致查询时未能第一时间区分它们的实际发生顺序。如果您需要了解其中某一次会议的具体内容,请告诉我。"
},
{
"role": "user",
"content": "刚刚我们聊什么来着?"
},
{
"role": "assistant",
"content": "我们刚才在讨论**“会议测试”知识库中最近的会议记录**。\n\n具体过程如下\n1. **您询问**最近开了哪些会。\n2. **我最初只提供了一个**最近会议的摘要,但由于台账记录的原因,信息不够全面。\n3. **您指出**应该开了不止 3 次会。\n4. **我重新检索了完整的台账**,为您列出了该知识库中最近的 **5 次会议记录**(日期均显示为 2026-06-23包含不同的转录文件。\n\n如果您想深入了解其中某次会议的指标、待办或风险可以随时告诉我。"
},
{
"role": "user",
"content": "用你的websearch工具帮我检索一下codex的重置机会重置的是一周的用量还是5小时的"
},
{
"role": "assistant",
"content": "抱歉,我并没有内置的 Web Search网页搜索工具。\n\n作为“会议知识库维护 Agent”我的功能范围主要集中在\n1. **管理和查询“周会经营台账”**(指标、待办、风险、追踪项等)。\n2. **导入会议转录文本**并将其结构化到知识库中。\n\n如果您提到的“codex 的重置机会”是在之前的会议中讨论过的内容,您可以告诉我,我会立即在知识库中为您检索相关的会议记录。如果这是关于外部软件或服务的通用问题,建议您直接使用搜索引擎查询。"
},
{
"role": "user",
"content": "你检查一下你现在拥有的工具"
},
{
"role": "assistant",
"content": "我检查了当前系统分配给我的工具列表,我确实**没有**内置的网页搜索Web Search工具。\n\n我目前拥有的工具及其功能如下\n\n### 1. 核心业务工具(用于维护会议知识库)\n* **`import_meeting_transcript`**: 将会议 ASR 转录文本导入知识库,并提取指标、待办、风险等。\n* **`query_knowledge`**: 查询知识库中的指标、待办、风险、完成事项和追踪项。\n* **`list_knowledge_bases`**: 列出当前所有的知识库(`data/` 下的文件夹)。\n\n### 2. 文件与系统操作工具\n* **`list_directory`**: 查看目录下的文件和子目录。\n* **`read_file`**: 读取文本文件内容。\n* **`write_file`**: 写入或修改文本文件。\n* **`search_files`**: 在工作区内按文件名模式搜索文件。\n* **`execute_shell`**: 执行 shell 命令进行环境检查或只读操作。\n* **`run_python`**: 运行 Python 代码进行数据处理或逻辑计算。\n\n### 3. 辅助工具\n* **`current_time`**: 获取当前系统时间。\n* **`tool_trace_query`**: 回看我之前的工具调用记录。\n\n**结论**:我的能力边界被设定在**管理和查询您提供的会议知识库**。如果您想了解关于 \"codex\" 的信息,除非它之前在会议中被提及并记录到了台账里,否则我无法通过外部互联网获取。"
},
{
"role": "user",
"content": "好吧,还真的没有,是我记错了"
},
{
"role": "assistant",
"content": "没关系的,这很正常!\n\n如果您之后需要查询**会议记录里的具体细节**(比如某个项目的进展、谁负责什么待办、或者某个指标的变动),或者需要**导入新的会议转录文件**,随时叫我就好。\n\n目前我们正在处理“会议测试”这个知识库有什么我可以帮您的吗"
}
],
"max_persisted_turns": 20
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,6 @@
{
"max_turns": 20,
"next_turn_id": 1,
"active_turn": null,
"turns": []
}

View File

@ -0,0 +1,46 @@
{
"session_id": "meeting_710",
"messages": [
{
"role": "user",
"content": "帮我查查目前的重点工作内容是什么"
},
{
"role": "assistant",
"content": "根据当前的会议台账(`meeting_710/team_ledger.md`),目前的重点工作主要集中在以下三个方面:\n\n1. **产品方案优化(当前状态:阻塞)**\n * **核心目标**解决软硬件方案“拼凑感”强、品牌不统一的问题并提升特定外设如U盾、写字板的兼容性。\n\n2. **销售与售前协作流程优化(进行中)**\n * **关键动作**:细化销售与售前的职责划分以减少冲突;同时优化配置器与报价申请流程,旨在降低售前的工作量。\n\n3. **售后服务体系建设(进行中)**\n * **关键动作**:建立项目分级制度,以确保重点项目的服务质量。\n\n**下一步建议**\n* 由于“产品方案优化”项目目前处于**阻塞**状态,可能需要针对软硬件集成或外设兼容性问题进行专项讨论。\n* 可以关注“销售与售前协作”中关于“配置器优化”的具体进度。"
},
{
"role": "user",
"content": "这是新的一个会议,你帮我整理到知识库里\n\n附加文本文件已保存可直接读取或导入\n- transcript_711.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782281782706_1_transcript_711.md"
},
{
"role": "assistant",
"content": "会议转录已成功导入知识库 `meeting_710`。\n\n**更新概况:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议核心摘要**:本次会议重点复盘了河南、山东区域的销售情况,讨论了在价格竞争激烈的背景下,如何通过 **AI 转型**(产品智能化与内部生产力智能化)、**行业场景化细分**(如教育、医疗)以及**优化内部响应流程**(解决测试机申请、研发响应及售后验收问题)来提升竞争力。\n- **数据统计**\n - 新增/更新项目5 个\n - 新增待办事项 (Actions)4 个\n - 新增风险点 (Risks)3 个\n - 新增追踪项 (Follow-ups)4 个\n\n**下一步建议:**\n您可以针对“AI 转型方案”、“区域销售策略”或“内部响应流程优化”等具体维度,通过询问“目前有哪些待办事项?”或“有哪些潜在风险?”来深入了解细节。"
},
{
"role": "user",
"content": "汇报一下最新的情况"
},
{
"role": "assistant",
"content": "根据最新的会议复盘2026-06-24目前的业务重点已从单纯的“流程优化”转向了 **“AI 转型”** 与 **“行业场景化深耕”**。\n\n以下是各维度的最新进展汇报\n\n### 1. 核心战略AI 转型与产品升级\n* **AI 云桌面研发 (进行中)**:战略目标是将云桌面从单一工具升级为“智能生产力平台”。重点在于探索 AI 与终端、数据、算力的深度纳管(如智听云/Next X。\n* **产品方案优化 (阻塞)**:由于市场同质化严重,目前正处于寻找差异化突破口的阶段。重点转向强化**教育、医疗**等细分场景的差异化方案,并加强**国产化适配**案例的积累。\n\n### 2. 市场与渠道进展\n* **区域销售策略 (进行中)**\n * **河南区域**:重点在于挖掘友商代理商并培养“金牌渠道”。\n * **山东区域**:侧重提升渠道拓展的精度,建立品牌信任。\n* **应对价格竞争**:针对服务器涨价导致的成本上升,计划通过强化“安全管控(数据不落地)”及提供“微胖”方案来对抗 PC 方案的竞争。\n\n### 3. 内部流程与服务保障\n* **销售与售前协作 (推进中)**:重点解决影响业务效率的痛点,包括**标准化资料更新慢**、**测试机申请周期长**以及**研发 Bug 修复响应慢**的问题。\n* **售后服务体系 (推进中)**:重点解决山东区域(如法院项目)遗留项目验收难的问题,旨在通过提升服务能力来维护品牌价值。\n\n---\n\n**💡 关键观察:**\n目前**“产品方案优化”**仍处于**阻塞**状态建议下一步重点关注“AI 转型”如何转化为可落地的“行业差异化方案”,以破解同质化竞争。\n\n**您可以继续追问:**\n* “目前有哪些具体的待办事项 (Actions)?”\n* “有哪些潜在的风险 (Risks) 需要关注?”\n* “关于 AI 研发有哪些具体的追踪项 (Follow-ups)?”"
},
{
"role": "user",
"content": "帮我将新的会议整理进去\n\n附加文本文件已保存可直接读取或导入\n- transcript_712.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283873952_1_transcript_712.md"
},
{
"role": "assistant",
"content": "会议转录已成功导入知识库 `meeting_710`。\n\n**更新概况:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议核心摘要**:本次会议重点复盘了 **6 月财务毛利情况**、**大额项目(合北智算)签约进展**、**AI 数字人产品逻辑**、**云桌面终端纳管方案**以及**无人机业务**的推进情况。\n- **数据统计**\n - 新增/更新项目5 个\n - 新增待办事项 (Actions)6 个\n - 新增风险点 (Risks)5 个\n - 新增追踪项 (Follow-ups)6 个\n - 完成事项 (Completed)1 个\n\n**关键动态:**\n1. **财务/业绩**:毛利率稳定在 35%,重点关注“合北智算”项目(约 7680 万)的落地。\n2. **AI 产品线**:明确了 AI 数字人“简单、高频”的商业逻辑;云桌面研发正转向“胖终端纳管”及“财务数字助理”试点。\n3. **新业务**:无人机业务在推进中,但面临采集数据与执法库对接的**合法性风险**。\n\n**下一步建议:**\n您可以针对“无人机业务的合规性风险”或“AI 数字人产品的落地计划”进行更深入的查询。"
},
{
"role": "user",
"content": "我最近会议的重点内容有哪些?哪些问题比较严重"
},
{
"role": "assistant",
"content": "通过对近期会议(特别是 2026-06-24 的复盘会议)的梳理,我为您总结了重点内容及当前面临的严重问题:\n\n### 核心重点内容\n1. **业绩与重大项目**\n * **财务表现**6 月毛利率稳定在 **35%**。\n * **大单落地****“合北智算”项目**(约 7680 万)已完成签约,是近期最重要的业绩支撑。\n2. **AI 转型战略落地**\n * **产品方向**:确立了 **AI 数字人**“简单、高频”的商业逻辑;**云桌面**正向“胖终端纳管”(如 U 盘启动)及“财务数字助理”试点方向演进。\n3. **业务拓展**\n * **无人机业务**:正在推进中物联及九龙坡项目。\n * **渠道管理**:重点关注渠道商报价波动及区域人员配置。\n\n### ⚠️ 比较严重的问题与风险\n在最近的讨论中以下几个问题需要高度关注\n\n1. **合规性风险(高优先级)**\n * **无人机业务**:目前在推进项目过程中,**采集的数据如何与执法库对接**存在**合法性风险**。如果不解决合规问题,该业务线的扩张将面临法律障碍。\n2. **管理与预测风险**\n * **销售预测准确性**:会议强调目前的销售预测存在问题,销售负责人不能仅盯着“财务收款”,必须结合实际业务进度进行更精准的预测,否则会影响整体经营判断。\n3. **技术与产品瓶颈**\n * **兼容性问题**:终端黑屏等兼容性问题仍需解决。\n * **AI 数据采集**:在 AI 场景下,如何实现高质量、合规的数据采集仍是技术攻关的难点。\n\n**建议关注方向**\n如果您需要决策支持建议优先听取关于**“无人机业务合规方案”**或**“AI 财务助理试点进展”**的专项汇报。"
}
],
"max_persisted_turns": 20
}

View File

@ -0,0 +1,131 @@
{
"max_turns": 20,
"next_turn_id": 8,
"active_turn": null,
"turns": [
{
"turn_id": 1,
"started_at": "2026-06-24T14:15:24.363881",
"completed_at": "2026-06-24T14:15:40.451527",
"user_message": "帮我查查目前的重点工作内容是什么",
"assistant_final": "根据当前的会议台账(`meeting_710/team_ledger.md`),目前的重点工作主要集中在以下三个方面:\n\n1. **产品方案优化(当前状态:阻塞)**\n * **核心目标**解决软硬件方案“拼凑感”强、品牌不统一的问题并提升特定外设如U盾、写字板的兼容性。\n\n2. **销售与售前协作流程优化(进行中)**\n * **关键动作**:细化销售与售前的职责划分以减少冲突;同时优化配置器与报价申请流程,旨在降低售前的工作量。\n\n3. **售后服务体系建设(进行中)**\n * **关键动作**:建立项目分级制度,以确保重点项目的服务质量。\n\n**下一步建议**\n* 由于“产品方案优化”项目目前处于**阻塞**状态,可能需要针对软硬件集成或外设兼容性问题进行专项讨论。\n* 可以关注“销售与售前协作”中关于“配置器优化”的具体进度。",
"tool_calls": [
{
"tool_name": "list_knowledge_bases",
"arguments": {},
"thought": "The user is asking to check the current \"key work content\" (重点工作内容).\nI need to query the knowledge base for this information.\nSince the user hasn't specified a `knowledge_base_id`, I should first check if there are any knowledge bases available or if I can just call `query_knowledge` without one.\nAccording to the system prompt: \"用户未指定知识库时,可以省略 knowledge_base_id如果 data/ 下只有一个知识库,工具会自动使用它。\"\nHowever, it's safer to list knowledge bases first if I want to be sure, or just try to query.\nActually, a better first step is to see what knowledge bases exist so I know which one to query or if I need to ask the user.\nBut if I just call `query_knowledge` with the query \"重点工作内容\", it might work if there's only one.\nLet's try `list_knowledge_bases` first to see what's available.",
"result": "{\n \"knowledge_bases\": [\n \"meeting_710\"\n ],\n \"count\": 1\n}",
"tool_call_id": "chatcmpl-tool-b9f7d02dfc654b2d",
"created_at": "2026-06-24T14:15:30.284205"
},
{
"tool_name": "query_knowledge",
"arguments": {
"knowledge_base_id": "meeting_710",
"query": "目前的重点工作内容是什么"
},
"thought": "The user wants to know the current key work contents (\"目前的重点工作内容是什么\").\nI have already listed the knowledge bases and found one: `meeting_710`.\nSince there is only one knowledge base, I can proceed to query it without explicitly specifying the ID, or I can specify it for clarity.\nI will use `query_knowledge` to find the \"重点工作内容\" (key work contents) in the `meeting_710` knowledge base.",
"result": "{\n \"answer\": \"根据当前会议台账,找到这些相关内容:\\n- [下一步] 销售与售前协作流程优化|细化销售与售前的工作职责划分,减少冲突\\n- [待办] 售后服务体系建设|项目分级制度建立|责任:售后团队|截止:|状态:进行中\\n- [项目] 产品方案完整性与兼容性优化状态阻塞更新解决目前软硬件方案“拼凑感”强、品牌不统一以及特定外设如U盾、写字板兼容性差的问题。\\n- [下一步] 销售与售前协作流程优化|优化配置器与报价申请流程,降低售前工作量\\n- [决策] 售后服务体系建设|建立项目分级制度,确保重点项目的服务质量\\n- [会议摘要] 2026-06-24讨论产品方案的完整性解决目前软硬件方案“拼凑感”及兼容性问题。\",\n \"matches\": [\n {\n \"type\": \"下一步\",\n \"project\": \"销售与售前协作流程优化\",\n \"display_text\": \"销售与售前协作流程优化|细化销售与售前的工作职责划分,减少冲突\",\n \"search_text\": \"销售与售前协作流程优化;销售与售前协作流程优化;细化销售与售前的工作职责划分,减少冲突\"\n },\n {\n \"type\": \"待办\",\n \"project\": \"售后服务体系建设\",\n \"display_text\": \"售后服务体系建设|项目分级制度建立|责任:售后团队|截止:|状态:进行中\",\n \"search_text\": \"售后服务体系建设;项目分级制度建立;售后团队;进行中;明确重点与非重点项目的服务边界;售后服务体系建设\"\n },\n {\n \"type\": \"项目\",\n \"project\": \"产品方案完整性与兼容性优化\",\n \"display_text\": \"产品方案完整性与兼容性优化状态阻塞更新解决目前软硬件方案“拼凑感”强、品牌不统一以及特定外设如U盾、写字板兼容性差的问题。\",\n \"search_text\": \"产品方案完整性与兼容性优化产品方案完整性与兼容性优化阻塞解决目前软硬件方案“拼凑感”强、品牌不统一以及特定外设如U盾、写字板兼容性差的问题。解决目前软硬件方案“拼凑感”强、品牌不统一以及特定外设如U盾、写字板兼容性差的问题。兼容性 方案集成 品牌统一 外设\"\n },\n {\n \"type\": \"下一步\",\n \"project\": \"销售与售前协作流程优化\",\n \"display_text\": \"销售与售前协作流程优化|优化配置器与报价申请流程,降低售前工作量\",\n \"search_text\": \"销售与售前协作流程优化;销售与售前协作流程优化;优化配置器与报价申请流程,降低售前工作量\"\n },\n {\n \"type\": \"决策\",\n \"project\": \"售后服务体系建设\",\n \"display_text\": \"售后服务体系建设|建立项目分级制度,确保重点项目的服务质量\",\n \"search_text\": \"售后服务体系建设;售后服务体系建设;建立项目分级制度,确保重点项目的服务质量\"\n },\n {\n \"type\": \"会议摘要\",\n \"project\": \"\",\n \"display_text\": \"2026-06-24讨论产品方案的完整性解决目前软硬件方案“拼凑感”及兼容性问题。\",\n \"search_text\": \"2026-06-24讨论产品方案的完整性解决目前软硬件方案“拼凑感”及兼容性问题。\"\n }\n ],\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\"\n}",
"tool_call_id": "chatcmpl-tool-9cf985245f4c65d2",
"created_at": "2026-06-24T14:15:33.888244"
}
]
},
{
"turn_id": 2,
"started_at": "2026-06-24T14:16:22.711828",
"completed_at": "2026-06-24T14:19:02.319474",
"user_message": "这是新的一个会议,你帮我整理到知识库里\n\n附加文本文件已保存可直接读取或导入\n- transcript_711.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782281782706_1_transcript_711.md",
"assistant_final": "会议转录已成功导入知识库 `meeting_710`。\n\n**更新概况:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议核心摘要**:本次会议重点复盘了河南、山东区域的销售情况,讨论了在价格竞争激烈的背景下,如何通过 **AI 转型**(产品智能化与内部生产力智能化)、**行业场景化细分**(如教育、医疗)以及**优化内部响应流程**(解决测试机申请、研发响应及售后验收问题)来提升竞争力。\n- **数据统计**\n - 新增/更新项目5 个\n - 新增待办事项 (Actions)4 个\n - 新增风险点 (Risks)3 个\n - 新增追踪项 (Follow-ups)4 个\n\n**下一步建议:**\n您可以针对“AI 转型方案”、“区域销售策略”或“内部响应流程优化”等具体维度,通过询问“目前有哪些待办事项?”或“有哪些潜在风险?”来深入了解细节。",
"tool_calls": [
{
"tool_name": "list_knowledge_bases",
"arguments": {},
"thought": "The user wants to import a new meeting transcript into the knowledge base.\nThe transcript file path is provided: `D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782281782706_1_transcript_711.md`.\nThe user mentions \"knowledge base\" but doesn't explicitly name a `knowledge_base_id`.\nHowever, the file path contains `kb_3cc4030350de`. I should check if this matches a known knowledge base or if I can infer it.\nWait, the instructions say: \"If the user doesn't specify a knowledge base, you can omit it; if there's only one in `data/`, the tool will automatically use it.\"\nBut I should first check what knowledge bases exist to be precise, or just try to use the tool with the provided path.\nActually, the user says \"整理到知识库里\" (organize into the knowledge base).\nThe file path suggests it belongs to a specific ID or is just an upload path.\nLet's first list the knowledge bases to see if `kb_3cc4030350de` or something similar exists.\nThen I will call `import_meeting_transcript`.\n\nWait, looking at the file path: `D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782281782706_1_transcript_711.md`.\nThe folder `kb_3cc4030350de` might be the `knowledge_base_id`.\nLet's try to list knowledge bases first to be sure.",
"result": "{\n \"knowledge_bases\": [\n \"meeting_710\"\n ],\n \"count\": 1\n}",
"tool_call_id": "chatcmpl-tool-84b57ced84897e62",
"created_at": "2026-06-24T14:16:33.539268"
},
{
"tool_name": "import_meeting_transcript",
"arguments": {
"file_path": "agent_memory/uploads/kb_3cc4030350de/1782281782706_1_transcript_711.md",
"knowledge_base_id": "meeting_710"
},
"thought": "",
"result": "{\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\",\n \"meeting_id\": \"meeting_6c2f32f573\",\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"state_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\state.json\",\n \"summary\": [\n \"本次会议对河南、山东区域销售情况进行了深度复盘,重点探讨了在价格竞争与同质化压力下,如何通过 AI 转型、行业场景化细分及优化内部响应流程来提升竞争力。\",\n \"区域销售反馈价格竞争激烈,需通过教育、医疗等细分场景的差异化方案及控标点提升胜率。\",\n \"明确 AI 转型战略AI 需深度融入产品(智能应用)与内部生产力(智能化知识库/快速问答)。\",\n \"针对服务器涨价导致的 VDI 成本上升,探讨通过强化安全管控(数据不落地)及“微胖”方案应对 PC 方案竞争。\",\n \"要求产品经理转型为“Farmer”模式深入一线调研对手功能及客户真实需求。\",\n \"强调解决测试机申请周期长、研发响应慢及售后遗留项目验收难等影响品牌的问题。\"\n ],\n \"imported\": true,\n \"counts\": {\n \"projects\": 5,\n \"metrics\": 0,\n \"actions\": 4,\n \"risks\": 3,\n \"completed\": 0,\n \"followups\": 4\n }\n}",
"tool_call_id": "chatcmpl-tool-a46cbbab601b6162",
"created_at": "2026-06-24T14:18:55.758992"
}
]
},
{
"turn_id": 3,
"started_at": "2026-06-24T14:19:18.376400",
"completed_at": "2026-06-24T14:19:38.990714",
"user_message": "汇报一下最新的情况",
"assistant_final": "根据最新的会议复盘2026-06-24目前的业务重点已从单纯的“流程优化”转向了 **“AI 转型”** 与 **“行业场景化深耕”**。\n\n以下是各维度的最新进展汇报\n\n### 1. 核心战略AI 转型与产品升级\n* **AI 云桌面研发 (进行中)**:战略目标是将云桌面从单一工具升级为“智能生产力平台”。重点在于探索 AI 与终端、数据、算力的深度纳管(如智听云/Next X。\n* **产品方案优化 (阻塞)**:由于市场同质化严重,目前正处于寻找差异化突破口的阶段。重点转向强化**教育、医疗**等细分场景的差异化方案,并加强**国产化适配**案例的积累。\n\n### 2. 市场与渠道进展\n* **区域销售策略 (进行中)**\n * **河南区域**:重点在于挖掘友商代理商并培养“金牌渠道”。\n * **山东区域**:侧重提升渠道拓展的精度,建立品牌信任。\n* **应对价格竞争**:针对服务器涨价导致的成本上升,计划通过强化“安全管控(数据不落地)”及提供“微胖”方案来对抗 PC 方案的竞争。\n\n### 3. 内部流程与服务保障\n* **销售与售前协作 (推进中)**:重点解决影响业务效率的痛点,包括**标准化资料更新慢**、**测试机申请周期长**以及**研发 Bug 修复响应慢**的问题。\n* **售后服务体系 (推进中)**:重点解决山东区域(如法院项目)遗留项目验收难的问题,旨在通过提升服务能力来维护品牌价值。\n\n---\n\n**💡 关键观察:**\n目前**“产品方案优化”**仍处于**阻塞**状态建议下一步重点关注“AI 转型”如何转化为可落地的“行业差异化方案”,以破解同质化竞争。\n\n**您可以继续追问:**\n* “目前有哪些具体的待办事项 (Actions)?”\n* “有哪些潜在的风险 (Risks) 需要关注?”\n* “关于 AI 研发有哪些具体的追踪项 (Follow-ups)?”",
"tool_calls": [
{
"tool_name": "query_knowledge",
"arguments": {
"knowledge_base_id": "meeting_710",
"query": "最近的会议更新和当前项目状态"
},
"thought": "The user wants a report on the \"latest situation\" (\"汇报一下最新的情况\").\nBased on the previous turn, the user just imported a new meeting transcript (`transcript_711.md`) into the `meeting_710` knowledge base.\nThe \"latest situation\" likely refers to the updates brought by this most recent meeting or the overall current status of the projects in the ledger after this update.\n\nTo provide a comprehensive and accurate answer, I should:\n1. Query the knowledge base to get the most recent updates or the overall current status.\n2. The `query_knowledge` tool is designed for this. I'll ask for \"latest meeting updates\" or \"current project status and key updates\".\n\nWait, looking at the previous response, the model *already* summarized the import. However, a \"report\" usually implies a more structured summary of the current state of affairs (projects, tasks, risks, etc.) following the latest update.\n\nI will call `query_knowledge` with a query like \"最近的会议更新和当前项目状态\" (recent meeting updates and current project status) to ensure I'm pulling the actual content from the ledger.",
"result": "{\n \"answer\": \"最近一次会议2026-06-24\\n会议标题汇智云桌面业务复盘及 AI 转型专题会议\\n来源文件D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\agent_memory\\\\uploads\\\\kb_3cc4030350de\\\\1782281782706_1_transcript_711.md\\n\\n会议摘要\\n- 本次会议对河南、山东区域销售情况进行了深度复盘,重点探讨了在价格竞争与同质化压力下,如何通过 AI 转型、行业场景化细分及优化内部响应流程来提升竞争力。\\n- 区域销售反馈价格竞争激烈,需通过教育、医疗等细分场景的差异化方案及控标点提升胜率。\\n- 明确 AI 转型战略AI 需深度融入产品(智能应用)与内部生产力(智能化知识库/快速问答)。\\n- 针对服务器涨价导致的 VDI 成本上升,探讨通过强化安全管控(数据不落地)及“微胖”方案应对 PC 方案竞争。\\n- 要求产品经理转型为“Farmer”模式深入一线调研对手功能及客户真实需求。\\n- 强调解决测试机申请周期长、研发响应慢及售后遗留项目验收难等影响品牌的问题。\\n\\n本次涉及项目\\n- 销售与售前协作流程优化:状态=推进中;更新=解决销售反馈的标准化资料更新慢、测试机申请周期长以及研发 Bug 修复响应慢的问题。\\n- 省代赋能与渠道管理:状态=推进中;更新=河南区域重点挖掘友商代理商并培养金牌渠道;山东区域需提升渠道拓展精度及建立信任。\\n- 售后服务体系建设:状态=推进中;更新=解决山东区域遗留项目(如法院项目)验收难及服务能力不足的问题,提升品牌价值。\\n- 产品方案完整性与兼容性优化:状态=阻塞;更新=应对同质化竞争,需强化教育、医疗等细分场景的差异化方案及国产化适配案例。\\n- AI 云桌面 (智听云/Next X) 研发:状态=推进中;更新=将云桌面从单一工具升级为智能生产力平台,探索 AI 与终端、数据、算力的深度纳管。\",\n \"matches\": [],\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\",\n \"meeting_id\": \"meeting_6c2f32f573\"\n}",
"tool_call_id": "chatcmpl-tool-b48b27daa6bcfdd3",
"created_at": "2026-06-24T14:19:25.996580"
}
]
},
{
"turn_id": 5,
"started_at": "2026-06-24T14:38:43.766467",
"completed_at": "2026-06-24T14:41:36.810382",
"user_message": "帮我继续将这个会议添加进去\n\n附加文本文件已保存可直接读取或导入\n- transcript_712.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283123758_1_transcript_712.md",
"assistant_final": "Agent run cancelled by user.",
"tool_calls": []
},
{
"turn_id": 6,
"started_at": "2026-06-24T14:51:13.958233",
"completed_at": "2026-06-24T14:54:26.383304",
"user_message": "帮我将新的会议整理进去\n\n附加文本文件已保存可直接读取或导入\n- transcript_712.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283873952_1_transcript_712.md",
"assistant_final": "会议转录已成功导入知识库 `meeting_710`。\n\n**更新概况:**\n- **台账路径**`data/meeting_710/team_ledger.md`\n- **会议核心摘要**:本次会议重点复盘了 **6 月财务毛利情况**、**大额项目(合北智算)签约进展**、**AI 数字人产品逻辑**、**云桌面终端纳管方案**以及**无人机业务**的推进情况。\n- **数据统计**\n - 新增/更新项目5 个\n - 新增待办事项 (Actions)6 个\n - 新增风险点 (Risks)5 个\n - 新增追踪项 (Follow-ups)6 个\n - 完成事项 (Completed)1 个\n\n**关键动态:**\n1. **财务/业绩**:毛利率稳定在 35%,重点关注“合北智算”项目(约 7680 万)的落地。\n2. **AI 产品线**:明确了 AI 数字人“简单、高频”的商业逻辑;云桌面研发正转向“胖终端纳管”及“财务数字助理”试点。\n3. **新业务**:无人机业务在推进中,但面临采集数据与执法库对接的**合法性风险**。\n\n**下一步建议:**\n您可以针对“无人机业务的合规性风险”或“AI 数字人产品的落地计划”进行更深入的查询。",
"tool_calls": [
{
"tool_name": "import_meeting_transcript",
"arguments": {
"file_path": "D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283873952_1_transcript_712.md",
"knowledge_base_id": "meeting_710"
},
"thought": "The user wants to import a new meeting transcript into the knowledge base.\nThe file path is `D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\kb_3cc4030350de\\1782283873952_1_transcript_712.md`.\nBased on the previous interaction, the current knowledge base is `meeting_710`.\nI should use the `import_meeting_transcript` tool.\n\nPlan:\n1. Call `import_meeting_transcript` with the provided `file_path` and `knowledge_base_id='meeting_710'`.",
"result": "{\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\",\n \"meeting_id\": \"meeting_fd54acb19d\",\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"state_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\state.json\",\n \"summary\": [\n \"本次会议复盘了6月财务毛利、重点项目签约进展、AI数字人产品逻辑、云桌面终端纳管方案及无人机业务的推进情况。\",\n \"财务方面毛利率维持在35%,强调销售负责人需提升预测能力。\",\n \"合北智算项目已完成签约涉及金额约7680万。\",\n \"AI数字人产品确定“简单、高频”的商业逻辑面向企业场景落地。\",\n \"云桌面研发聚焦于胖终端纳管U盘启动及财务数字助理的试点应用。\",\n \"无人机业务推进中物联及九龙坡项目,需解决采集数据与执法库对接的合法性问题。\"\n ],\n \"imported\": true,\n \"counts\": {\n \"projects\": 5,\n \"metrics\": 4,\n \"actions\": 6,\n \"risks\": 5,\n \"completed\": 1,\n \"followups\": 6\n }\n}",
"tool_call_id": "chatcmpl-tool-9bc2ab14288bc88b",
"created_at": "2026-06-24T14:54:17.433475"
}
]
},
{
"turn_id": 7,
"started_at": "2026-06-24T14:59:38.595464",
"completed_at": "2026-06-24T15:00:16.931725",
"user_message": "我最近会议的重点内容有哪些?哪些问题比较严重",
"assistant_final": "通过对近期会议(特别是 2026-06-24 的复盘会议)的梳理,我为您总结了重点内容及当前面临的严重问题:\n\n### 核心重点内容\n1. **业绩与重大项目**\n * **财务表现**6 月毛利率稳定在 **35%**。\n * **大单落地****“合北智算”项目**(约 7680 万)已完成签约,是近期最重要的业绩支撑。\n2. **AI 转型战略落地**\n * **产品方向**:确立了 **AI 数字人**“简单、高频”的商业逻辑;**云桌面**正向“胖终端纳管”(如 U 盘启动)及“财务数字助理”试点方向演进。\n3. **业务拓展**\n * **无人机业务**:正在推进中物联及九龙坡项目。\n * **渠道管理**:重点关注渠道商报价波动及区域人员配置。\n\n### ⚠️ 比较严重的问题与风险\n在最近的讨论中以下几个问题需要高度关注\n\n1. **合规性风险(高优先级)**\n * **无人机业务**:目前在推进项目过程中,**采集的数据如何与执法库对接**存在**合法性风险**。如果不解决合规问题,该业务线的扩张将面临法律障碍。\n2. **管理与预测风险**\n * **销售预测准确性**:会议强调目前的销售预测存在问题,销售负责人不能仅盯着“财务收款”,必须结合实际业务进度进行更精准的预测,否则会影响整体经营判断。\n3. **技术与产品瓶颈**\n * **兼容性问题**:终端黑屏等兼容性问题仍需解决。\n * **AI 数据采集**:在 AI 场景下,如何实现高质量、合规的数据采集仍是技术攻关的难点。\n\n**建议关注方向**\n如果您需要决策支持建议优先听取关于**“无人机业务合规方案”**或**“AI 财务助理试点进展”**的专项汇报。",
"tool_calls": [
{
"tool_name": "query_knowledge",
"arguments": {
"knowledge_base_id": "meeting_710",
"query": "总结最近几次会议的重点内容,并特别指出哪些问题比较严重(如阻塞状态、高风险项、或需要紧急处理的待办事项)。"
},
"thought": "The user is asking for the \"key content of recent meetings\" and \"which problems are serious\".\nI need to query the knowledge base to find recent meeting highlights and identify critical issues (Risks, Blocked projects, or high-priority Actions/Follow-ups).\n\nThe knowledge base ID is `meeting_710` (based on previous turns).\n\nPlan:\n1. Call `query_knowledge` with a query like \"最近会议的重点内容和严重问题\" or \"summary of recent meetings and serious risks/blocked projects\".\n2. Analyze the result to categorize \"key content\" and \"serious problems\".\n3. Formulate the response.",
"result": "{\n \"answer\": \"最近一次会议2026-06-24\\n会议标题汇智云桌面业务复盘及 AI 转型专题会议\\n来源文件D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\agent_memory\\\\uploads\\\\kb_3cc4030350de\\\\1782283873952_1_transcript_712.md\\n\\n会议摘要\\n- 本次会议复盘了6月财务毛利、重点项目签约进展、AI数字人产品逻辑、云桌面终端纳管方案及无人机业务的推进情况。\\n- 财务方面毛利率维持在35%,强调销售负责人需提升预测能力。\\n- 合北智算项目已完成签约涉及金额约7680万。\\n- AI数字人产品确定“简单、高频”的商业逻辑面向企业场景落地。\\n- 云桌面研发聚焦于胖终端纳管U盘启动及财务数字助理的试点应用。\\n- 无人机业务推进中物联及九龙坡项目,需解决采集数据与执法库对接的合法性问题。\\n\\n本次涉及项目\\n- 销售与售前协作流程优化:状态=推进中;更新=复盘了6月毛利、定开收入及销售预测情况强调预测需结合实际而非仅看财务收款。\\n- AI 云桌面 (智听云/Next X) 研发:状态=推进中;更新=推进财务数字助理试点研究通过U盘启动实现胖终端纳管并确立数字人产品方向。\\n- 省代赋能与渠道管理:状态=推进中;更新=讨论了渠道商报价波动及云南区域人员入职情况。\\n- 无人机业务:状态=推进中;更新=推进中物联及九龙坡项目,需解决采集数据与执法库对接的合法性问题。\\n- 产品方案完整性与兼容性优化:状态=推进中;更新=解决终端黑屏兼容性问题并研究AI场景下的数据采集方案。\",\n \"matches\": [],\n \"ledger_path\": \"D:\\\\github_project\\\\my_code\\\\meeting_knowledge\\\\data\\\\meeting_710\\\\team_ledger.md\",\n \"team_id\": \"meeting_710\",\n \"knowledge_base_id\": \"meeting_710\",\n \"meeting_id\": \"meeting_fd54acb19d\"\n}",
"tool_call_id": "chatcmpl-tool-8bafccc132a4653c",
"created_at": "2026-06-24T14:59:44.016488"
}
]
}
]
}

View File

@ -0,0 +1,54 @@
{
"session_id": "meeting_knowledge",
"messages": [
{
"role": "user",
"content": "C:\\Users\\admin\\Downloads\\会议原文 帮我将这个路径下的几个md会议文档整理到知识库里知识库名字叫会议测试"
},
{
"role": "assistant",
"content": "在尝试为您整理会议文档时遇到了问题。\n\n我尝试导入 `C:\\Users\\admin\\Downloads\\会议原文` 目录下的文件到“会议测试”知识库,但由于**大模型服务调用超时Request timed out**,导致导入任务失败。\n\n**已尝试处理的文件:**\n1. `transcript_704.md` - ❌ 失败 (超时)\n2. `transcript_710.md` - ❌ 失败 (超时)\n\n**后续建议:**\n这通常是由于后端大模型服务器响应过慢或网络波动引起的。您可以\n1. **稍后重试**:等服务器负载降低后再试。\n2. **检查网络**:确保您的网络环境可以正常访问大模型服务地址。\n\n一旦服务恢复正常您可以再次指令我进行整理。"
},
{
"role": "user",
"content": "帮我将这个文档整理到知识库\n\n附加文本文件已保存可直接读取或导入\n- huiyi.txt: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_2b0479c17171\\1782266715613_1_huiyi.txt"
},
{
"role": "assistant",
"content": "会议文档已成功整理到知识库 **“会议测试”** 中。\n\n**整理结果摘要:**\n- **台账路径**`D:\\github_project\\my_code\\meeting_knowledge\\data\\会议测试\\team_ledger.md`\n- **更新统计**\n - 识别到 **6** 个相关项目\n - 提取了 **3** 项关键指标\n - 整理了 **6** 项待办事项 (Actions)\n - 识别了 **3** 项风险 (Risks)\n - 记录了 **6** 项追踪项 (Follow-ups)\n\n**会议核心内容:**\n1. **宽带运维**:讨论了安装量受天气影响的波动情况,需平衡上门量与安装量。\n2. **PCDN问题**针对PCDN指标恶化需通过分析学校IP并协同限速解决。\n3. **二级站拆除**进度顺利预计4月15日前完成全量拆除及服务费调整。\n4. **商客业务**:市场部需提升业务价值,并于本周汇报招聘及渠道进度。\n5. **活动筹备**体育文化节方阵人员落实中目前尚缺9人。\n\n您可以随时通过提问如“最近有哪些待办”或“PCDN的问题怎么解决来查询详细信息。"
},
{
"role": "user",
"content": "看看我的会议内容"
},
{
"role": "assistant",
"content": "为您查询到知识库“会议测试”中最近一次会议2026-06-24的内容总结如下\n\n### 📅 最近会议概览\n* **日期**2026-06-24\n* **会议主题**:周例会\n* **核心内容**:本次会议主要围绕运维指标、业务恶化问题、拆除进度、市场业务及活动筹备五个方面展开。\n\n### 🚀 项目进展详情\n| 项目名称 | 当前状态 | 核心进展/关注点 |\n| :--- | :--- | :--- |\n| **宽带安装与运维** | 推进中 | 安装量受天气影响有波动,需平衡上门量与安装量。 |\n| **PCDN与专线业务** | 🔴 **阻塞** | **PCDN指标持续恶化**,问题集中在学校出口带宽占用过大。 |\n| **二级站拆除工程** | 推进中 | 拆除、下电及服务费调整工作正在顺利进行。 |\n| **市场与商客业务** | 推进中 | 商客收入稳步增长,重点在于提升拆迁业务的价值。 |\n| **体育文化节筹备** | 进行中 | 正在筹备第4届文化节重点是落实方阵人员。 |\n| **客户满意度管理** | 关注中 | 针对测评结果进行反思,要求提升执行力。 |\n\n---\n**💡 提示:**\n如果您想了解具体的**待办事项 (Actions)**、**风险 (Risks)** 或某个项目的**详细追踪项**,请直接告诉我,例如:“*PCDN的问题有哪些具体的解决措施*” 或 “*列出所有待办事项*”。"
},
{
"role": "user",
"content": "帮我把这个会议也整理一下\n\n附加文本文件已保存可直接读取或导入\n- transcript_704.md: D:\\github_project\\my_code\\meeting_knowledge\\agent_memory\\uploads\\chat_2b0479c17171\\1782267550230_1_transcript_704.md"
},
{
"role": "assistant",
"content": "会议文档已成功整理到知识库 **“会议测试”** 中。\n\n**整理结果摘要:**\n- **台账路径**`D:\\github_project\\my_code\\meeting_knowledge\\data\\会议测试\\team_ledger.md`\n- **更新统计**\n - 识别到 **4** 个相关项目\n - 整理了 **3** 项待办事项 (Actions)\n - 识别了 **1** 项风险 (Risks)\n - 记录了 **3** 项追踪项 (Follow-ups)\n\n**会议核心内容:**\n1. **定制开发业务**重点在于6月183万收入目标的冲刺需通过降低边际成本来增强竞争力。\n2. **AI 算力资源管理**:强调 Token 和 H20 资源管理需从“游击队”模式转向规模化、流程化的专业管理。\n3. **无人机平台研发**:正在推进与集云结合的方案,重点关注违停抓拍功能及互联网接入。\n4. **产品质量与风险**:数字助理产品框架已定;同时需高度关注**云桌面产品**因性能卡顿导致的退货风险,需加强质量与服务建设。\n\n您可以随时询问“*云桌面有哪些风险?*” 或 “*关于AI算力的待办事项有哪些*” 来获取更详细的信息。"
},
{
"role": "user",
"content": "我最近会议内容有哪些?"
},
{
"role": "assistant",
"content": "为您查询到知识库“会议测试”中最近一次会议的内容总结:\n\n### 📅 最近会议概览\n* **日期**2026-06-24\n* **会议标题**:周例会\n* **来源文件**`transcript_704.md`\n\n### 📝 会议核心摘要\n本次会议主要聚焦于业务目标的冲刺、AI 资源的专业化管理以及产品质量风险的防范:\n1. **定制开发业务**:正在全力冲刺 6 月份 **183 万**的收入目标,并着手规划下半年的任务。\n2. **AI 算力与模型管理**:强调 AI 算力Token 和 H20的管理需要实现从“游击队”模式向**规模化、流程化**的量化管理转型。\n3. **无人机平台**:正在推进与集云结合的方案,重点开发**违停抓拍**及互联网接入功能。\n4. **产品质量预警****云桌面产品**目前存在因性能卡顿导致客户退货的风险,需加强产品质量与服务团队建设。\n5. **数字助理**:产品框架已确定,将通过 AI 实现自动化咨询与配置助手功能。\n\n---\n**💡 提示:**\n如果您需要查看更具体的任务可以问我\n* “*云桌面产品有哪些具体的待办或风险?*”\n* “*关于 AI 算力管理有哪些追踪项?*”"
},
{
"role": "user",
"content": "我上次会议讲的九零工程具体情况怎么样?"
},
{
"role": "assistant",
"content": "根据台账记录,“九零工程”目前处于**待达标**状态。\n\n具体指标如下\n* **项目名称**:宽带安装与运维\n* **核心指标**:九零工程转化率 = **87.35%**\n* **目标值****90%**\n* **当前进度**:虽然月度来看有提升,但距离 90% 的目标还差一点点。"
}
],
"max_persisted_turns": 20
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,777 @@
未知说话人01
00:01
也是我们也对宽带的重视,也是有一定的效果哈。
未知说话人01
00:06
然后上门处理因为上周第1个是下雨的原因第2个导致的安装的上门的量的下降所以当周的那个上门量还是比较高五百八十哈。所以说那个累计上门量达到了以前以上哈但是离离我们的目标三千户的话这1块稍微进度还是靠后哈主要还是因为上门这块和那个和安装这安装这块的平衡哈要两边都要兼顾。总体的话线路故障的话这1块是比较平稳哈。
未知说话人01
00:37
你那个说法,刚刚说的本来那个安装没得好多量的噻。不是没得量,下雨。
未知说话人01
00:43
下雨打的上周下雨。上面都应该黑皮的啊。上面就比较好的咱们这上面比较好的。那整个加在一起的话你才。前面前面我们不是。累积了35的进度那个进度不高哦。前面2周不是安装达到了一千吧最近。
未知说话人01
00:58
你给我看后面我们这上周我们安装只有接近400。
未知说话人01
01:02
所以说这个安装量和上门量它2个还是就是每1周还是达到了将近1000的上门量就是安装和维护的上门还是有1000然后每个月的四达到了4000的上门量然后再看一下那个。
未知说话人01
01:17
这个刚刚说的就是那个上门整个指标的情况的话就是从上门之后的指标几个关键指标第1个是弱光弱光现在目前是0.51也是在上周持续在向好即将达到零点五的那个目标。三代终端也是因为全年因为去年的目标是七点五今年要求的年度目标是五点五所以说这个需要1个持续往下压的过程这个也是较上周提升了0.1个百分点就是下降了哈就是有有改善。所以说这个是年度目标的话我也跟司空建议了这个指标它会拉入到运维我也建议他们那个每1个季度设1个阶段性的目标不然的话全市的话五点五都达不到现在目前。FPTR现在是达标了就上周这块那达标哈主动过境这块是靠后零点三这是1个上门之后的效果。
未知说话人01
02:07
然后那个九零工程转化率要达到90%退单率要低于七点五。然后整个月度来看的话我们现在是转化率是87.35离90的目标还差一点点。但是当周来看的话主要是天气影响啊。当周的虽然上了80%哈但是离90的目标还是差的很远。但是月度来看的话都是转化率还是有很大的提升哈接近90退单率是6.53。整个退单的那个改约的情况是影响那个转化率的1个主要因素主要是用户原因二十九单覆盖六单天气两单。
未知说话人01
02:41
整个退单来看的话,主要是用户不安装退单十六单卡,长期无法连续三单。
未知说话人01
02:48
还有入错四单低价值入错建设用户入网不通哈这块。3块蓝单的话就是上周的话那个没达到80%哈56.82整体的当月的转化率是75.29也是比八十还是稍微还差一点的哈退单率是44.29。
未知说话人01
03:07
然后退单率比较高的原因主要还是那个B to C的这1块的那个协调问题。这块1个是审核撤单的退单的流程审核已经给施工是那个建议了。当时那个院里经理也专门跟我打电话哈他也是明白了我们想做的那个这个这个这个审核的1个流程意识他们也在正在开发。然后同时的话也那个这个指标也会也会是我们综合运维的1个考核指标。我们也建议了施工是上次来也建议他们这个退单率应该看能不能提出这种分居长如果这种一线审批如果同意了我觉得还是应该提出的啊。不然的话这种团单来了之后啊都是都是都是他自己有一些个人的意识在里面要退单啊。
未知说话人01
03:53
PCDN那个专线持续在恶化然后主要还是学校就是当当月3月份的话是开学之后啊是4.84这个的话1个就是我们把那个涉及到的IP地址已经报市公司帮我们分析这个出口的学校的IP地址因为目前数据我们上周跟他们沟通之后他们现在这1周才能反馈那个啊是集中在1个学校吗2个学校人文和是海南还是还是就2个学校。
未知说话人01
04:22
我但这个我报全职以后他是不是马上就会过限速啊他是要把IP地址分析出来之后要报给学校限速。学校他们现他们现在也在协同他们。啊。我们。我们也在找全职他们能不能帮我们后来限建立这种机制因为三关因为加关和启关现在都已经都施工都不通报了周报也没有了主要是现在主要问题还是在专线。问问题是专线主要还是在学校我们。学校的话因为我们只是提供他的出口带宽他的IP地址啊那些数据的话要经过那个。而且学校的话互联网带宽我们是没收费的。对免费。这个是更不应该出现问题了。哎这个他们这个学校这边的话如果有问题我觉得第1时间就应该直接就在系统上限。对我们也在想这个是正常的这个行为。我们也也想在那个。学生在沟通一下嘛。一旦发现直接限掉他也没什么好说的。他作为1个学校或者是那个什么他那个提供的是正常的那个带宽使用的这个速度。如果他那个这个超过了这个的话那这这这是有问题啊。嗯。我现在在学校全职尽快的话看有没有
未知说话人01
05:33
啊这是这是整个那个P C N啊3次空间主要1项没达标主要就是那个物业点那个本月那个。
未知说话人01
05:42
那个之前是计划的本月但是反馈天线还现在没有过预计4月初进行1个检测轨道。
未知说话人01
05:50
其他的都是达标的。
未知说话人01
05:53
然后唱厅有1项主要是那个其实问题都解决了但就是要带智能体要智能判断剩余工单要逐项的核查是否达标才能算结束。所以说虽然是处理了但是要等到质检其他指标都是达标的。然后还有1个有12项自行工作啊智能添加的就是也包括南中也的反馈就是我们有些打接电话没有接到哈。然后他们也是当周的现在目前的解决方案是爱立信5G基站参数的调整试点带观察效果哈这个近期反映的就是有漏话这种情况比较多5G这1块。
未知说话人01
06:32
然后那个畅想这块全部达标哈就结束了。然后那个超频站点的攻坚这块是是控制还是比较那个有帮助哈。就是上周我们发现了3个超频基站呃有2个原因是铁塔的开关电源故障导致电流过稳所以说也及时的处理就进行了1个恢复。然后还有1个就是传输光纤过大哈这个维护部人员更换前厅后恢复这个也是说把它解决了。然后旁站的故障的话也持续在也是处理也比较低。
未知说话人01
07:03
然后专线护航目前来讲的话118条专线我们目前的话有存量的73条的话已经完成巡检56条还有17条。然后未验收的工程已经建设的有45条全部进行了巡检。然后四公司要求是3月底全量完成巡检目前17条的话我们在月底能够按时进行1个全部巡检来完成。然后主要是那个涉及到存量这1块我们立即力改都改了主要还是涉及到光光缆路由的这块有抢台行动我们会及时的上报给四公司。然后上周的话我跟机科里面的培训客服出了问题主要还是他们内部的那个就是他们的机房和机柜这块的问题比较比较严重哈这块也是。今天我们会就是这1周的话这2天会把全量的清单我们的问题客服处理的问题全量的发给客服经理让他们知晓并与客户沟通。
未知说话人01
07:57
然后当周故障的话是收到报障是33件然后9件跟网络相关主要问题还是光缆这块的问题啊。
未知说话人01
08:07
然后东环呢也持续保持1个良好的1个水平上周还在全市做了1个那个交流。
未知说话人01
08:14
金沙管家这1块重点那个说一下二级因为上周小总也安排了二级。二级的话整个我们目前哈就最新的数据我们现在剩下20206年总共还剩下一百二十一站这二百一百二十一站其实上周也是抓紧处理已经全部下电了。然后目前累计完成拆除是四十六站还有75站。七十五站预计的话我们争取的话是在2周之内完成全量的拆除然后进行只管专署以及电量服务费4月的1个调整。也就相当于在4月15号之前可以全量完成二级的拆除和下电和服务费的调整。
未知说话人01
08:55
好,工程下层项目都很正常哈,除了那个他们那个,那个单子也也在补当中哈。
未知说话人01
09:06
然后其他的就是也都是正常开展的。
未知说话人01
09:09
其他就是没有啥子大的问题啊。
未知说话人01
09:12
还有就是最后1个就是网络质量综合评估体系哈虽然没有发文但是已经已经给已经发下来了。那个我们内部先收到了这1块我们会嗯后面就是下1周的话我们会针对综合网网络运维评估体系进行进行1个那个进行1个分解和目标的1个制定啊这边就没有起来了。
未知说话人02
09:46
25年剩余2项工作哈第1个花果山那个没得啥子变化的第2个微托无人机哈是说的在金南人组织验收以后是花本周哈拜访唐书记进行推动是项目组的1个。
未知说话人02
10:01
嗯上周完成了5项工作哈包括嗯这种要求建委哈对施工司管的框架下整理的工作清单哈以及投资计划专项评估汇报哈建委部答复是已经汇报并且完成。然后市场那块终端占比问题哈嗯是进行了1个全方位工工的1个重点的1个汇报哈形成了1个5个方面哈制定了具体的措施哈嗯解决问题最终又提升啊答复是已经进行了专题研讨汇报。嗯正中安排的收入地图和行业中心的收入地图哈是各中心已经完成并且经嗯要求进行了提交中核集团。嗯打印机哈那块是跟局科到现场办公室沟通以后哈现在已经是收回中国国啊专门为我们嘞调度表哈预留1.1元个打印机哈。同时啊那块我们也在思考哈如何确保哈,了解了他们日常需求的嗯打印数量哈,嗯打印情况以后哈,嗯我们在周边的哈打印店也进行了一些洽谈。
未知说话人02
11:04
最终他们的价格偏差还是很大有十倍以上。啊最后我们嗯跟建跟那个叫啥整整套是吧整套。啊整套。沟通以后到时候我们去试他有需求的时候我们陪到一起去试确保质量的情况下我们呃提供1个相当于外派点进行1个保障确保那个招投标的打印不影响招投标。嗯。
未知说话人02
11:28
然后,那个气象。
未知说话人03
11:30
那个事情给那个行业中心和那个,哎,运输中心都说,都是个人自己要晓得哦,下回不要再给我,再给我以那个理由,再说去答应你有问题哈,那都是个人的问题。
未知说话人03
11:43
请你们通知到位,好吧。
未知说话人02
11:48
然后有7项工作人在推动过程中。第1个是前期交办的预期数据中心哈整体包包装哈那块儿是说政府要拿出方案补贴方案以后的话再汇报。嗯无人机哈那个需求哈结合到落实成为合川本地的1个方案目前没得啥子变化和之前的反馈。嗯然后基建工程2个宣传工作计划中国那边在推动基建工程2个是是嗯留言后哈目前还没下发下发以后我们在优化制定。啊宣传工作那块儿目前说之后哈争取本周完成1个那个嗯推动啊因为工作人兼职的原因啊。然后那个嗯渠道那块儿呢有1个产能评估哈那块儿是计划嗯开了宣传会哈嗯还在梳理方案最后是结果情况预计在本周进行1个专题汇报。嗯专线发展节奏的问题哈希望整体思考整体将。
未知说话人02
12:40
那个数据进行了展示哈然后嗯体现的是整体情况本周专题汇报。嗯然后分局是那个资产优化的运输类的话是形成了那个清单周报。嗯然后那个每周与分局进行沟通形成1个清单的1个呃计划啊具体的优化方案嗯完成以后哈进行1个完结那是在跟进的效果。呃综合当前几点有1条有些做的工作哈请大家知晓。呃3月20号我们也召开了分公司的话呃工会主席面对面哈进行了一些沟通1个是我们的工会经费进行1个公示。啊因为整个工会经费哈从市工会到市公司哈再到分公司都有所压减啊所以今年我们需要一记严考用更少的钱哈办更好的事儿哈。然后主席给我们做了一些具体指引哈大家有啊积极发言。同时今年我们在本周需要上报市公司的软性工程哈嗯其中我们已经确定的啊可以上报哈目前沟通的哈包括我们更衣室哈包括我们食堂的改造。食堂改造那边的话嗯按照之前的方案是大概是二十九点八万哈然后我们将会进行1个审计。呃结合到大家用餐的时间哈我们提出的建议是5月哈结合
未知说话人02
14:54
更替和修补。呃,同时呃,市公司呃呃,和分公司哈,我们做了解以后哈,也是收集大家意见以后,包括啊,我们准备呃引用那个自饮机的方式哈来解决我们用水的问题哈,呃,送水啊那些呃机器也坏了,啊,自饮机那块儿的话也报了市公司哈,对批准,啊,等到去批复以后啊,我们就可以引入,大概在下半年的时候哈,啊,我们就可以用自饮机哈进行饮水,同时也节约成本。
未知说话人02
15:21
幸福一家一的活动哈全年同时组织了六场啊大型活动啊包括3月的运动会啊4月的足球5月的羽毛球6月的篮球10月的气排球和10月的乒乓球。啊分公司对于除了参加四公司活动以外哈整体还安排的有一些重大节假日的一些活动包括近期我们啊在思考筹备的6月二中节啊的1个活动以及我们那个啊班组活动哈班组活动那边的话也是整整出了大家的意见哈可能今年我们也在思考是不是搞1个我们分公司内部的班组之间的啊嗯气排球比赛啊通过那种形式哈来解决那个啊班组各自开展活动哈那种另行开展的那个方式哈然后根据的一些啊就是在1个思考过程中。嗯同时重点汇报一下周五哈计划的是第4届体育嗯文化节啊我们河川将以河川佳节哈穿越巅峰一起新嗯一起新城然后动要赛场哈1个主题口号结合到我们泡竹汤哈啊出名的1个形式通过我们方正和演员的演出哈将我们啊深情的河川河川的那种啊快乐啊热情的那种精神面貌哈展示给全市的啊兄弟公司和领导
未知说话人02
17:08
啊,原来黄翔啊,他们都是因为啊有伤在身啊,无法参加。啊,他们觉着不行啊。啊,他不得行。
未知说话人02
17:17
这是哪儿受伤了啊?呃,他肌肉拉伤。
未知说话人02
17:23
呃,没法成交吗?哎,对,他不敢跑,对吧?
未知说话人02
17:27
方向啥情况啊?呃也是。
未知说话人03
17:31
2个2个主力选手都不得行哦那那个今年是那个有点儿具体哦平时主持体格。
未知说话人02
17:38
呃,他是那么跟我说的。
未知说话人03
17:41
我29然后他们七八。
未知说话人03
17:44
看得起啊,我说没看得起黑老婆呀。
未知说话人02
17:48
啊,他不敢不敢爆发爆发性的发力,那种。如果是,如果是确实受伤了,我觉得吧,就不能勉强,因为我。
未知说话人02
17:58
然后完了以后的话就是需要各部门儿积极配合特此请示领导。1个就是我们方阵哈目前除了我们参赛的队员儿和我们呃和本人哈作为方阵队员儿以外哈可能我们容纳到20个人来进行那个活动开展。嗯再加上领导和1个举牌手和1个那个形象嗯大师以外我们一共需要25人。好我们确定的呃报名人数以外的话我们还差9个人还差9个人的话看呃结合到那个一线生产工作嘞繁忙哈嗯那个过度嗯看是不是各部门儿再出2个人哈。啊构成我们8人的方阵哈另外AI运输中心出1个人哈我们总共目前差9个人。啊确定人员的话需要今天确定确定以后我们需要给他们购买衣服。啊购买衣服以后周二到周四进行排练啊就是下班以后然后星期五都去参加活动。好那块儿的话看领导觉得合适那种分配。
未知说话人02
18:55
1个男生整起来。
未知说话人03
19:00
电脑都没地方每个部门2个人该出出来是这个想法。
未知说话人02
19:05
嗯,周二到周四要拍的呀。
未知说话人02
19:07
买了几件参加哈。
未知说话人03
19:10
不是确实跟到2个人都看不出来跟我说。
未知说话人02
19:15
性别性呢?呃,不行。
未知说话人03
19:19
大家说清楚有衣服的。
未知说话人02
19:23
同时呢参加以后介会我还上决赛哈和往年一样积极参加的还是有一定的绩效的1个加分儿只要参加方阵的是每人一分儿获获得名次的啊更多一些。
未知说话人02
19:36
第2个都是嗯请知晓今天的主题教育哈公司也是啊已经发布了第1期的教育简报啊总体来说都是第1期就是公布公司哈啊在召开了主题教育的专题的1个那个啊工作。
未知说话人02
19:51
第2个基层党组织哈就是上次我们也开了哈截止到3月15号所有的基层党组织也开展了学习后面我们再按照其那个动作来执行。好因为呃主题教育请大家知晓动作。第2个都是嗯市公司也要求哈。
未知说话人02
20:06
招待费哈要进行1个信息公开那块儿公开的对象和范围啊都是本单位嗯管理层的那个嗯班子成员哈那个范围哈。所以前期我们也是通过邮件的方式将25年啊啊招待费的形式进行1个发布和公示也完成了向纪检组织的啊报备。好请领导知晓哈这项工作已经完成。等我问一下它那个公开是每年公开1次吗还是每1次都要公开呀每年1次现在每年。
未知说话人02
20:38
然后那个25年整体招待费使用的情况哈嗯那个嗯也嗯做好做个汇报哈25年哈我们整体招待费的使用是超我们年初预算是14%哈其中综合部哈是逐年增长哈24年、25年哈逐年增长嗯增长去非常明显哈超预算也非常多哈我们综合部去年超了107%好所以26年的话也给综合部调整了预算哈其他部门因为涉及到成本的管控哈目前没有调整预算嗯26年使用来看的话哈目前也是综合部哈嗯以及我们的市场部市场部因为年初的渠道大会哈啊整体它的那个时间节奏可能不太一样哈包括我们超改分局和南京的分局哈相对使用较快哈是超过了时间进度好其他一些部门的招待费预算力度啊是是在进度范围之内好可能更多的嗯要进行嗯特别提醒嘛我觉得提醒啊整体可以嗯其实是有钱哈在那个1个范围之内可以合理的规划按时间进度哎安排更多的同志哈参与的同志都可以报哈包括嗯因为之前我们也盘点的哈嗯那个包括安运输中心
未知说话人03
22:00
情况的1个汇报和通报。看到的给我说一下就是关于政企部啊因为我们的整个对外招待集福来政企部啊。
未知说话人03
22:10
这起步那边的话呀,因为有些重要单位又集中在个别的客户经理那边儿。
未知说话人03
22:16
哦,所以说这种啊,要经历比较统筹。
未知说话人03
22:20
那个统筹安排你不能说1个客服经理。
未知说话人03
22:23
太多,是吧?然后比如说,有像那种的,比如哎,哎,服务中心的那边的街坊啊,那些也可以,就大家分到分到来,哦,那边你跟和谐商量,把那个,就说实实在在去,是真实发生的费用啊,尽快的解决和。
未知说话人03
22:43
不要拖,然后据我了解,去年子还有些费用都没还没完。
未知说话人03
22:47
今天是油来了,是吧?
未知说话人03
22:49
哦,那个事情呢,我认为它是个,不是个啥子大事情,不需要喊我们那个公公司领导市民来操心的那个事。
未知说话人02
23:01
然后河川区发了1个通知哈叫做河川区担当作为先进集体和先进个人。因为之前我们也报过河川强区啊企业的先进集体、先进个人好目前还在区领导那里已经嗯提交区领导1个因为目前区领导还没得指示。好同时收到弄了1个通知嗯那个文件的话相当于之前他的那个我们区的文件内容是嗯区级的部门儿啊单位以及区国资委啊管辖的企业。好因为今年呢领导班子也特别提到计划我们要逐渐在河川区啊加强叫做呢嗯增强我们区的影响力。所以我觉得那个可能也是1个点说我们也单独进行了一些追踪和嗯询问哈那个下发单位哈不是区委办啊府办嗯区委组织部以及区人力社保局啊他们组建成了专班。好联系人是委办1个通知和人力社保局哈看到他们他们是经办的那个负责发文的同志。好相当于嗯经过咨询1个嗯争取到相当于我们嗯如果有意意愿参加的话也可以参加。看了下前一届的那个评选名单呢没得那种我们那种企业哈都是政府的一些组织和机构。好那个
未知说话人02
24:23
首先因为它那个报名简单啊就是报材料嘛我们来签头写就说看给不给合川移动1个资格啊。有那个资格的话呀相当于可以差异化地与竞争对手拉开差距。目前竞争对手应该是对那个还是没有引起重视也没有动。啊青云集团有三十多名。
未知说话人02
24:42
有了名额嘛去报嘛这是他的程序哈。嗯要求的是3月31号之前哈也是1个网上申请需要进行1个报名。啊所以用那个事情啊下面都。
未知说话人02
24:53
的回报。
未知说话人02
24:55
啊,以上是综合部的汇报。
未知说话人03
25:00
他达到啥子水平才能够担当?
未知说话人02
25:03
啊,它跟我们平一样的,都是嗯。
未知说话人02
25:08
那20个类似于20个方身都是文字型的。
未知说话人02
25:15
不是。
未知说话人02
25:17
就说工作突成绩突出,认真负责,类似于那种那种的,他都是套话。所以说那种东西,它主要还是在于。
未知说话人02
25:27
给不给你的革命呢?
未知说话人02
25:30
负责的是尾巴还是社保局他们是联合工作组通知里头有尾巴和社保局2个联系单位应该是他们2个都在。
未知说话人02
25:39
啊,相当于突出的评选都是政治忠诚可靠啊,担当过硬啊,都是套话这个。套话。哎,所以那个工作成绩突出啊,什么,嗯,整体形象好啊,然后那个纪律严明,就像强区那个一样,实际上他也是那种历史套话。啊,所以说那块儿的话,我个人觉得还是从今历史判断来看,还是要有有推荐,然后你再去报,不然的话确实有点浪费。
未知说话人02
26:06
啊,那块儿是委办室叫陈红同志,啊,然后人力资源局是叫崔磊同志,也跟他们联系了,他们说他们都是发会议的,具体经办呢。
未知说话人02
26:16
收入跟那个差距。
未知说话人02
26:21
没得没问到。你要去了解噻。哎都是可能看那块儿是正气气了解。就是有有有。行政区可能都是就是讲它那边主要那个方面是吧然后它那个是哪1个。
未知说话人03
26:33
尾巴是是那个。
未知说话人02
26:37
对的,我都说建议争取跟能联系,因为我们问了他们的,他说我是发会议的,我不清楚,让我鼓捣他去问呢,对不?也不太高兴。嗯。
未知说话人03
26:47
那个我们没得好大好大的那个的话,我们去花那个时间也没得好大意义的。学校的练习有。
未知说话人02
26:55
我印象中好像,那是发通报的时候。
未知说话人03
26:58
好像有1个员工还有个我说你们去了解一下如果是那种的话我们也可以跟这边的领导先沟通一下。
未知说话人03
27:06
但是我现在不晓得是哪个领导在负责那个事儿。
未知说话人03
27:09
因为你至少是委办主任那那个层面嘛,或者是或者是分管那个领导嘛,去单独去了解,好吧。他们如果,如果有那个的话,我们去去争取多,确实,别个都是拼的区头的,啊,我们也不用花那个时间。
未知说话人03
27:25
是吧?了解是吧?
未知说话人03
27:29
今儿不是聊天。
未知说话人01
27:32
那个你那个整个三期要的第2期的工作做1个嗯项目汇报。现在是拆迁方面我们整个刚刚去在了解的我们一些那个拆迁目前已经完成了1145。好整个还有呢是。嗯拆迁方式我们那个通过当通过那个注册那个单位上那么逐步好那个做510000的价值提升都是他原来的通货没有达到50的我们提升到50。好那么那个会议还有里面有好多个成员都可以说那个收入。好那么还有1个都是就是我们办理那个比如说新业不拆迁都是2次通货15.1元个月。我们做了2个2次通货都是1个1个那个整体层面的1个拆迁。好那么党建网拆迁都是我们的那个那个新增的通货。好包括有文化套餐好做了那个他会一笔一笔进行进行算。好就是一季度的整个整个拆迁是主要的还是在通货场景我们原来的通货没达到50的做一些新的价值提升说完成率啊成绩比较好。那个是整个拆迁1个情况。好那么在在整个历史融合在在这些包括上面呢那个会议的那个政府那个啊还是继续
未知说话人01
30:23
单位上集中做的啊是和前方在那个还有京东他们说也也有一些社区做的啊那种家庭的清洗和我们那个相结合嘛两种方式做点看看哪种效果好。因为像在社区做的啊我们就像比如说像区区委组织部家属院建设要家属院小区啊那种和单位客户比较集中的小区啊我们去开展一些一些单位上的客户一些服务工作。啊那么在也是做的啊本周的1个做的情况本周做的啊一共开展了五场啊那个落实相对较好那个业务量的话也还是没有达到我们想要的成果好特别是在在落实上面的签约好其他的方案的话集资签约的板块的话那边我在本周啊还要进行1个加强。
未知说话人01
31:04
商客市场1个情况整个整个商客的话到了我们到了2月份收入是88.5好比上个一期增加了10000。好完成较好的是超改那个城北和南京改增幅都上了20000好一家农家乐出现了负200块钱的1个情况。啊整体个商客的1个规模情况和那个都还是比较好。啊那个在价值拓展方面的话我们是完成了115。好同的是第6和下一期我再把其他公司做1个展示。因为因为那个我们我仅分全县的问题目前只能看到个的。好排名是找上市公司个的。好我下一期把那个整个全市的1个排名好做1个展现。
未知说话人01
31:40
那个是整个价值拓展的1个情况。
未知说话人01
31:44
他那个重点的工作啊都在商客经理方面的话整个目前我们还有2个人。嗯那是以周为单位周为单位曾会流的话那个星期他的他的那个基础业务只有两笔所以说相对于较差一点。我们是以周为单位在在统计我下期把累计的嗯按累计来嘛。
未知说话人01
32:05
它那个是整个材料会吧。
未知说话人04
32:13
只需要。
未知说话人04
32:15
我份工作还有没得?
未知说话人05
32:20
在种。
未知说话人05
32:22
第1次建委那边哈那个几十铺哈。
未知说话人05
32:25
之前有个那个。
未知说话人05
32:27
想的也。
未知说话人06
32:31
那个。
未知说话人06
32:33
咸鱼粉。
未知说话人06
32:36
每个单位长一些,大约啥子?
未知说话人06
32:39
我先模仿一下。
未知说话人05
32:42
第2关然后再次接放大是那样的。
未知说话人05
32:47
就可以上清华反正第2天你已经理了一些哈我觉得能够把那个。
未知说话人05
32:51
公司人也很少的。
未知说话人05
32:55
进一步结合地方的改造。
未知说话人05
32:59
那个。
未知说话人05
33:00
厨房的一些。
未知说话人05
33:06
第2就是建委今年那个综合评估体系已经出来了。
未知说话人05
33:11
不知道怎么了。
未知说话人05
33:12
把前面。
未知说话人05
33:15
疗效。
未知说话人05
33:19
啊第2个关于正畸康复包括运输已经7号就说了第1个点。
未知说话人05
33:24
上海人多。
未知说话人05
33:26
经理要当天处理,及时回复。
未知说话人05
33:30
像刚才,中国不同宝的。
未知说话人05
33:32
那个建伟部主儿,肯定是收到,跟到给我回复,把事情是完结了。
未知说话人05
33:37
啊,我们这几块儿的经理基本上也不在那个板块的站。
未知说话人05
33:41
哦,也不好意思啊,我在会议上说了,私下也说了很多次了。
未知说话人05
33:45
要养成那种日日日清、日清日结、日结日高的好习惯。
未知说话人05
33:51
就算没完成,阶段性的来沟通一下儿,有什么问题困难,我们一起来想办法推进。
未知说话人05
33:57
你不能1个事情有些工作一说几个月都还是还没做。
未知说话人05
34:04
回复工作是首先回复安排工作的领导。
未知说话人05
34:09
而不是去肉包的时候会不丰富。
未知说话人05
34:12
我要养成好习惯,动作要推得早。
未知说话人05
34:15
不好意思啊,我都不知道得罪儿童怎么。
未知说话人05
34:18
我都当是我的嘛。
未知说话人05
34:21
并且恢复工作一定是要量化的。
未知说话人05
34:24
要有结果。
未知说话人05
34:26
要有措施,要有成效。
未知说话人05
34:33
第2关于上课这个指标我也说了很多次了我们没看到具体的保障措施。只有最后1周了早上上班的时候也跟廖经理做了沟通需要拿出具体可行的方案。
未知说话人05
34:47
那个1月5号那天我就说了是结合专线。
未知说话人05
34:51
助账账客单位的业务发展。
未知说话人05
34:56
各项指标都会好,方法我也教了,也没看到动作出来。
未知说话人05
35:01
你没看到回复。
未知说话人05
35:04
请今天要给我1个具体的回复哪个能保证那项指标达成
未知说话人05
35:13
重新想啊,其他东西。
未知说话人03
35:19
关于那个市场部的工作说。
未知说话人03
35:22
3个方面第1个方面就是。
未知说话人03
35:26
整个1个资料的1个存储安排就是。
未知说话人03
35:29
1个是本季度本本季度的1个1个1个收官之日。
未知说话人03
35:34
刚才谈电脑,按照目标来推。
未知说话人03
35:38
治疗的第2个像小方面都是基本上那个周也是。
未知说话人03
35:43
最后1周了。
未知说话人03
35:45
本本季度都是新区营销不厚,我觉得完了过后还是有一些按部就班的一些,大家有点皮,包括那个整个业务推进,市场方面也比较也比较皮软。
未知说话人03
35:57
呃,请商户提前谋划。
未知说话人03
35:59
二级部的1个业务活动那把生日。
未知说话人03
36:04
不能说二季度是传统的淡季。
未知说话人03
36:08
不管是我的签约也好商人也好H也好还是那个AI军团也好那个业务活动的那个升职还是要要造起来哈。
未知说话人03
36:19
电脑上不提。
未知说话人03
36:21
第2个方面就是那个呃上周那个根据我看那个能力地图收入地图已经已经发过来了。
未知说话人03
36:28
呃,请那个商务部去到中心。
未知说话人03
36:32
馆主。
未知说话人03
36:34
本周内,把涉及到销售能力的几个事情的进度。
未知说话人03
36:40
给我做1次报告1个是招聘的情况。
未知说话人03
36:44
第2个是农村渠道的。那个
未知说话人03
36:47
进度第3个是上周营销那个事情。
未知说话人03
36:52
他没在1个。
未知说话人03
36:53
具体的方案西消队伍建设和转型本周内给我1个这3个事情。
未知说话人03
37:00
做个1次汇报。
未知说话人03
37:03
3个字蚂蚁多。
未知说话人03
37:06
满意度这1次满意度的测评结果上周五林哥说了个话。
未知说话人03
37:13
我当时问,还是觉得这个事情,不管从我还是。
未知说话人03
37:18
商务部和政企部,这个工作确实是。
未知说话人03
37:22
有烛火。
未知说话人03
37:27
这次是那个,都是,应该是最后,就是按照老的方式来测评嘛。
未知说话人03
37:33
下1块就是用啊用后机评来测评啊。
未知说话人03
37:36
3月份还有。
未知说话人02
37:38
有前期的,没就会咯。但前期不会在错。
未知说话人03
37:44
所以说这个事情,我觉得,但是真的要反思一下儿。
未知说话人03
37:49
满一度的工作,你是做了吗?还是做到了?
未知说话人03
37:55
正起步之前都是我们满一做了1个1个拉分项1个开卷考试。廖经理是不是思想上松懈了因为除了夏总参加的客户会我看到廖经理之外其他次的客户会廖经理都不在。
未知说话人03
38:11
都是1个都是1个货开卷儿炸。
未知说话人03
38:15
我觉得这个事情你应该是可控的,你才不,你才不关注这个事情。
未知说话人03
38:23
补充句啊,近期不要主观对这个。
未知说话人03
38:27
除廖经理以外,你们哪个主管对郑启满队负责?
未知说话人03
38:31
我支持好看点吧。
未知说话人03
38:34
我上次上次不是跟廖经理说的,如果说货款有,这个我们就抓起来啊,让他亲自抓。
未知说话人07
38:50
麻烦等会儿,那个。
未知说话人07
38:52
我觉得现在就是叫啥子啊,亡羊补牢为时未晚,这个事情现在还有。
未知说话人07
39:00
还有时间,但是我觉得市场不和自己。
未知说话人07
39:05
在在抓这个工作的时候,老师出了问题才重视,才才反转,才抓微笑曲线。我觉得这种工作方式和工作态度,你说我们要反思一下。
未知说话人07
39:17
另外就是关于那个,就是客户的工作,中中国的时候我也跟跟那个。
未知说话人07
39:24
财务部的领导做的够好,嗯,有几点。
未知说话人07
39:28
也是同步市场部也是给项目做1个做。
未知说话人07
39:32
就是那个第1个都是八零的两率。
未知说话人07
39:36
就是到3月底特别是3月底就这个话关键门儿说市公司会有统一。
未知说话人07
39:44
是不是会通用?所以说,就是它的满意的样板儿。
未知说话人07
39:48
下来就是你跟那个小付一起,在我办公室我们讨论一下,满意客户满意的样板一定要按照四公司的规定来。
未知说话人07
39:58
第2个是对于不满的客户。
未知说话人07
40:03
它有2个时间节点1个是五点半1个是五点五点半过后。
未知说话人07
40:08
还有五点半有几个操作的1个有1个有个技巧。同时不满的客户要报警客户。因为现在不是这个指标不是KPK考核集团的但是啊总是要求的要考核分公司的。所以说这个事情在操作上面是有空间的。
未知说话人07
40:32
然后那个80200000000我也看。
未知说话人07
40:36
那还是不要我们目前是属于一类的到属于第2的左右。
未知说话人07
40:42
也是也是不是低于全市的平均,所以说我觉得这个事情也是没有快速的把市公司的有一些技巧性的动作启动。
未知说话人07
40:58
好,在考题上面都是八零两率,是有打印后面的。
未知说话人07
41:04
这个是可以,是个是可以来平衡的。
未知说话人07
41:08
好,这块儿的话,就是请那个。
未知说话人07
41:11
商户,把每天的那个,就是每天,就是到到月底之前,每天微信上面出个日报。
未知说话人07
41:21
发送给发给我和小红嘛。
未知说话人07
41:25
然后,然后高关于考核,我也大概问了一下。
未知说话人07
41:29
嗯,法律两界还是有那个,就是有可能有比较。
未知说话人07
41:33
有可能又不高也没说定。今年的KPAN考还基本上定了是工信部的有责。
未知说话人07
41:40
要考KPI啊还有就是你的网率就是跟满意度相关的客户不走就是按照那个观点驾驶提升就你的网率不高于去年你应该是个你应该是会拿个KPI来考核。
未知说话人08
41:57
应该是1个就是。
未知说话人08
42:00
目前都是定了的就是2个考核1个是工信部有责1个是。
未知说话人08
42:04
都去。
未知说话人07
42:06
8.2月是8.2月是有有打印。
未知说话人07
42:11
大概是这个样子,所以说把那个考核的情况你进一步再去摸细,还有那些都是可以可控的操作的一些动作。
未知说话人07
42:23
跟子公司的话。
未知说话人07
42:27
对吧商务部都说的3个方面商务部的话我们最关注是把运动会、本周运动会。
未知说话人07
42:33
对朱子豪。
未知说话人07
42:35
嗯,我是星期跟下午到了,我要星期星期星期三、星期四出差,星期五到时候。
未知说话人07
42:43
然后有啥事情,就是给我报告的事情。如果是来点计划,就明天明天,明儿下午,反正说几个事情。
未知说话人07
42:53
给我汇报,如果来不及的话,都都用视频。好,星期星期五上午。
未知说话人09
43:06
红火亮的樱桃。
未知说话人10
43:11
KPI。
未知说话人10
43:13
刚才会上我们也过了1遍按照我们定好的目标确保完成。
未知说话人10
43:20
好,马云的话。
未知说话人10
43:22
具体的一些情况,请你们这个会后。
未知说话人10
43:27
1个详细规划。
未知说话人10
43:29
啊,我想强调的是。
未知说话人10
43:32
自信的。
未知说话人10
43:34
我觉得,不管是部门儿还是。
未知说话人10
43:38
不管是部门经理还是主管还是具体抓工作的人和每1个一线的同志们大家都要有那个执行力。
未知说话人10
43:47
事情要办法,事不做好。
未知说话人10
43:52
要知道怎么做,但是就是不去做。
未知说话人10
43:56
那我觉得那是我们公司至少。
未知说话人10
43:59
我们班子几个人是,我相信都不认可的那种工作作风。
未知说话人10
44:10
还有4公司的那个年度考核。
未知说话人10
44:13
觉得几个部门主要是去了解情况啊,那个提前也给你们给说过几次。
未知说话人10
44:19
啊,如果有。
未知说话人10
44:21
对我们影响比较大的一些病毒,或者是。
未知说话人10
44:25
你要提前讲,不要等到起那个,别个都定好了再来跟我说,夏总,那个考核是今年子我们要照。
未知说话人10
44:34
对,我说很。
未知说话人10
44:37
我们把工作做在前面你们细细了解。我其实有点担心2个哈1个是。
未知说话人10
44:43
1个叫啥子啊那个阿布咖啡。
未知说话人10
44:46
那个全市那个集团公司有1个啥子阿普提升啥子考的我不知道他怎么考我不清楚。
未知说话人10
44:53
很默契。
未知说话人10
44:55
提升或者改善,或者啥,阿婆的啥子啥子。
未知说话人10
44:58
我觉得那个对,我我们公司,我不知道说不有。
未知说话人10
45:02
我们要去,不管哪个样子,我觉得考核,我们其实我们不要求上市公司好像要要要对其他公司有啥子。
未知说话人10
45:10
我要去想那些事情但是至少是比较公平的。我们通过努力我们可以去进行1个得乎点儿改善的。
未知说话人10
45:18
最主要单位。
未知说话人10
45:20
有提醒过,我我那边的话也会。
未知说话人10
45:24
2个分管领导里面的话也跟他们2个主要部门嘛政企部跟。
未知说话人10
45:29
他说:“我们加强力度嘛,然后如果确实有有必要的话,哈,我们可以去。”
未知说话人10
45:35
也是。
未知说话人10
45:36
好,那些沟通。
未知说话人10
45:39
是1个年龄的1个非常重要的一点啊不要开始我们处在起跑线上那个很麻烦不像我印象我们当时那个近视还在有1年说我们那个啥子。
未知说话人10
45:50
心也不靠海,是吧?
未知说话人10
45:53
开始都是主要在细胞线上面,啊,最开始为啥子不够通行?有那种卡位口结和那些。
未知说话人10
45:59
是吧?我也相信,市公司他也应该会考虑分公司的整体情况,然后统筹来,至少大家都能够调高摸得度,是吧?
未知说话人10
46:14
今天的周会就到这儿结束。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@


View File

@ -0,0 +1,107 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION - REFERENCE ONLY] Earlier turns were compacted into the "
"summary below. Treat it as background reference, not as fresh user input. "
"Prefer the latest user message over this summary.\n\n"
)
@dataclass(slots=True)
class CompressionResult:
summary_message: Dict[str, str] | None
tail_messages: List[Dict[str, str]]
did_compact: bool
estimated_tokens: int
class RollingContextCompressor:
def __init__(
self,
*,
max_input_tokens: int = 12000,
keep_last_turns: int = 3,
summary_char_limit: int = 4000,
) -> None:
self.max_input_tokens = max_input_tokens
self.keep_last_turns = keep_last_turns
self.summary_char_limit = summary_char_limit
self.rolling_summary = ""
def compact(self, history: List[Dict[str, str]]) -> CompressionResult:
estimated_tokens = self.estimate_tokens(history, self.rolling_summary)
if estimated_tokens <= self.max_input_tokens:
return CompressionResult(
summary_message=self._summary_message() if self.rolling_summary else None,
tail_messages=list(history),
did_compact=False,
estimated_tokens=estimated_tokens,
)
tail_count = max(0, self.keep_last_turns * 2)
tail_messages = list(history[-tail_count:]) if tail_count else []
middle_messages = history[:-tail_count] if tail_count else list(history)
if not middle_messages:
return CompressionResult(
summary_message=self._summary_message() if self.rolling_summary else None,
tail_messages=tail_messages,
did_compact=False,
estimated_tokens=estimated_tokens,
)
merged = self._merge_summary(self.rolling_summary, self._summarize_messages(middle_messages))
self.rolling_summary = merged[: self.summary_char_limit].strip()
return CompressionResult(
summary_message=self._summary_message(),
tail_messages=tail_messages,
did_compact=True,
estimated_tokens=self.estimate_tokens(tail_messages, self.rolling_summary),
)
def estimate_tokens(self, history: List[Dict[str, str]], summary: str = "") -> int:
text = summary + "\n".join(f"{item.get('role', '')}: {item.get('content', '')}" for item in history)
return max(1, len(text) // 4)
def _summary_message(self) -> Dict[str, str]:
return {"role": "assistant", "content": SUMMARY_PREFIX + self.rolling_summary}
def _merge_summary(self, existing: str, fresh: str) -> str:
if not existing:
return fresh
return (
"## Prior Summary\n"
f"{existing.strip()}\n\n"
"## Newly Compacted Turns\n"
f"{fresh.strip()}"
)
def _summarize_messages(self, messages: List[Dict[str, str]]) -> str:
facts: List[str] = []
latest_user = ""
latest_assistant = ""
for message in messages:
role = message.get("role", "")
content = str(message.get("content", "")).strip()
if not content:
continue
compact = " ".join(content.split())
if len(compact) > 240:
compact = compact[:240] + "..."
if role == "user":
latest_user = compact
facts.append(f"- User asked: {compact}")
elif role == "assistant":
latest_assistant = compact
facts.append(f"- Assistant responded: {compact}")
else:
facts.append(f"- {role}: {compact}")
lines = ["## Active Task", latest_user or "Continue the latest user request.", ""]
lines.append("## Recent Progress")
lines.extend(facts[-12:] or ["- No earlier progress captured."])
lines.extend(["", "## Remaining Work", latest_assistant or "Use the latest visible conversation state to continue."])
return "\n".join(lines).strip()

View File

@ -0,0 +1,104 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
try:
from dotenv import load_dotenv
except Exception: # pragma: no cover
load_dotenv = None
@dataclass(slots=True)
class CoreAgentConfig:
model: str
api_key: Optional[str]
base_url: Optional[str]
timeout: float = 120.0
temperature: float = 0.2
max_iterations: int = 12
user_name: str = "User"
agent_name: str = "MeetingAgent"
def load_core_agent_env(env_dir: str | Path | None = None) -> Optional[Path]:
candidates = []
if env_dir:
candidates.append(Path(env_dir) / ".env")
candidates.append(Path.cwd() / ".env")
for path in candidates:
if not path.exists():
continue
if load_dotenv is not None:
load_dotenv(path, override=False)
else:
_load_env_without_dependency(path)
return path
return None
def apply_compat_env_aliases() -> None:
if not os.getenv("OPENAI_API_KEY") and os.getenv("API_KEY"):
os.environ["OPENAI_API_KEY"] = os.environ["API_KEY"]
if not os.getenv("OPENAI_BASE_URL") and os.getenv("BASE_URL"):
os.environ["OPENAI_BASE_URL"] = os.environ["BASE_URL"]
if not os.getenv("CORE_AGENT_MODEL") and os.getenv("MODEL_NAME"):
os.environ["CORE_AGENT_MODEL"] = os.environ["MODEL_NAME"]
def build_core_agent_config() -> CoreAgentConfig:
return CoreAgentConfig(
model=os.getenv("CORE_AGENT_MODEL") or os.getenv("MODEL_NAME") or os.getenv("MODEL") or "",
api_key=os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL") or os.getenv("BASE_URL"),
timeout=float(os.getenv("OPENAI_TIMEOUT", "120")),
temperature=float(os.getenv("OPENAI_TEMPERATURE", "0.2")),
max_iterations=int(os.getenv("CORE_AGENT_MAX_ITERATIONS", "12")),
user_name=os.getenv("USER_NAME", "User"),
agent_name=os.getenv("AGENT_NAME", "MeetingAgent"),
)
def require_model_config(config: CoreAgentConfig) -> None:
missing = []
if _is_missing_or_placeholder(config.api_key):
missing.append("OPENAI_API_KEY")
if _is_missing_or_placeholder(config.base_url):
missing.append("OPENAI_BASE_URL")
if _is_missing_or_placeholder(config.model):
missing.append("MODEL_NAME or CORE_AGENT_MODEL")
if missing:
joined = ", ".join(missing)
raise RuntimeError(
"缺少大模型配置:" + joined + "。请复制 .env.example 为 .env并填写 OpenAI-compatible API 配置;"
"如果只想本地演示工具流程,请加 --offline。"
)
def _load_env_without_dependency(path: Path) -> None:
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
def _is_missing_or_placeholder(value: Optional[str]) -> bool:
if value is None or not str(value).strip():
return True
normalized = str(value).strip().lower()
placeholders = {
"your-api-key-here",
"your-base-url-here",
"your-model-name-here",
"sk-xxx",
"xxx",
"changeme",
}
return normalized in placeholders or normalized.startswith("your-")

View File

@ -0,0 +1,2 @@
class AgentCancelled(RuntimeError):
pass

View File

@ -0,0 +1,320 @@
from __future__ import annotations
from dataclasses import dataclass, field
import json
from pathlib import Path
import re
import traceback
from typing import Any, Dict, Iterator, List, Optional
from core_agent.compression import RollingContextCompressor
from prompts import DEFAULT_SYSTEM_PROMPT, build_system_prompt
from providers.base import AgentProvider, AssistantTurn
from skills import SkillStore
from tools.default_tools import build_default_registry
from tools.registry import ToolContext, ToolRegistry
from tools.tool_trace import ToolTraceStore, ToolTraceTurn
@dataclass(slots=True)
class ChatEvent:
type: str
delta: str = ""
tool_name: str = ""
tool_args: Dict[str, Any] = field(default_factory=dict)
tool_result: str = ""
final_response: str = ""
turn_content: str = ""
turn_reasoning: str = ""
iteration: int = 0
max_iterations: int = 0
final_reason: str = ""
@dataclass(slots=True)
class AgentRunResult:
final_response: str
messages: List[Dict[str, Any]]
session: Dict[str, Any] = field(default_factory=dict)
from core_agent.exceptions import AgentCancelled
class ConversationSession:
def __init__(
self,
*,
provider: AgentProvider,
workspace: str | Path,
session_id: str = "default",
skill_dirs: Optional[List[str | Path]] = None,
tool_registry: Optional[ToolRegistry] = None,
system_prompt: Optional[str] = None,
max_iterations: int = 12,
max_history_turns: int = 20,
max_persisted_turns: int = 20,
active_skills: Optional[List[str]] = None,
) -> None:
self.provider = provider
self.workspace = Path(workspace).resolve()
self.session_id = _sanitize_session_id(session_id)
self.skill_store = SkillStore(skill_dirs or [self.workspace / "skills"])
self.tool_registry = tool_registry or build_default_registry(self.workspace / "data")
self.system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
self.max_iterations = max_iterations
self.max_history_turns = max_history_turns
self.max_persisted_turns = max_persisted_turns
self.session_dir = self.workspace / "agent_memory" / "sessions" / self.session_id
self.history_path = self.session_dir / "recent_history.json"
self.tool_trace_store = ToolTraceStore(self.session_dir / "tool_trace.json", max_turns=max_history_turns)
self.context_compressor = RollingContextCompressor()
self.session_state: Dict[str, Any] = {
"workspace": str(self.workspace),
"active_skills": list(active_skills or []),
"session_id": self.session_id,
"_cancel_requested": False,
}
self.history: List[Dict[str, Any]] = self._load_recent_history()
self.last_messages: List[Dict[str, Any]] = []
def ask(self, user_message: str) -> AgentRunResult:
final: ChatEvent | None = None
for event in self.stream_ask(user_message):
final = event
if final is None or final.type != "final":
raise RuntimeError("Conversation ended without a final response")
return AgentRunResult(final_response=final.final_response, messages=list(self.last_messages), session=dict(self.session_state))
def request_cancel(self) -> None:
self.session_state["_cancel_requested"] = True
def clear_cancel(self) -> None:
self.session_state["_cancel_requested"] = False
def cancellation_requested(self) -> bool:
return bool(self.session_state.get("_cancel_requested"))
def _raise_if_cancelled(self) -> None:
if self.cancellation_requested():
raise AgentCancelled("Agent run cancelled by user.")
def stream_ask(self, user_message: str) -> Iterator[ChatEvent]:
self.clear_cancel()
messages = self.build_messages(user_message)
self.last_messages = list(messages)
current_trace_turn = self.tool_trace_store.begin_turn(user_message)
repeat_counter: dict[str, int] = {}
_REPEAT_LIMIT = 3
tool_name_counter: dict[str, int] = {}
_TOOL_NAME_LIMIT = 6
for iteration in range(1, self.max_iterations + 1):
self._raise_if_cancelled()
yield ChatEvent(type="round_start", iteration=iteration, max_iterations=self.max_iterations)
try:
turn: AssistantTurn | None = None
for stream_event in self.provider.stream_generate(messages, self.tool_registry.definitions(), should_cancel=self.cancellation_requested):
self._raise_if_cancelled()
if stream_event.type == "reasoning" and stream_event.delta:
yield ChatEvent(
type="reasoning_delta",
delta=stream_event.delta,
iteration=iteration,
max_iterations=self.max_iterations,
)
elif stream_event.type == "content" and stream_event.delta:
yield ChatEvent(
type="content_delta",
delta=stream_event.delta,
iteration=iteration,
max_iterations=self.max_iterations,
)
elif stream_event.type == "turn":
turn = stream_event.turn
if turn is None:
raise RuntimeError("Provider stream ended without a final turn")
self._raise_if_cancelled()
except AgentCancelled as exc:
final = str(exc)
self.last_messages = list(messages)
self.tool_trace_store.finish_turn(current_trace_turn, final)
yield ChatEvent(type="final", final_response=final, iteration=iteration, max_iterations=self.max_iterations, final_reason="cancelled")
return
except Exception as exc:
final = str(exc)
self.last_messages = list(messages)
self.tool_trace_store.finish_turn(current_trace_turn, final)
yield ChatEvent(type="final", final_response=final, iteration=iteration, max_iterations=self.max_iterations, final_reason="provider_error")
return
yield ChatEvent(
type="assistant_turn",
turn_content=turn.content or "",
turn_reasoning=turn.reasoning or "",
iteration=iteration,
max_iterations=self.max_iterations,
)
messages.append(_assistant_message(turn))
if not turn.tool_calls:
repeat_counter.clear()
tool_name_counter.clear()
final = turn.content or ""
self._finalize_turn(user_message, final, current_trace_turn)
self.last_messages = list(messages)
yield ChatEvent(type="final", final_response=final, iteration=iteration, max_iterations=self.max_iterations, final_reason="completed")
return
ctx = ToolContext(workspace=self.workspace, session=self.session_state, tool_trace_store=self.tool_trace_store)
repeated_loop_detected = False
repeated_loop_message = ""
for call in turn.tool_calls:
self._raise_if_cancelled()
call_key = json.dumps({"name": call.name, "args": call.arguments}, sort_keys=True, ensure_ascii=False)
repeat_counter[call_key] = repeat_counter.get(call_key, 0) + 1
if repeat_counter[call_key] >= _REPEAT_LIMIT:
repeated_loop_detected = True
repeated_loop_message = "检测到重复工具调用(相同参数连续调用 {0} 次),已自动终止循环。请检查工具参数或换用更大参数的模型。".format(_REPEAT_LIMIT)
break
tool_name_counter[call.name] = tool_name_counter.get(call.name, 0) + 1
if tool_name_counter[call.name] >= _TOOL_NAME_LIMIT:
repeated_loop_detected = True
repeated_loop_message = "检测到同一工具「{0}」被连续调用 {1} 次(参数略有不同但未产生有效进展),已自动终止循环。建议换用更大参数的模型。".format(call.name, _TOOL_NAME_LIMIT)
break
yield ChatEvent(type="tool_call", tool_name=call.name, tool_args=call.arguments, iteration=iteration, max_iterations=self.max_iterations)
try:
result = self.tool_registry.execute(call.name, call.arguments, ctx)
except AgentCancelled as exc:
final = str(exc)
self.last_messages = list(messages)
self.tool_trace_store.finish_turn(current_trace_turn, final)
yield ChatEvent(type="final", final_response=final, iteration=iteration, max_iterations=self.max_iterations, final_reason="cancelled")
return
except Exception as exc:
result = _tool_error_result(call.name, call.arguments, exc)
self.tool_trace_store.record_tool_call(
current_trace_turn,
tool_name=call.name,
arguments=call.arguments,
thought=turn.reasoning or "",
result=result,
tool_call_id=call.id,
)
messages.append({"role": "tool", "tool_call_id": call.id, "name": call.name, "content": result})
yield ChatEvent(type="tool_result", tool_name=call.name, tool_args=call.arguments, tool_result=result, iteration=iteration, max_iterations=self.max_iterations)
if repeated_loop_detected:
final = repeated_loop_message
self._finalize_turn(user_message, final, current_trace_turn)
self.last_messages = list(messages)
yield ChatEvent(type="final", final_response=final, iteration=iteration, max_iterations=self.max_iterations, final_reason="repeat_loop_detected")
return
messages[0]["content"] = self._system_prompt()
final = "Agent reached max_iterations before producing a final response."
self._finalize_turn(user_message, final, current_trace_turn)
self.last_messages = list(messages)
yield ChatEvent(type="final", final_response=final, iteration=self.max_iterations, max_iterations=self.max_iterations, final_reason="max_iterations")
def build_messages(self, user_message: str) -> List[Dict[str, Any]]:
recent_history = self.history[-self.max_history_turns * 6 :]
compression = self.context_compressor.compact(recent_history)
messages: List[Dict[str, Any]] = [{"role": "system", "content": self._system_prompt()}]
if compression.summary_message:
messages.append(compression.summary_message)
messages.extend(compression.tail_messages)
messages.append({"role": "user", "content": user_message})
self.session_state["compression"] = {
"did_compact": compression.did_compact,
"estimated_tokens": compression.estimated_tokens,
"has_summary": bool(compression.summary_message),
}
return messages
def persisted_dialog_messages(self) -> List[Dict[str, str]]:
return list(self.history)
def _append_history(self, user_message: str, final_content: str) -> None:
self.history.append({"role": "user", "content": user_message})
self.history.append({"role": "assistant", "content": final_content})
self._trim_recent_history()
self._save_recent_history()
def _finalize_turn(self, user_message: str, final_content: str, trace_turn: ToolTraceTurn) -> None:
self._append_history(user_message, final_content)
self.tool_trace_store.finish_turn(trace_turn, final_content)
def _load_recent_history(self) -> List[Dict[str, str]]:
if not self.history_path.is_file():
return []
try:
raw = json.loads(self.history_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
items = raw.get("messages", []) if isinstance(raw, dict) else []
history: List[Dict[str, str]] = []
for item in items:
if not isinstance(item, dict):
continue
role = str(item.get("role", "")).strip()
content = str(item.get("content", "")).strip()
if role not in {"user", "assistant"} or not content:
continue
history.append({"role": role, "content": content})
return history[-self.max_persisted_turns * 2 :]
def _save_recent_history(self) -> None:
self.history_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"session_id": self.session_id,
"messages": self.history[-self.max_persisted_turns * 2 :],
"max_persisted_turns": self.max_persisted_turns,
}
self.history_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _trim_recent_history(self) -> None:
self.history = self.history[-self.max_persisted_turns * 2 :]
def _system_prompt(self) -> str:
return build_system_prompt(
skill_store=self.skill_store,
active_skills=self.session_state.get("active_skills", []),
tool_registry=self.tool_registry,
workspace=str(self.workspace),
base_prompt=self.system_prompt,
)
def _assistant_message(turn: AssistantTurn) -> Dict[str, Any]:
message: Dict[str, Any] = {"role": "assistant", "content": turn.content or ""}
if turn.tool_calls:
message["tool_calls"] = [
{
"id": call.id,
"type": "function",
"function": {"name": call.name, "arguments": json.dumps(call.arguments, ensure_ascii=False)},
}
for call in turn.tool_calls
]
return message
def _tool_error_result(tool_name: str, tool_args: Dict[str, Any], exc: Exception) -> str:
payload = {
"success": False,
"error": f"{type(exc).__name__}: {exc}",
"tool_name": tool_name,
"tool_args": tool_args,
"retryable": False,
"traceback": traceback.format_exc(limit=8),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
def _sanitize_session_id(value: str) -> str:
text = re.sub(r"[^A-Za-z0-9._-]+", "_", str(value or "default")).strip("._")
return text or "default"

31
dispatch.py 100644
View File

@ -0,0 +1,31 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
from uuid import uuid4
@dataclass(slots=True)
class Task:
id: str
title: str
description: str
status: str = "ready"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class TaskDispatcher:
"""Small task-board placeholder matching the reference project's extension point."""
def __init__(self) -> None:
self._tasks: dict[str, Task] = {}
def create_task(self, *, title: str, description: str) -> Task:
task = Task(id=f"task_{uuid4().hex[:8]}", title=title, description=description)
self._tasks[task.id] = task
return task
def list_tasks(self) -> list[dict[str, Any]]:
return [task.to_dict() for task in self._tasks.values()]

8
frontend_nanobot/.gitignore vendored 100644
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.vite/
coverage/
*.tsbuildinfo
.env
.env.*
!.env.example

View File

@ -0,0 +1,101 @@
# nanobot WebUI Source
This directory contains the React/TypeScript source for the nanobot WebUI. If
you installed `nanobot-ai` from PyPI and only want to use the bundled browser UI,
read the user guide in [`docs/webui.md`](../docs/webui.md). You do not need
Node.js, Bun, Vite, or anything in this directory unless you are changing the
frontend.
For the project overview, install guide, and general docs map, see the root [`README.md`](../README.md) and [`docs/README.md`](../docs/README.md).
## Pick a Path
| Goal | Start with | Opens at |
|---|---|---|
| Use the bundled browser UI | [`docs/webui.md`](../docs/webui.md) | `http://127.0.0.1:8765` |
| Use the WebUI from another device | [`docs/webui.md#lan-access`](../docs/webui.md#lan-access) | `http://<your-ip>:8765` |
| Change WebUI source code | [Develop the WebUI (Vite HMR)](#develop-the-webui-vite-hmr) | `http://127.0.0.1:5173` |
| Debug setup failures | [`docs/troubleshooting.md#webui-problems`](../docs/troubleshooting.md#webui-problems) | Diagnosis order and common fixes |
The source app is built with Vite + React 18 + TypeScript + Tailwind 3 +
shadcn/ui. It talks to the gateway over the WebSocket multiplex protocol and
reads session metadata from the embedded REST surface on the same port.
## Layout
```text
webui/ source tree (this directory)
../webui/dist/ build output served by the gateway
```
## Develop the WebUI (Vite HMR)
### 1. Install nanobot from source
From the repository root:
```bash
python -m pip install -e .
```
> Editable installs intentionally **skip** the WebUI bundle step — Vite HMR is faster than rebuilding `dist/` on every change.
### 2. Enable the WebSocket channel
In `~/.nanobot/config.json`, merge:
```json
{ "channels": { "websocket": { "enabled": true } } }
```
### 3. Start the gateway
In one terminal:
```bash
nanobot gateway
```
### 4. Start the WebUI dev server
In another terminal:
```bash
cd webui
bun install # npm install also works
bun run dev
```
Then open `http://127.0.0.1:5173`.
By default the dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to `http://127.0.0.1:8765`.
If your gateway listens on a non-default port, point the dev server at it:
```bash
NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
```
## Build for packaged runtime
You usually do not need to run this by hand: `python -m build` invokes the WebUI build automatically when packaging the wheel.
If you want to preview the production bundle locally without rebuilding the wheel:
```bash
cd webui
bun run build # writes to ../webui/dist in this project
```
The gateway picks up the new bundle on the next restart.
## Test
```bash
cd webui
bun run test
```
## Acknowledgements
- [`agent-chat-ui`](https://github.com/langchain-ai/agent-chat-ui) for UI and interaction inspiration across the chat surface.

View File

@ -0,0 +1,952 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "nanobot-webui",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.0.6",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"tailwind-merge": "^2.6.0",
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"happy-dom": "^16.3.0",
"katex": "^0.16.21",
"postcss": "^8.5.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vitest": "^2.1.8",
},
},
},
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
"@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="],
"@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="],
"@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="],
"@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
"character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"happy-dom": ["happy-dom@16.8.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
"hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
"hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="],
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"i18next": ["i18next@26.2.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="],
"is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="],
"rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="],
"remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="],
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
"hastscript/comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="],
"hastscript/property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="],
"hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
"mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
"stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"mdast-util-mdx-jsx/parse-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"mdast-util-mdx-jsx/parse-entities/character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"mdast-util-mdx-jsx/parse-entities/is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
}
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"hooks": "@/hooks",
"lib": "@/lib"
}
}

View File

@ -0,0 +1,31 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["dist", "coverage", "node_modules", "*.config.js"],
linterOptions: {
reportUnusedDisableDirectives: "off",
},
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.es2020,
},
},
plugins: {
"react-hooks": reactHooks,
},
rules: {
"react-hooks/rules-of-hooks": "error",
},
},
);

View File

@ -0,0 +1,186 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta
name="description"
content="nanobot web UI — chat with your nanobot workspace."
data-i18n-meta="description"
/>
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#161618" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/png" sizes="32x32" href="/brand/nanobot_favicon_32.png" />
<link rel="icon" type="image/png" sizes="73x75" href="/brand/nanobot_icon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/brand/nanobot_apple_touch.png" />
<style>
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: #ffffff;
color: #0a0a0a;
font-family:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", "Noto Sans SC",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
}
html.dark body {
background: #1a1a1a;
color: #fafafa;
}
.boot-splash {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
}
.boot-splash-inner {
display: flex;
align-items: center;
gap: 10px;
color: rgba(255, 255, 255, 0.74);
font-size: 14px;
}
html:not(.dark) .boot-splash-inner {
color: rgba(10, 10, 10, 0.64);
}
.boot-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
background: currentColor;
opacity: 0.75;
animation: boot-pulse 1s ease-in-out infinite;
}
@keyframes boot-pulse {
0%,
100% {
transform: scale(0.9);
opacity: 0.45;
}
50% {
transform: scale(1);
opacity: 0.85;
}
}
</style>
<script>
(function () {
try {
var stored = localStorage.getItem("nanobot-webui.theme");
var dark =
stored === "dark" ||
(!stored &&
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
} catch {}
})();
</script>
<script>
(function () {
var localeKey = "nanobot.locale";
var copy = {
en: {
boot: "Loading nanobot…",
description: "nanobot web UI — chat with your nanobot workspace."
},
"zh-CN": {
boot: "正在加载 nanobot…",
description: "nanobot Web UI —— 与你的 nanobot 工作区对话。"
},
"zh-TW": {
boot: "正在載入 nanobot…",
description: "nanobot Web UI —— 與你的 nanobot 工作區對話。"
},
fr: {
boot: "Chargement de nanobot…",
description: "Interface web nanobot — discutez avec votre espace de travail nanobot."
},
ja: {
boot: "nanobot を読み込み中…",
description: "nanobot Web UI — nanobot ワークスペースと会話します。"
},
ko: {
boot: "nanobot 불러오는 중…",
description: "nanobot 웹 UI — nanobot 작업공간과 대화하세요."
},
es: {
boot: "Cargando nanobot…",
description: "Interfaz web de nanobot: conversa con tu espacio de trabajo de nanobot."
},
vi: {
boot: "Đang tải nanobot…",
description: "Giao diện web nanobot — trò chuyện với workspace nanobot của bạn."
},
id: {
boot: "Memuat nanobot…",
description: "UI web nanobot — ngobrol dengan workspace nanobot Anda."
}
};
function normalizeLocale(input) {
if (!input) return "en";
var raw = String(input).trim();
if (!raw) return "en";
if (copy[raw]) return raw;
var lower = raw.toLowerCase();
if (lower === "zh" || lower.indexOf("zh-cn") === 0 || lower.indexOf("zh-sg") === 0) {
return "zh-CN";
}
if (
lower.indexOf("zh-tw") === 0 ||
lower.indexOf("zh-hk") === 0 ||
lower.indexOf("zh-mo") === 0 ||
lower.indexOf("zh-hant") === 0
) {
return "zh-TW";
}
var base = lower.split("-")[0];
return copy[base] ? base : "en";
}
try {
var stored = localStorage.getItem(localeKey);
var detected =
stored ||
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
"en";
var locale = normalizeLocale(detected);
var localized = copy[locale] || copy.en;
document.documentElement.lang = locale;
var description = document.querySelector('[data-i18n-meta=\"description\"]');
if (description) description.setAttribute("content", localized.description);
var boot = document.querySelector("[data-boot-copy]");
if (boot) boot.textContent = localized.boot;
} catch {}
})();
</script>
<title>nanobot</title>
</head>
<body class="bg-background text-foreground antialiased">
<div id="root">
<div class="boot-splash">
<div class="boot-splash-inner">
<span class="boot-dot" aria-hidden="true"></span>
<span data-boot-copy>Loading nanobot…</span>
</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7147
frontend_nanobot/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
{
"name": "nanobot-webui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.build.json && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --max-warnings 0"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.0.6",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"happy-dom": "^16.3.0",
"katex": "^0.16.21",
"postcss": "^8.5.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"typescript-eslint": "^8.59.4",
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,173 @@
import { useState, type ReactNode } from "react";
import { FileIcon, ImageIcon, PlaySquare } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { UIMediaAttachment } from "@/lib/types";
interface AttachmentTileProps {
attachment: UIMediaAttachment;
className?: string;
inline?: boolean;
variant?: "default" | "compact";
}
export function AttachmentTile({ attachment, className, inline = false, variant = "default" }: AttachmentTileProps) {
const { t } = useTranslation();
const [failed, setFailed] = useState(false);
const hasUrl = typeof attachment.url === "string" && attachment.url.length > 0;
const label = attachmentLabel(attachment, t);
if (attachment.kind === "image" && hasUrl && !failed) {
return (
<AttachmentFrame
attachment={attachment}
className={className}
inline={inline}
variant={variant}
>
<a
href={attachment.url}
target="_blank"
rel="noreferrer noopener"
className="block bg-muted/20"
aria-label={attachment.name ? `Open ${attachment.name}` : t("lightbox.open", { defaultValue: "Open image" })}
>
<img
src={attachment.url}
alt={attachment.name ?? ""}
loading="lazy"
decoding="async"
draggable={false}
onError={() => setFailed(true)}
className={cn(
"block h-auto max-w-full bg-background object-contain",
variant === "compact" ? "max-h-40" : "max-h-[34rem]",
)}
/>
</a>
</AttachmentFrame>
);
}
if (attachment.kind === "video" && hasUrl) {
return (
<AttachmentFrame
attachment={attachment}
className={className}
inline={inline}
variant={variant}
>
<video
src={attachment.url}
controls
preload="auto"
className={cn(
"block w-full bg-black",
variant === "compact" ? "max-h-40" : "max-h-[26rem]",
)}
aria-label={attachment.name ? `${t("message.videoAttachment", { defaultValue: "Video attachment" })}: ${attachment.name}` : t("message.videoAttachment", { defaultValue: "Video attachment" })}
/>
</AttachmentFrame>
);
}
const Icon = attachment.kind === "video"
? PlaySquare
: attachment.kind === "image"
? ImageIcon
: FileIcon;
const body = (
<>
<Icon className="h-4 w-4 flex-none" aria-hidden />
<span className="min-w-0 truncate">{attachment.name ?? label}</span>
</>
);
if (hasUrl && !failed) {
return (
<a
href={attachment.url}
download={attachment.name ?? label}
title={attachment.name ?? undefined}
aria-label={label}
className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
"border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
"transition-colors hover:bg-muted/55 hover:text-foreground",
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
className,
)}
>
{body}
</a>
);
}
return (
<div
className={cn(
"flex max-w-[18rem] items-center gap-2 rounded-[14px]",
"border border-border/60 bg-muted/35 px-3 py-2 text-xs text-muted-foreground",
variant === "compact" && "max-w-[14rem] rounded-xl px-2.5 py-1.5 text-[11.5px]",
className,
)}
title={attachment.name ?? undefined}
aria-label={label}
>
{body}
<span className="sr-only">
{t("message.attachmentUnavailable", { defaultValue: "Attachment unavailable" })}
</span>
</div>
);
}
function AttachmentFrame({
attachment,
children,
className,
inline = false,
variant = "default",
}: {
attachment: UIMediaAttachment;
children: ReactNode;
className?: string;
inline?: boolean;
variant?: "default" | "compact";
}) {
const frameClassName = cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/60 bg-muted/40",
attachment.kind === "image" && "bg-background/85",
attachment.kind === "video" ? "w-[min(100%,32rem)]" : "",
variant === "compact" && "my-1 rounded-xl shadow-none",
variant === "compact" && attachment.kind === "video" && "w-[min(100%,20rem)]",
className,
);
const bodyClassName = "block max-w-full";
const body = inline ? (
<span className={bodyClassName}>{children}</span>
) : (
<div className={bodyClassName}>{children}</div>
);
return inline ? (
<span className={frameClassName}>
{body}
</span>
) : (
<figure className={frameClassName}>
{body}
</figure>
);
}
function attachmentLabel(attachment: UIMediaAttachment, t: ReturnType<typeof useTranslation>["t"]): string {
if (attachment.kind === "video") {
return t("message.videoAttachment", { defaultValue: "Video attachment" });
}
if (attachment.kind === "image") {
return t("message.imageAttachment", { defaultValue: "Image attachment" });
}
return t("message.fileAttachment", { defaultValue: "File attachment" });
}

View File

@ -0,0 +1,584 @@
import {
memo,
useEffect,
useMemo,
useState,
} from "react";
import {
ChevronRight,
Archive,
ArchiveRestore,
Folder,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Plus,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { deriveTitle, relativeTime } from "@/lib/format";
import {
COLLAPSED_CHATS_VISIBLE_COUNT,
displayTitle,
groupSessions,
isCollapsedProject,
isFoldableChatsGroup,
isFoldedChatsGroup,
limitGroups,
visibleSessionsForGroup,
type ChatGroupLabels,
} from "@/lib/chat-groups";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
import { sameWorkspacePath } from "@/lib/workspace";
const INITIAL_VISIBLE_SESSIONS = 160;
const VISIBLE_SESSIONS_INCREMENT = 160;
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
const ACTION_MENU_ITEM_CLASS = "grid w-[7.75rem] grid-cols-[1rem_minmax(0,1fr)] items-center gap-2";
interface ChatListProps {
sessions: ChatSummary[];
activeKey: string | null;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onToggleGroup?: (groupId: string) => void;
onRequestRenameProject?: (projectKey: string, label: string) => void;
onNewChatInProject?: (projectPath: string, projectName: string) => void;
onSelectProjectWorkspace?: (projectPath: string, projectName: string) => void;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
updatedChatIds?: string[];
density?: SidebarDensity;
showPreviews?: boolean;
showTimestamps?: boolean;
sort?: SidebarSortMode;
showArchived?: boolean;
defaultWorkspacePath?: string | null;
currentWorkspacePath?: string | null;
actionMenuPortalContainer?: HTMLElement | null;
loading?: boolean;
emptyLabel?: string;
}
export const ChatList = memo(function ChatList({
sessions,
activeKey,
onSelect,
onRequestDelete,
onTogglePin,
onRequestRename,
onToggleArchive,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
onSelectProjectWorkspace,
pinnedKeys = [],
archivedKeys = [],
titleOverrides = {},
projectNameOverrides = {},
collapsedGroups = {},
runningChatIds = [],
updatedChatIds = [],
density = "comfortable",
showPreviews = false,
showTimestamps = false,
sort = "updated_desc",
showArchived = false,
defaultWorkspacePath,
currentWorkspacePath,
actionMenuPortalContainer,
loading,
emptyLabel,
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
archived: t("chat.groups.archived"),
projects: t("chat.groups.projects"),
fallbackTitle: t("chat.newChat"),
}), [t]);
const groups = useMemo(
() => groupSessions(sessions, labels, {
pinnedKeys,
archivedKeys,
titleOverrides,
projectNameOverrides,
showArchived,
sort,
defaultWorkspacePath,
}),
[
archivedKeys,
labels,
pinnedKeys,
sessions,
showArchived,
sort,
titleOverrides,
projectNameOverrides,
defaultWorkspacePath,
],
);
const limitedGroups = useMemo(
() => limitGroups(groups, visibleLimit, activeKey, collapsedGroups),
[activeKey, collapsedGroups, groups, visibleLimit],
);
const totalSessionCount = useMemo(
() => groups.reduce(
(total, group) =>
total + (isCollapsedProject(group, collapsedGroups) ? 0 : group.sessions.length),
0,
),
[collapsedGroups, groups],
);
const visibleSessionCount = useMemo(
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
[limitedGroups],
);
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
useEffect(() => {
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
{t("chat.loading")}
</div>
);
}
if (sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
{emptyLabel ?? t("chat.noSessions")}
</div>
);
}
const pinned = new Set(pinnedKeys);
const archived = new Set(archivedKeys);
const running = new Set(runningChatIds);
const updated = new Set(updatedChatIds);
const compact = density === "compact";
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<div className="min-w-0 space-y-3 px-2 py-1.5">
{limitedGroups.map((group) => {
const foldableChatsGroup = isFoldableChatsGroup(group);
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
const visibleSessions = visibleSessionsForGroup(
group,
activeKey,
collapsedGroups,
);
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
return (
<section key={group.id} aria-label={group.label}>
{group.kind === "project" ? (
<ProjectGroupHeader
label={group.label}
path={group.projectPath}
collapsed={Boolean(collapsedGroups[group.id])}
active={sameWorkspacePath(group.projectPath, currentWorkspacePath)}
onToggle={() => onToggleGroup?.(group.id)}
onSelectWorkspace={
group.projectPath && onSelectProjectWorkspace
? () => onSelectProjectWorkspace(group.projectPath ?? "", group.label)
: undefined
}
onRequestRename={
group.projectKey && onRequestRenameProject
? () => onRequestRenameProject(group.projectKey ?? "", group.label)
: undefined
}
onNewChat={
group.projectPath && onNewChatInProject
? () => onNewChatInProject(group.projectPath ?? "", group.label)
: undefined
}
actionMenuPortalContainer={actionMenuPortalContainer}
updatedAt={showTimestamps ? group.updatedAt : null}
/>
) : (
<ChatsGroupHeader label={group.label} />
)}
{group.kind === "project" && collapsedGroups[group.id] ? null : (
<ul className="space-y-0.5">
{visibleSessions.map((s) => {
const active = s.key === activeKey;
const fallbackTitle = t("chat.fallbackTitle", {
id: s.chatId.slice(0, 6),
});
const generatedTitle = s.title?.trim() || "";
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
const tooltipTitle =
titleOverrides[s.key]?.trim() ||
generatedTitle ||
deriveTitle(s.preview, fallbackTitle);
const isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key);
const preview = s.preview.trim();
const showPreview = showPreviews && preview && preview !== title;
const timestamp = showTimestamps
? relativeTime(s.updatedAt ?? s.createdAt)
: "";
const projectMode = group.kind === "project";
const activityState = running.has(s.chatId)
? "running"
: updated.has(s.chatId) && !active
? "updated"
: null;
return (
<li key={s.key} className="min-w-0">
<div
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
compact ? "min-h-7" : "min-h-8",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
>
<button
type="button"
onClick={() => onSelect(s.key)}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
compact ? "py-1" : "py-1.5",
projectMode && "pl-7",
)}
>
{projectMode ? (
<span className="flex w-full min-w-0 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-medium leading-5">
{title}
</span>
{timestamp ? (
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
{timestamp}
</span>
) : null}
</span>
) : (
<span className="block w-full truncate font-medium leading-5">
{title}
</span>
)}
{showPreview ? (
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
{preview}
</span>
) : null}
{timestamp && !projectMode ? (
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
{timestamp}
</span>
) : null}
</button>
<SessionActivityIndicator state={activityState} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={ACTION_MENU_CONTENT_CLASS}
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => onTogglePin(s.key)}
className={ACTION_MENU_ITEM_CLASS}
>
{isPinned ? (
<PinOff className="h-4 w-4 shrink-0" />
) : (
<Pin className="h-4 w-4 shrink-0" />
)}
{isPinned ? t("chat.unpin") : t("chat.pin")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)}
className={ACTION_MENU_ITEM_CLASS}
>
<Pencil className="h-4 w-4 shrink-0" />
{t("chat.rename")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onToggleArchive(s.key)}
className={ACTION_MENU_ITEM_CLASS}
>
{isArchived ? (
<ArchiveRestore className="h-4 w-4 shrink-0" />
) : (
<Archive className="h-4 w-4 shrink-0" />
)}
{isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className={cn(
ACTION_MENU_ITEM_CLASS,
"text-destructive focus:text-destructive",
)}
>
<Trash2 className="h-4 w-4 shrink-0" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
)}
{foldableChatsGroup && canToggleFold ? (
<ChatsFoldFooter
folded={foldedChatsGroup}
hiddenCount={hiddenInGroup}
onToggle={() => onToggleGroup?.(group.id)}
/>
) : null}
</section>
);
})}
{hiddenSessionCount > 0 ? (
<div className="px-2 pb-2 pt-1">
<button
type="button"
onClick={() =>
setVisibleLimit((limit) =>
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT),
)
}
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground/65 transition-colors hover:bg-sidebar-accent/65 hover:text-muted-foreground"
>
{t("chat.showMore", { count: hiddenSessionCount })}
</button>
</div>
) : null}
</div>
</div>
);
});
function ProjectGroupHeader({
label,
path,
collapsed,
active,
onToggle,
onSelectWorkspace,
onRequestRename,
onNewChat,
actionMenuPortalContainer,
updatedAt,
}: {
label: string;
path?: string;
collapsed: boolean;
active: boolean;
onToggle: () => void;
onSelectWorkspace?: () => void;
onRequestRename?: () => void;
onNewChat?: () => void;
actionMenuPortalContainer?: HTMLElement | null;
updatedAt?: string | null;
}) {
const { t } = useTranslation();
return (
<div
title={path}
className="group flex min-w-0 items-center gap-1 px-1 pb-1 pt-1 text-[12px] font-medium text-muted-foreground/78"
>
<button
type="button"
aria-expanded={!collapsed}
onClick={onToggle}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground/72 transition-colors hover:bg-sidebar-accent/45 hover:text-sidebar-foreground"
aria-label={collapsed ? t("chat.expandProject", { project: label, defaultValue: `Expand ${label}` }) : t("chat.collapseProject", { project: label, defaultValue: `Collapse ${label}` })}
>
<ChevronRight
className={cn("h-3.5 w-3.5 transition-transform", !collapsed && "rotate-90")}
aria-hidden
/>
</button>
<button
type="button"
onClick={onSelectWorkspace}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-sidebar-accent/45 hover:text-sidebar-foreground",
active && "bg-sidebar-accent/65 text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]",
)}
aria-current={active ? "true" : undefined}
>
<Folder className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
{updatedAt ? (
<span className="shrink-0 text-[11px] text-muted-foreground/55">
{relativeTime(updatedAt)}
</span>
) : null}
{onRequestRename ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100",
)}
aria-label={t("chat.actions", { title: label })}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={ACTION_MENU_CONTENT_CLASS}
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem onSelect={onRequestRename} className={ACTION_MENU_ITEM_CLASS}>
<Pencil className="h-4 w-4 shrink-0" />
{t("chat.rename")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{onNewChat ? (
<button
type="button"
aria-label={t("chat.newInProject", { project: label })}
title={t("chat.newInProject", { project: label })}
onClick={(event) => {
event.stopPropagation();
onNewChat();
}}
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100",
)}
>
<Plus className="h-3.5 w-3.5" />
</button>
) : null}
</div>
);
}
function ChatsGroupHeader({ label }: { label: string }) {
return (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{label}
</div>
);
}
function ChatsFoldFooter({
folded,
hiddenCount,
onToggle,
}: {
folded: boolean;
hiddenCount: number;
onToggle: () => void;
}) {
const { t, i18n } = useTranslation();
const collapsedFallback = i18n.resolvedLanguage?.startsWith("zh")
? `已折叠 ${hiddenCount} 个对话`
: `${hiddenCount} hidden chats`;
return (
<div className="px-2 pb-1 pt-1">
<button
type="button"
onClick={onToggle}
className="h-7 w-full rounded-xl text-left text-[12px] font-medium text-muted-foreground/65 transition-colors hover:bg-sidebar-accent/50 hover:text-muted-foreground"
>
<span className="px-2">
{folded
? t("chat.collapsed", {
count: hiddenCount,
defaultValue: collapsedFallback,
})
: t("chat.showLess")}
</span>
</button>
</div>
);
}
function SessionActivityIndicator({
state,
}: {
state: "running" | "updated" | null;
}) {
const { t } = useTranslation();
if (state === "running") {
const label = t("chat.activity.running");
return (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-3 w-3 animate-spin rounded-full border border-blue-500/25 border-t-blue-500 [animation-duration:1.4s] motion-reduce:animate-none dark:border-blue-400/25 dark:border-t-blue-400" />
</span>
);
}
if (state === "updated") {
const label = t("chat.activity.updated");
return (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-2 w-2 rounded-full bg-[#ff8a3d] shadow-[0_0_0_2px_rgba(255,138,61,0.16)]" />
</span>
);
}
return <span className="h-4 w-4 shrink-0" aria-hidden="true" />;
}

View File

@ -0,0 +1,291 @@
import { useEffect, useMemo, useState } from "react";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export type CliAppMentionSegment =
| { kind: "text"; text: string }
| { kind: "cli"; text: string; app: CliAppInfo };
export type CapabilityMentionSegment =
| CliAppMentionSegment
| { kind: "mcp"; text: string; preset: McpPresetInfo };
export function cliAppInitials(app: CliAppInfo): string {
const value = app.display_name || app.name;
return (
value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase()
);
}
export function mcpPresetInitials(preset: Pick<McpPresetInfo, "name" | "display_name">): string {
const value = preset.display_name || preset.name;
return (
value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || preset.name.slice(0, 2).toUpperCase()
);
}
export function splitCliAppMentionSegments(
value: string,
cliApps: CliAppInfo[],
): CliAppMentionSegment[] {
if (!value || cliApps.length === 0) return value ? [{ kind: "text", text: value }] : [];
const appsByName = new Map(
cliApps
.filter((app) => app.installed)
.map((app) => [app.name.toLowerCase(), app]),
);
if (appsByName.size === 0) return [{ kind: "text", text: value }];
const segments: CliAppMentionSegment[] = [];
const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(value)) !== null) {
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const app = appsByName.get(name.toLowerCase());
if (!app) continue;
const mentionStart = match.index + prefix.length;
const mentionEnd = mentionStart + name.length + 1;
if (mentionStart > cursor) {
segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
}
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
cursor = mentionEnd;
}
if (cursor < value.length) {
segments.push({ kind: "text", text: value.slice(cursor) });
}
return segments.length ? segments : [{ kind: "text", text: value }];
}
export function splitCapabilityMentionSegments(
value: string,
cliApps: CliAppInfo[],
mcpPresets: McpPresetInfo[] = [],
): CapabilityMentionSegment[] {
if (!value || (cliApps.length === 0 && mcpPresets.length === 0)) {
return value ? [{ kind: "text", text: value }] : [];
}
const cliAppsByName = new Map(
cliApps
.filter((app) => app.installed)
.map((app) => [app.name.toLowerCase(), app]),
);
const mcpPresetsByName = new Map(
mcpPresets
.filter((preset) => preset.installed && preset.configured)
.map((preset) => [preset.name.toLowerCase(), preset]),
);
if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0) {
return [{ kind: "text", text: value }];
}
const segments: CapabilityMentionSegment[] = [];
const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(value)) !== null) {
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const key = name.toLowerCase();
const app = cliAppsByName.get(key);
const preset = app ? null : mcpPresetsByName.get(key);
if (!app && !preset) continue;
const mentionStart = match.index + prefix.length;
const mentionEnd = mentionStart + name.length + 1;
if (mentionStart > cursor) {
segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
}
if (app) {
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
} else if (preset) {
segments.push({ kind: "mcp", text: value.slice(mentionStart, mentionEnd), preset });
}
cursor = mentionEnd;
}
if (cursor < value.length) {
segments.push({ kind: "text", text: value.slice(cursor) });
}
return segments.length ? segments : [{ kind: "text", text: value }];
}
export function CliAppMentionText({
text,
cliApps,
mcpPresets = [],
}: {
text: string;
cliApps: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
}) {
const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets);
if (!segments.some((segment) => segment.kind === "cli" || segment.kind === "mcp")) return <>{text}</>;
return (
<>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
if (segment.kind === "cli") return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="message"
/>
);
return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="message"
/>
);
})}
</>
);
}
export function CliAppMentionToken({
app,
label,
variant,
isHero = false,
}: {
app: CliAppInfo;
label: string;
variant: "composer" | "message";
isHero?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = app.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
const logoUrl = logoUrls[logoIndex];
const showLogo = Boolean(logoUrl);
const testIdPrefix = variant === "composer" ? "composer" : "message";
useEffect(() => setLogoIndex(0), [app.logo_url]);
return (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
title={`CLI app: ${app.display_name || app.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
style={{ lineHeight: "inherit" }}
>
@
{showLogo ? (
<span
data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
>
<img
src={logoUrl ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
) : null}
</span>
{mentionName}
</span>
);
}
export function McpPresetMentionToken({
preset,
label,
variant,
isHero = false,
}: {
preset: McpPresetInfo;
label: string;
variant: "composer" | "message";
isHero?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = preset.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
const logoUrl = logoUrls[logoIndex];
const showLogo = Boolean(logoUrl);
const testIdPrefix = variant === "composer" ? "composer" : "message";
useEffect(() => setLogoIndex(0), [preset.logo_url]);
return (
<span
data-testid={`${testIdPrefix}-mcp-mention-${preset.name}`}
title={`MCP server: ${preset.display_name || preset.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
style={{ lineHeight: "inherit" }}
>
@
{showLogo ? (
<span
data-testid={`${testIdPrefix}-mcp-mention-logo-${preset.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
>
<img
src={logoUrl ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
) : null}
</span>
{mentionName}
</span>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}

View File

@ -0,0 +1,281 @@
import { Suspense, lazy, useCallback, useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useThemeValue } from "@/hooks/useTheme";
import { hasAnsi, parseAnsiSegments, stripAnsi } from "@/lib/ansi";
import { cn } from "@/lib/utils";
interface CodeBlockProps {
language?: string;
code: string;
className?: string;
chrome?: "default" | "none";
highlight?: boolean;
showLineNumbers?: boolean;
wrapLongLines?: boolean;
}
interface HighlightedCodeProps {
language?: string;
code: string;
isDark: boolean;
chrome: "default" | "none";
showLineNumbers: boolean;
wrapLongLines: boolean;
}
const CODE_FONT_STACK = [
'"JetBrains Mono"',
'"SFMono-Regular"',
'"SF Mono"',
'"Fira Code"',
'"Cascadia Code"',
'"Source Code Pro"',
"Menlo",
"Consolas",
"monospace",
].join(", ");
const ANSI_LANGUAGES = new Set(["ansi", "ansi-output"]);
const CODE_SURFACE_LIGHT = "#f4f4f5";
const CODE_SURFACE_DARK = "#27272a";
const LazyHighlightedCode = lazy(async () => {
const [
{ default: SyntaxHighlighter },
{ default: oneDark },
{ default: oneLight },
] = await Promise.all([
import("react-syntax-highlighter/dist/esm/prism-async-light"),
import("react-syntax-highlighter/dist/esm/styles/prism/one-dark"),
import("react-syntax-highlighter/dist/esm/styles/prism/one-light"),
]);
return {
default({
language,
code,
isDark,
chrome,
showLineNumbers,
wrapLongLines,
}: HighlightedCodeProps) {
const theme = isDark ? oneDark : oneLight;
const transparentTheme = chrome === "none" ? {
...theme,
'pre[class*="language-"]': {
...theme['pre[class*="language-"]'],
background: "transparent",
},
'code[class*="language-"]': {
...theme['code[class*="language-"]'],
background: "transparent",
},
} : theme;
return (
<SyntaxHighlighter
language={language || "text"}
style={transparentTheme}
customStyle={{
background: chrome === "none"
? "transparent"
: isDark
? CODE_SURFACE_DARK
: CODE_SURFACE_LIGHT,
margin: 0,
padding: chrome === "none" ? "0.75rem 1rem" : "1rem",
fontFamily: CODE_FONT_STACK,
fontSize: chrome === "none" ? "13px" : "0.875rem",
lineHeight: chrome === "none" ? 1.55 : 1.6,
tabSize: 2,
}}
codeTagProps={{
style: {
background: "transparent",
fontFamily: CODE_FONT_STACK,
},
}}
lineNumberStyle={{
minWidth: "2.6em",
paddingRight: "1.15rem",
color: isDark ? "rgba(212, 212, 216, 0.45)" : "rgba(63, 63, 70, 0.68)",
fontFamily: CODE_FONT_STACK,
userSelect: "none",
}}
PreTag="pre"
showLineNumbers={showLineNumbers}
wrapLongLines={wrapLongLines}
>
{code}
</SyntaxHighlighter>
);
},
};
});
function renderPlainText(value: string): ReactNode {
return value;
}
function renderAnsiText(value: string): ReactNode {
return parseAnsiSegments(value).map((segment, index) => (
<span key={index} style={segment.style}>
{segment.text}
</span>
));
}
function CodeTextBlock({
code,
chrome,
showLineNumbers,
testId,
className,
renderText = renderPlainText,
}: {
code: string;
chrome: "default" | "none";
showLineNumbers: boolean;
testId: string;
className?: string;
renderText?: (value: string) => ReactNode;
}) {
const lines = code.split("\n");
return (
<pre
className={cn(
"m-0 overflow-x-auto p-4 font-mono text-sm leading-[1.6] text-foreground/90",
showLineNumbers ? "whitespace-pre" : "whitespace-pre-wrap",
chrome === "default" ? "bg-zinc-100 dark:bg-zinc-800" : "bg-transparent",
chrome === "none" && "p-3 text-[13px] leading-[1.55]",
className,
)}
data-testid={testId}
>
<code className="text-inherit">
{showLineNumbers ? (
lines.map((line, index) => (
<span key={index} className="flex min-w-max">
<span className="w-10 shrink-0 select-none pr-4 text-right text-muted-foreground/60">
{index + 1}
</span>
<span className="whitespace-pre">{renderText(line || " ")}</span>
{index < lines.length - 1 ? "\n" : null}
</span>
))
) : renderText(code)}
</code>
</pre>
);
}
function shouldRenderAnsi(language: string | undefined, code: string): boolean {
const normalized = language?.trim().toLowerCase();
return Boolean((normalized && ANSI_LANGUAGES.has(normalized)) || hasAnsi(code));
}
export function CodeBlock({
language,
code,
className,
chrome = "default",
highlight = true,
showLineNumbers = false,
wrapLongLines = true,
}: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isDark = useThemeValue() === "dark";
const hasChrome = chrome === "default";
const renderAnsi = shouldRenderAnsi(language, code);
const onCopy = useCallback(() => {
if (!navigator.clipboard) return;
navigator.clipboard.writeText(renderAnsi ? stripAnsi(code) : code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1_500);
});
}, [code, renderAnsi]);
return (
<div
className={cn(
"not-prose overflow-hidden",
hasChrome && "rounded-lg border",
hasChrome && (isDark ? "border-white/10" : "border-black/10"),
className,
)}
>
{hasChrome ? (
<div
className={cn(
"flex items-center justify-between px-4 pb-1.5 pt-2 text-xs font-medium",
isDark
? "bg-zinc-800 text-zinc-300"
: "bg-zinc-100 text-zinc-600",
)}
>
<span className="lowercase font-mono">
{language || t("code.fallbackLanguage")}
</span>
<button
type="button"
onClick={onCopy}
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
isDark
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
)}
aria-label={t("code.copyAria")}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
) : null}
{renderAnsi ? (
<CodeTextBlock
code={code}
chrome={chrome}
showLineNumbers={showLineNumbers}
testId="ansi-code"
renderText={renderAnsiText}
/>
) : highlight ? (
<Suspense
fallback={
<CodeTextBlock
code={code}
chrome={chrome}
showLineNumbers={showLineNumbers}
testId="plain-code-fallback"
/>
}
>
<LazyHighlightedCode
language={language}
code={code}
isDark={isDark}
chrome={chrome}
showLineNumbers={showLineNumbers}
wrapLongLines={wrapLongLines}
/>
</Suspense>
) : (
<CodeTextBlock
code={code}
chrome={chrome}
showLineNumbers={showLineNumbers}
testId="plain-code-fallback"
/>
)}
</div>
);
}

View File

@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types";
const COPY: Record<ConnectionStatus, { color: string }> = {
idle: { color: "text-muted-foreground" },
connecting: {
color: "text-amber-700 dark:text-amber-300",
},
open: {
color: "text-emerald-700 dark:text-emerald-400",
},
reconnecting: {
color: "text-amber-700 dark:text-amber-300",
},
closed: {
color: "text-muted-foreground",
},
error: {
color: "text-destructive",
},
};
export function ConnectionBadge() {
const { t } = useTranslation();
const { client } = useClient();
const [status, setStatus] = useState<ConnectionStatus>(client.status);
useEffect(() => client.onStatus(setStatus), [client]);
const meta = COPY[status];
const pulsing =
status === "connecting" ||
status === "reconnecting" ||
status === "error";
const label = t(`connection.${status}`);
return (
<span
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
"text-muted-foreground/70 hover:bg-sidebar-accent/65",
meta.color,
)}
aria-live="polite"
role="status"
title={label}
>
<span className="relative flex h-2 w-2" aria-hidden>
{pulsing && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
)}
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
</span>
<span className="sr-only">{label}</span>
</span>
);
}

View File

@ -0,0 +1,162 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { TFunction } from "i18next";
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format";
import type { SessionAutomationJob } from "@/lib/types";
interface DeleteConfirmProps {
open: boolean;
title: string;
automations?: SessionAutomationJob[];
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteConfirm({
open,
title,
automations = [],
onCancel,
onConfirm,
}: DeleteConfirmProps) {
const { t } = useTranslation();
const locale = currentLocale();
const hasAutomations = automations.length > 0;
const visibleAutomations = automations.slice(0, 4);
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
return (
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<AlertDialogContent
className="w-[min(calc(100vw-2rem),24rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl data-[state=open]:zoom-in-95 sm:rounded-[28px]"
>
<AlertDialogHeader className="items-center space-y-0 text-center">
<div className="mb-5 grid h-16 w-16 place-items-center rounded-full bg-destructive/10 text-destructive">
<div className="grid h-9 w-9 place-items-center rounded-full border border-destructive/20 bg-destructive/5">
<Trash2 className="h-5 w-5" strokeWidth={2.4} aria-hidden />
</div>
</div>
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{t("deleteConfirm.title", { title })}
</AlertDialogTitle>
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
{hasAutomations
? t("deleteConfirm.automationsDescription")
: t("deleteConfirm.description")}
</AlertDialogDescription>
{hasAutomations ? (
<div className="mt-4 max-h-40 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
{visibleAutomations.map((job) => (
<div key={job.id} className="min-w-0 py-1.5">
<div className="truncate text-[13px] font-medium leading-5 text-foreground">
{job.name || job.id}
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-[11.5px] leading-5 text-muted-foreground">
<span className="truncate">
{formatAutomationSchedule(job, t, locale)}
</span>
<span aria-hidden>·</span>
<span className="truncate">{formatAutomationNextRun(job, t, locale)}</span>
</div>
</div>
))}
{hiddenCount > 0 ? (
<div className="text-[13px] leading-6 text-muted-foreground">
{t("deleteConfirm.moreAutomations", {
count: hiddenCount,
})}
</div>
) : null}
</div>
) : null}
</AlertDialogHeader>
<AlertDialogFooter className="mt-7 !grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2 sm:space-x-0">
<AlertDialogCancel
onClick={onCancel}
className="mt-0 h-11 w-full min-w-0 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
>
{t("deleteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="h-11 w-full min-w-0 !whitespace-normal rounded-full bg-destructive px-5 text-center text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
>
{hasAutomations
? t("deleteConfirm.confirmWithAutomations")
: t("deleteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function formatAutomationSchedule(
job: SessionAutomationJob,
t: TFunction,
locale: string,
): string {
if (job.schedule.kind === "at" && job.schedule.at_ms) {
return t("deleteConfirm.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
}
if (job.schedule.kind === "every" && job.schedule.every_ms) {
return t("deleteConfirm.schedule.every", {
duration: formatDuration(job.schedule.every_ms, locale),
});
}
if (job.schedule.kind === "cron" && job.schedule.expr) {
return job.schedule.tz
? t("deleteConfirm.schedule.cronWithTz", {
expr: job.schedule.expr,
tz: job.schedule.tz,
})
: t("deleteConfirm.schedule.cron", { expr: job.schedule.expr });
}
return t("deleteConfirm.schedule.unknown");
}
function formatAutomationNextRun(
job: SessionAutomationJob,
t: TFunction,
locale: string,
): string {
if (!job.enabled) return t("deleteConfirm.next.disabled");
const next = job.state.next_run_at_ms;
if (!next) return t("deleteConfirm.next.none");
return t("deleteConfirm.next.label", { time: fmtDateTime(next, locale) });
}
function formatDuration(ms: number, locale: string): string {
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
["second", 1000],
];
for (const [unit, size] of units) {
if (ms >= size && ms % size === 0) {
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
maximumFractionDigits: 0,
}).format(ms / size);
}
}
return new Intl.NumberFormat(locale, {
style: "unit",
unit: "minute",
unitDisplay: "long",
maximumFractionDigits: 1,
}).format(ms / 60_000);
}

View File

@ -0,0 +1,287 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { AlertCircle, ChevronRight, FileText, Loader2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CodeBlock } from "@/components/CodeBlock";
import { splitFilePath } from "@/components/FileReferenceChip";
import { ApiError, fetchFilePreview } from "@/lib/api";
import type { FilePreviewPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
interface FilePreviewPanelProps {
sessionKey: string;
path: string;
token: string;
desktopWidth?: number;
isClosing?: boolean;
onResizeStart?: (event: ReactPointerEvent<HTMLButtonElement>) => void;
onClose: () => void;
}
type PreviewState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; payload: FilePreviewPayload };
function supportsHoverCloseControl(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
export function FilePreviewPanel({
sessionKey,
path,
token,
desktopWidth = 544,
isClosing = false,
onResizeStart,
onClose,
}: FilePreviewPanelProps) {
const { t } = useTranslation();
const [state, setState] = useState<PreviewState>({ status: "loading" });
const [entered, setEntered] = useState(false);
const [supportsHoverClose, setSupportsHoverClose] = useState(supportsHoverCloseControl);
useEffect(() => {
const frame = window.requestAnimationFrame(() => setEntered(true));
return () => window.cancelAnimationFrame(frame);
}, []);
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setSupportsHoverClose(query.matches);
update();
if (typeof query.addEventListener === "function") {
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}
query.addListener(update);
return () => query.removeListener(update);
}, []);
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetchFilePreview(token, sessionKey, path)
.then((payload) => {
if (!cancelled) setState({ status: "ready", payload });
})
.catch((error: unknown) => {
if (cancelled) return;
const message = error instanceof ApiError
? (error.status === 404 && /API route not found/i.test(error.message)
? t("filePreview.routeMissing", {
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
})
: error.message)
: t("filePreview.failed", { defaultValue: "Could not preview this file." });
setState({ status: "error", message });
});
return () => {
cancelled = true;
};
}, [path, sessionKey, t, token]);
const displayPath = state.status === "ready" ? state.payload.display_path : path;
const previewPath = state.status === "ready" ? state.payload.path : displayPath;
const normalizedPreviewPath = previewPath.replace(/\\/g, "/");
const hasRootPrefix = normalizedPreviewPath.startsWith("/");
const { name } = splitFilePath(displayPath);
const breadcrumbs = useMemo(
() => normalizedPreviewPath.split("/").filter(Boolean),
[normalizedPreviewPath],
);
const compactBreadcrumbs = useMemo(
() => (breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs),
[breadcrumbs],
);
const hasCompactPrefix = breadcrumbs.length > compactBreadcrumbs.length;
return (
<aside
aria-label={t("filePreview.aria", { defaultValue: "File preview" })}
style={{
"--file-preview-width": `${desktopWidth}px`,
"--file-preview-slot-width": !entered || isClosing ? "0px" : `${desktopWidth}px`,
} as CSSProperties}
className={cn(
"absolute inset-y-0 right-0 z-30 w-[min(100vw,var(--file-preview-slot-width))] overflow-hidden",
"transition-[width] duration-300 ease-out will-change-[width]",
"md:relative md:z-auto md:w-[var(--file-preview-slot-width)] md:min-w-0 md:shrink-0",
isClosing && "pointer-events-none",
)}
data-testid="file-preview-panel"
data-file-preview-panel
>
<div
className={cn(
"absolute inset-y-0 right-0 flex w-[min(100vw,var(--file-preview-width))] flex-col overflow-hidden pb-[env(safe-area-inset-bottom)] md:w-[var(--file-preview-width)] md:pb-0",
"border-l border-border/70 bg-background shadow-2xl md:shadow-none",
"transition-[opacity,transform] duration-300 ease-out will-change-transform",
!entered || isClosing ? "translate-x-full opacity-0" : "translate-x-0 opacity-100",
"motion-reduce:translate-x-0",
)}
>
{onResizeStart ? (
<button
type="button"
aria-label={t("filePreview.resize", { defaultValue: "Resize file preview" })}
className={cn(
"group absolute inset-y-0 left-0 z-20 hidden w-3 -translate-x-1/2 cursor-col-resize touch-none md:flex",
"items-stretch justify-center focus-visible:outline-none",
)}
onPointerDown={onResizeStart}
>
<span
aria-hidden
className={cn(
"h-full w-px bg-foreground/25 opacity-0 transition-opacity",
"group-hover:opacity-100 group-focus-visible:bg-ring group-focus-visible:opacity-100",
)}
/>
</button>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border/60 px-3">
{supportsHoverClose ? (
<div
className={cn(
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-[12px]",
"bg-muted/70 px-2.5 py-1.5 text-sm font-medium",
)}
title={name || displayPath}
>
<button
type="button"
onClick={onClose}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full",
"text-muted-foreground/75 transition-[background-color,color,opacity] duration-150 ease-out",
"group-hover:bg-foreground group-hover:text-background group-hover:opacity-100",
"group-focus-within:bg-foreground group-focus-within:text-background",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
>
<FileText
className={cn(
"absolute h-4 w-4 transition-all duration-150 ease-out",
"opacity-100 group-hover:scale-75 group-hover:opacity-0",
"group-focus-within:scale-75 group-focus-within:opacity-0",
)}
aria-hidden
/>
<X
className={cn(
"absolute h-3.5 w-3.5 scale-75 opacity-0 transition-all duration-150 ease-out",
"group-hover:scale-100 group-hover:opacity-100",
"group-focus-within:scale-100 group-focus-within:opacity-100",
)}
aria-hidden
/>
</button>
<span className="min-w-0 truncate">{name || displayPath}</span>
</div>
) : (
<>
<button
type="button"
onClick={onClose}
className={cn(
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
>
<X className="h-5 w-5" aria-hidden />
</button>
<span className="min-w-0 truncate text-sm font-medium">
{name || displayPath}
</span>
</>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div
className={cn(
"flex min-h-10 shrink-0 items-center gap-1.5 overflow-hidden",
"border-b border-border/45 px-4 text-[13px] text-muted-foreground",
)}
title={previewPath}
>
<div className="flex min-w-0 items-center gap-1.5">
{hasCompactPrefix ? (
<span className="shrink-0 text-muted-foreground/55">...</span>
) : hasRootPrefix ? (
<span className="shrink-0 text-muted-foreground/55">/</span>
) : null}
{compactBreadcrumbs.length > 0 ? (
compactBreadcrumbs.map((part, index) => (
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1.5">
{index > 0 || hasCompactPrefix || hasRootPrefix ? (
<ChevronRight
className="h-3 w-3 shrink-0 text-muted-foreground/40"
aria-hidden
/>
) : null}
<span
className={cn(
"min-w-0 truncate",
index === compactBreadcrumbs.length - 1
? "font-medium text-foreground"
: "max-w-[42vw] shrink text-muted-foreground/76",
)}
>
{part}
</span>
</span>
))
) : (
<span className="truncate">{previewPath}</span>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
</div>
) : state.status === "error" ? (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
<div className="max-w-sm">
<AlertCircle className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70" aria-hidden />
<p>{state.message}</p>
</div>
</div>
) : (
<div className="min-h-full">
{state.payload.truncated ? (
<div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
{t("filePreview.truncated", {
defaultValue: "Preview is truncated because this file is large.",
})}
</div>
) : null}
<CodeBlock
language={state.payload.language}
code={state.payload.content}
chrome="none"
showLineNumbers
wrapLongLines={false}
className="min-h-full"
/>
</div>
)}
</div>
</div>
</div>
</div>
</aside>
);
}

View File

@ -0,0 +1,310 @@
import type { KeyboardEvent, MouseEvent } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type FileReferenceKind =
| "default"
| "css"
| "html"
| "javascript"
| "json"
| "markdown"
| "notebook"
| "python"
| "react"
| "typescript";
interface FileReferenceChipProps {
path: string;
tooltipPath?: string;
display?: "name" | "path";
active?: boolean;
className?: string;
textClassName?: string;
previewPath?: string;
onOpen?: (path: string) => void;
testId?: string;
}
export function FileReferenceChip({
path,
tooltipPath,
display = "name",
active = false,
className,
textClassName,
previewPath,
onOpen,
testId = "inline-file-path",
}: FileReferenceChipProps) {
const { directory, name } = splitFilePath(path);
const kind = fileKindForPath(path);
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
const fullPath = tooltipPath || path;
const targetPath = previewPath || tooltipPath || path;
const interactive = Boolean(onOpen);
const openPreview = (event: MouseEvent | KeyboardEvent) => {
if (!onOpen) return;
event.preventDefault();
event.stopPropagation();
onOpen(targetPath);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
openPreview(event);
};
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("not-prose inline-flex max-w-full align-baseline leading-[inherit]", className)}
>
<span
data-testid={testId}
aria-label={fullPath}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
onClick={interactive ? openPreview : undefined}
onKeyDown={interactive ? onKeyDown : undefined}
className={cn(
"inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]",
"text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200",
interactive && [
"cursor-pointer rounded-[5px]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45",
],
)}
>
<FileReferenceIcon kind={kind} />
<span
data-sheen-text={active ? displayText : undefined}
className={cn(
"min-w-0 max-w-full truncate",
active && "streaming-text-sheen file-reference-sheen",
textClassName,
)}
>
{display === "path" && directory ? (
<>
<span className="text-muted-foreground/65">{directory}</span>
<span className="font-semibold text-sky-700 dark:text-sky-200">{name}</span>
</>
) : (
displayText
)}
</span>
</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
sideOffset={8}
collisionPadding={12}
className={cn(
"max-w-[min(38rem,calc(100vw-2rem))] rounded-[10px]",
"border-border/60 bg-popover/95 px-2.5 py-1.5",
"break-all font-mono text-[11px] leading-snug text-popover-foreground",
"shadow-lg backdrop-blur",
)}
>
{fullPath}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function isLikelyFilePath(value: string): boolean {
const raw = value.trim();
if (!raw || raw.includes("\n")) return false;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
if (isFilePatternReference(raw)) return false;
if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
return false;
}
const normalized = raw.replace(/\\/g, "/");
const name = normalized.split("/").filter(Boolean).pop() ?? normalized;
if (!name || name === "." || name === "..") return false;
if (/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(name)) return true;
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
}
export function isFilePatternReference(value: string): boolean {
return /[*?[\]{}]/.test(value.trim());
}
export function splitFilePath(path: string): { directory: string; name: string } {
const normalized = path.replace(/\\/g, "/");
const slash = normalized.lastIndexOf("/");
if (slash < 0) return { directory: "", name: path };
return {
directory: normalized.slice(0, slash + 1),
name: normalized.slice(slash + 1) || normalized,
};
}
export function fileKindForPath(path: string): FileReferenceKind {
const normalized = path.toLowerCase();
const name = normalized.split(/[\\/]/).pop() ?? normalized;
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
if (name === "dockerfile") {
return "default";
}
switch (ext) {
case "py":
case "pyi":
return "python";
case "jsx":
case "tsx":
return "react";
case "js":
case "mjs":
case "cjs":
return "javascript";
case "ts":
case "mts":
case "cts":
return "typescript";
case "html":
case "htm":
return "html";
case "css":
case "scss":
case "sass":
return "css";
case "json":
case "jsonl":
return "json";
case "md":
case "mdx":
return "markdown";
case "ipynb":
return "notebook";
default:
return "default";
}
}
export function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
if (kind === "python") {
return (
<svg
aria-hidden
className="h-[1em] w-[1em] shrink-0 translate-y-[0.12em]"
viewBox="0 0 24 24"
>
<path
d="M11.9 2.3c-3 0-4.5.8-4.5 2.3v2.1h4.8v.8H5.5C4 7.5 3 8.8 3 10.8v2.1c0 1.8 1.1 3 2.7 3h1.6v-2.3c0-1.7 1.4-3.1 3.1-3.1h4.2c1.3 0 2.3-1 2.3-2.3V4.6c0-1.4-1.5-2.3-4.6-2.3h-.4Z"
fill="#3776AB"
/>
<path
d="M12.1 21.7c3 0 4.5-.8 4.5-2.3v-2.1h-4.8v-.8h6.7c1.5 0 2.5-1.3 2.5-3.3v-2.1c0-1.8-1.1-3-2.7-3h-1.6v2.3c0 1.7-1.4 3.1-3.1 3.1H9.4c-1.3 0-2.3 1-2.3 2.3v3.6c0 1.4 1.5 2.3 4.6 2.3h.4Z"
fill="#FFD43B"
/>
<circle cx="9" cy="5.1" r="0.8" fill="#fff" />
<circle cx="15" cy="18.9" r="0.8" fill="#5C3B00" opacity="0.85" />
</svg>
);
}
if (kind === "react") {
return (
<svg
aria-hidden
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none" />
<ellipse cx="12" cy="12" rx="9" ry="3.7" />
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(60 12 12)" />
<ellipse cx="12" cy="12" rx="9" ry="3.7" transform="rotate(120 12 12)" />
</svg>
);
}
if (kind === "default") {
return (
<svg
aria-hidden
className="h-[0.92em] w-[0.92em] shrink-0 translate-y-[0.11em] text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7z" />
<path d="M14 2v5h5" />
</svg>
);
}
const label = fileKindLabel(kind);
return (
<svg
aria-hidden
className="h-[0.96em] w-[0.96em] shrink-0 translate-y-[0.12em] text-sky-500 dark:text-sky-300"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M7 3.5h6.6L18 7.9V19a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 19V5a1.5 1.5 0 0 1 1.5-1.5Z"
fill="currentColor"
opacity="0.12"
/>
<path
d="M13.5 3.75V8h4.25M7 3.5h6.6L18 7.9V19a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 19V5a1.5 1.5 0 0 1 1.5-1.5Z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<text
x="12"
y="15.7"
textAnchor="middle"
fill="currentColor"
fontSize={label.length > 1 ? "5.8" : "7.2"}
fontWeight="800"
letterSpacing="-0.2"
>
{label}
</text>
</svg>
);
}
function fileKindLabel(kind: FileReferenceKind): string {
switch (kind) {
case "css":
return "#";
case "html":
return "H";
case "javascript":
return "JS";
case "json":
return "{}";
case "markdown":
return "M";
case "notebook":
return "N";
case "python":
return "PY";
case "typescript":
return "TS";
default:
return "";
}
}

View File

@ -0,0 +1,199 @@
import { useCallback, useEffect, useMemo } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { UIImage } from "@/lib/types";
interface ImageLightboxProps {
images: UIImage[];
index: number | null;
onIndexChange: (index: number) => void;
onOpenChange: (open: boolean) => void;
}
/**
* Modal image viewer. Uses the Radix Dialog primitives directly so we can
* fill the viewport (the shared `DialogContent` wrapper caps at max-w-lg,
* which is much too small for a photo preview).
*
* Implementation notes:
* - `translate3d` + `will-change: transform` promote the image to a GPU
* compositing layer so open/swap stays at 60 FPS on long threads.
* - Adjacent images are rendered in hidden `<img>` tags so the browser
* decodes them eagerly; pressing left/right feels instant.
* - Radix handles `Escape` + focus trapping; we only wire up / + Home/End.
* - Respects `prefers-reduced-motion` by dropping the fade + zoom-in
* keyframes via `motion-reduce:*` variants.
*/
export function ImageLightbox({
images,
index,
onIndexChange,
onOpenChange,
}: ImageLightboxProps) {
const { t } = useTranslation();
const open = index !== null;
const total = images.length;
const current = index !== null ? images[index] : null;
const go = useCallback(
(delta: number) => {
if (index === null || total <= 1) return;
const next = (index + delta + total) % total;
onIndexChange(next);
},
[index, onIndexChange, total],
);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
go(1);
} else if (e.key === "Home") {
e.preventDefault();
onIndexChange(0);
} else if (e.key === "End") {
e.preventDefault();
onIndexChange(total - 1);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [go, onIndexChange, open, total]);
// Neighbours we want the browser to decode eagerly.
const preload = useMemo(() => {
if (index === null || total <= 1) return [] as UIImage[];
const prev = images[(index - 1 + total) % total];
const next = images[(index + 1) % total];
return [prev, next].filter((i) => i && i.url);
}, [images, index, total]);
if (!current || !current.url) return null;
const hasMany = total > 1;
const counter = hasMany ? `${index! + 1} / ${total}` : null;
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none",
)}
/>
<DialogPrimitive.Content
aria-label={current.name ?? t("lightbox.title")}
className={cn(
"fixed inset-0 z-50 flex items-center justify-center",
"focus:outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none",
)}
>
<DialogPrimitive.Title className="sr-only">
{current.name ?? t("lightbox.title")}
</DialogPrimitive.Title>
<div
className="relative flex max-h-[92vh] max-w-[94vw] items-center justify-center"
style={{
transform: "translate3d(0,0,0)",
willChange: "transform",
}}
>
<img
key={current.url}
src={current.url}
alt={current.name ?? ""}
decoding="async"
draggable={false}
className="max-h-[92vh] max-w-[94vw] select-none rounded-[6px] object-contain shadow-2xl"
/>
</div>
{hasMany ? (
<>
<NavButton
side="left"
label={t("lightbox.prev")}
onClick={(e) => {
e.stopPropagation();
go(-1);
}}
/>
<NavButton
side="right"
label={t("lightbox.next")}
onClick={(e) => {
e.stopPropagation();
go(1);
}}
/>
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-3 py-1 text-xs font-medium text-white/90 tabular-nums">
{counter}
</div>
</>
) : null}
<DialogPrimitive.Close
aria-label={t("lightbox.close")}
className={cn(
"absolute right-4 top-4 grid h-9 w-9 place-items-center rounded-full",
"bg-black/55 text-white/90 hover:bg-black/70 hover:text-white",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
"transition-colors motion-reduce:transition-none",
)}
>
<X className="h-4 w-4" aria-hidden />
</DialogPrimitive.Close>
{/* Invisible preload — browser decodes adjacent images so prev/next swap is instant. */}
<div aria-hidden className="hidden">
{preload.map((img, i) => (
<img key={`${img.url}-${i}`} src={img.url} alt="" />
))}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
interface NavButtonProps {
side: "left" | "right";
label: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
function NavButton({ side, label, onClick }: NavButtonProps) {
const Icon = side === "left" ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className={cn(
"absolute top-1/2 -translate-y-1/2 grid h-11 w-11 place-items-center rounded-full",
"bg-black/55 text-white/90 hover:bg-black/70 hover:text-white",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
"transition-colors motion-reduce:transition-none",
side === "left" ? "left-4" : "right-4",
)}
>
<Icon className="h-5 w-5" aria-hidden />
</button>
);
}

View File

@ -0,0 +1,67 @@
import { Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import { setAppLanguage } from "@/i18n";
import {
currentLocale,
} from "@/i18n";
import {
localeOption,
supportedLocales,
type SupportedLocale,
} from "@/i18n/config";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function LanguageSwitcher() {
const { t } = useTranslation();
const locale = currentLocale();
const selected = localeOption(locale);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label={t("sidebar.language.ariaLabel")}
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<Globe className="h-3.5 w-3.5" />
<span className="max-w-[7rem] truncate">{selected.nativeLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("sidebar.language.label")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={locale}
onValueChange={(value) => {
void setAppLanguage(value as SupportedLocale);
}}
>
{supportedLocales.map((option) => (
<DropdownMenuRadioItem key={option.code} value={option.code}>
<span className="flex min-w-0 items-center gap-2">
<span>{option.nativeLabel}</span>
{option.nativeLabel !== option.label ? (
<span className="truncate text-xs text-muted-foreground">
{option.label}
</span>
) : null}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,174 @@
import {
Component,
Suspense,
lazy,
memo,
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
interface MarkdownTextProps {
children: string;
className?: string;
streaming?: boolean;
onOpenFilePreview?: (path: string) => void;
}
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
source,
className,
highlightCode,
onOpenFilePreview,
}: {
source: string;
className?: string;
highlightCode: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
return (
<LazyMarkdownRenderer
className={className}
highlightCode={highlightCode}
onOpenFilePreview={onOpenFilePreview}
>
{source}
</LazyMarkdownRenderer>
);
});
const SHORT_STREAM_COMMIT_MS = 80;
const MEDIUM_STREAM_COMMIT_MS = 140;
const LONG_STREAM_COMMIT_MS = 220;
const STREAMING_HIGHLIGHT_CHAR_LIMIT = 16_000;
class MarkdownRendererBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
export function preloadMarkdownText(): void {
void loadMarkdownRenderer();
}
/**
* Lightweight markdown renderer mirroring agent-chat-ui: GFM + math via
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
*/
export function MarkdownText({
children,
className,
streaming = false,
onOpenFilePreview,
}: MarkdownTextProps) {
const renderedSource = useStreamingMarkdownSource(children, streaming);
const highlightCode = streaming
? renderedSource.length <= STREAMING_HIGHLIGHT_CHAR_LIMIT
: renderedSource === children;
useEffect(() => {
if (streaming) preloadMarkdownText();
}, [streaming]);
const plainFallback = (
<div
className={cn(
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
className,
)}
>
{renderedSource}
</div>
);
return (
<MarkdownRendererBoundary fallback={plainFallback}>
<Suspense fallback={plainFallback}>
<MemoizedMarkdownRenderer
source={renderedSource}
className={className}
highlightCode={highlightCode}
onOpenFilePreview={onOpenFilePreview}
/>
</Suspense>
</MarkdownRendererBoundary>
);
}
function useStreamingMarkdownSource(source: string, streaming: boolean): string {
const [renderedSource, setRenderedSource] = useState(source);
const latestSourceRef = useRef(source);
const renderedSourceRef = useRef(source);
const timerRef = useRef<number | null>(null);
const clearPendingCommit = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const commitSource = useCallback((next: string, urgent: boolean) => {
if (renderedSourceRef.current === next) return;
renderedSourceRef.current = next;
if (urgent) {
setRenderedSource(next);
return;
}
startTransition(() => setRenderedSource(next));
}, []);
const scheduleCommit = useCallback(() => {
if (timerRef.current !== null) return;
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
commitSource(latestSourceRef.current, false);
}, streamingCommitDelay(latestSourceRef.current.length));
}, [commitSource]);
latestSourceRef.current = source;
useLayoutEffect(() => {
latestSourceRef.current = source;
if (!streaming) {
clearPendingCommit();
commitSource(source, true);
}
}, [clearPendingCommit, commitSource, source, streaming]);
useEffect(() => {
latestSourceRef.current = source;
if (!streaming) return;
scheduleCommit();
}, [scheduleCommit, source, streaming]);
useEffect(() => clearPendingCommit, [clearPendingCommit]);
return renderedSource;
}
function streamingCommitDelay(length: number): number {
if (length > 24_000) return LONG_STREAM_COMMIT_MS;
if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS;
return SHORT_STREAM_COMMIT_MS;
}

View File

@ -0,0 +1,609 @@
import {
Children,
isValidElement,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import { Check, Globe2 } from "lucide-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CodeBlock } from "@/components/CodeBlock";
import {
FileReferenceChip,
isFilePatternReference,
isLikelyFilePath,
} from "@/components/FileReferenceChip";
import { inferMediaKind } from "@/lib/media";
import { faviconUrls } from "@/lib/provider-brand";
import { remarkTexMath } from "@/lib/remark-tex-math";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
interface MarkdownTextRendererProps {
children: string;
className?: string;
highlightCode?: boolean;
onOpenFilePreview?: (path: string) => void;
}
type MarkdownAstNode = {
type: string;
value?: string;
children?: MarkdownAstNode[];
data?: {
hName?: string;
};
};
type InlineLinkPreview = {
href: string;
host: string;
prefix?: string;
title: string;
};
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
function extensionOf(value: string): string {
const clean = value.split(/[?#]/, 1)[0]?.trim() ?? "";
const slash = clean.lastIndexOf("/");
const name = slash >= 0 ? clean.slice(slash + 1) : clean;
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot).toLowerCase() : "";
}
function markdownAttachmentKind(source: string, label: string): "image" | "video" | "file" {
const inferredKind = inferMediaKind({ url: source, name: label });
if (inferredKind !== "file") return inferredKind;
return extensionOf(label) || extensionOf(source) ? "file" : "image";
}
function safeHtmlNode(tagName: string, children: MarkdownAstNode[]): MarkdownAstNode {
return {
type: `nanobotSafeHtml${tagName}`,
data: { hName: tagName },
children,
};
}
function safeText(value: string): MarkdownAstNode {
return { type: "text", value };
}
function htmlTag(node: MarkdownAstNode): { tag: string; closing: boolean } | null {
if (node.type !== "html" || typeof node.value !== "string") return null;
const match = /^<\s*(\/?)\s*(mark|sub|sup)\s*>$/i.exec(node.value.trim());
if (!match) return null;
return { tag: match[2].toLowerCase(), closing: match[1] === "/" };
}
function normalizeSafeInlineHtml(children: MarkdownAstNode[]): MarkdownAstNode[] {
const next: MarkdownAstNode[] = [];
for (let index = 0; index < children.length; index += 1) {
const node = children[index];
if (node.children) {
node.children = normalizeSafeInlineHtml(node.children);
}
const tag = htmlTag(node);
if (!tag || tag.closing || !SAFE_INLINE_HTML_TAGS.has(tag.tag)) {
next.push(node);
continue;
}
let closeIndex = -1;
for (let cursor = index + 1; cursor < children.length; cursor += 1) {
const closeTag = htmlTag(children[cursor]);
if (closeTag?.closing && closeTag.tag === tag.tag) {
closeIndex = cursor;
break;
}
}
if (closeIndex === -1) {
next.push(node);
continue;
}
next.push(
safeHtmlNode(
tag.tag,
normalizeSafeInlineHtml(children.slice(index + 1, closeIndex)),
),
);
index = closeIndex;
}
return next;
}
function detailsOpen(node: MarkdownAstNode): { summary: string } | null {
if (node.type !== "html" || typeof node.value !== "string") return null;
const value = node.value.trim();
const match = /^<\s*details\s*>\s*<\s*summary\s*>([\s\S]*?)<\s*\/\s*summary\s*>$/i.exec(value);
if (match) return { summary: match[1].trim() };
if (/^<\s*details\s*>$/i.test(value)) return { summary: "Details" };
return null;
}
function isDetailsClose(node: MarkdownAstNode): boolean {
return node.type === "html"
&& typeof node.value === "string"
&& /^<\s*\/\s*details\s*>$/i.test(node.value.trim());
}
function normalizeSafeDetails(children: MarkdownAstNode[]): MarkdownAstNode[] {
const next: MarkdownAstNode[] = [];
for (let index = 0; index < children.length; index += 1) {
const node = children[index];
const open = detailsOpen(node);
if (!open) {
next.push(node);
continue;
}
const closeIndex = children.findIndex(
(candidate, candidateIndex) => candidateIndex > index && isDetailsClose(candidate),
);
if (closeIndex === -1) {
next.push(node);
continue;
}
const body = normalizeSafeInlineHtml(
normalizeSafeDetails(children.slice(index + 1, closeIndex)),
);
next.push({
type: "nanobotSafeHtmlDetails",
data: { hName: "details" },
children: [
{
type: "nanobotSafeHtmlSummary",
data: { hName: "summary" },
children: [safeText(open.summary)],
},
...body,
],
});
index = closeIndex;
}
return next;
}
function remarkSafeHtmlSubset() {
return (tree: MarkdownAstNode) => {
if (tree.children) {
tree.children = normalizeSafeInlineHtml(normalizeSafeDetails(tree.children));
}
};
}
const remarkPlugins: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [
remarkBreaks,
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
remarkTexMath,
remarkSafeHtmlSubset,
];
const rehypePlugins: NonNullable<ReactMarkdownOptions["rehypePlugins"]> = [rehypeKatex];
function nodeText(value: ReactNode): string {
return Children.toArray(value)
.map((child) => (typeof child === "string" || typeof child === "number" ? String(child) : ""))
.join("");
}
function cleanFileReferenceTarget(value: string): string {
let target = value.trim();
if (!target) return "";
try {
if (/^file:\/\//i.test(target)) {
target = decodeURIComponent(new URL(target).pathname);
} else {
target = decodeURIComponent(target);
}
} catch {
// Keep the raw value when URL/path decoding is not possible.
}
target = target.split("?", 1)[0]?.split("#", 1)[0]?.trim() ?? "";
if (!/^[A-Za-z]:[\\/]/.test(target)) {
target = target.replace(/:\d+(?::\d+)?$/, "");
}
return target;
}
function isPreviewableFileTarget(value: string): boolean {
if (isFilePatternReference(value)) return false;
if (isLikelyFilePath(value)) return true;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
if (/[\\/]/.test(value)) return false;
return /^[^?#]+\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(value);
}
function isNonNavigableFilePatternLink(href: string | undefined): boolean {
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return false;
const target = cleanFileReferenceTarget(href);
return Boolean(target && isFilePatternReference(target));
}
function fileReferenceFromLink(href: string | undefined): string | null {
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return null;
const target = cleanFileReferenceTarget(href);
return isPreviewableFileTarget(target) ? target : null;
}
function linkPreviewParts(value: ReactNode): { text: string; href?: string } {
let text = "";
let href: string | undefined;
for (const child of Children.toArray(value)) {
if (typeof child === "string" || typeof child === "number") {
text += String(child);
continue;
}
if (!isValidElement(child)) {
continue;
}
const props = child.props as { href?: unknown; children?: ReactNode };
if (!href && typeof props.href === "string" && /^https?:\/\//i.test(props.href)) {
href = props.href;
}
const nested = linkPreviewParts(props.children);
text += nested.text;
href ||= nested.href;
}
return { text, href };
}
function cleanLinkPreviewText(value: string): string {
return value
.replace(/\s+/g, " ")
.replace(/^[\s"'“”‘’]+|[\s"'“”‘’]+$/g, "")
.trim();
}
function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null {
const { text: rawText, href } = linkPreviewParts(children);
if (!href) return null;
let url: URL;
try {
url = new URL(href);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
const strippedUrl = rawText
.replace(/\s+/g, " ")
.replace(href, "")
.replace(url.toString(), "")
.replace(/https?:\/\/\S+/i, "")
.trim();
if (!strippedUrl || strippedUrl.length < 4) return null;
const sourceMatch = /^(.*?)\s*(?:[—–]| - |:)\s*(.+)$/.exec(strippedUrl);
const prefix = sourceMatch?.[1] ? cleanLinkPreviewText(sourceMatch[1]) : undefined;
const title = cleanLinkPreviewText(sourceMatch?.[2] ?? strippedUrl);
if (!title || /^https?:\/\//i.test(title)) return null;
return {
href,
host: url.hostname,
prefix,
title,
};
}
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
const { favicon, onFaviconError } = useFaviconFallback(link.host);
const label = link.prefix
? `${link.prefix}${link.title}`
: link.title;
return (
<a
href={link.href}
target="_blank"
rel="noreferrer noopener"
aria-label={`Open link: ${label}`}
className={cn(
"not-prose inline-flex max-w-full items-center gap-2 align-baseline",
"text-blue-500 no-underline underline-offset-2 hover:underline dark:text-blue-300",
)}
>
<span
className={cn(
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
"border border-border/65 bg-background text-muted-foreground",
)}
aria-hidden
>
{favicon ? (
<img
src={favicon}
alt=""
className="h-3 w-3 rounded-[2px] object-contain"
loading="lazy"
onError={onFaviconError}
/>
) : (
<Globe2 className="h-3 w-3" />
)}
</span>
<span className="min-w-0 truncate leading-normal">
{label}
</span>
</a>
);
}
function useFaviconFallback(host: string) {
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
const [faviconIndex, setFaviconIndex] = useState(0);
useEffect(() => {
setFaviconIndex(0);
}, [host]);
const onFaviconError = useCallback(() => {
setFaviconIndex((index) => Math.min(index + 1, faviconCandidates.length));
}, [faviconCandidates.length]);
return {
favicon: faviconCandidates[faviconIndex] ?? null,
onFaviconError,
};
}
function isRenderedCodeBlock(value: ReactNode): boolean {
if (!isValidElement(value)) return false;
const props = value.props as { code?: unknown };
return value.type === CodeBlock || typeof props.code === "string";
}
function codeFenceFromPreChild(value: ReactNode): { code: string; language?: string } | null {
if (!isValidElement(value)) return null;
const props = value.props as { className?: unknown; children?: ReactNode };
if (!("children" in props)) return null;
const className = typeof props.className === "string" ? props.className : "";
const language = /language-([^\s]+)/.exec(className)?.[1];
return {
code: nodeText(props.children).replace(/\n$/, ""),
language,
};
}
/**
* Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a
* separate chunk so the app shell can paint sooner on refresh.
*/
export default function MarkdownTextRenderer({
children,
className,
highlightCode = true,
onOpenFilePreview,
}: MarkdownTextRendererProps) {
const components = useMemo<Components>(
() => ({
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (match) {
const code = String(kids).replace(/\n$/, "");
return (
<CodeBlock
language={match[1]}
code={code}
className="my-3"
highlight={highlightCode}
/>
);
}
const raw = String(kids).replace(/\n$/, "");
if (isLikelyFilePath(raw)) {
return <FileReferenceChip path={raw} onOpen={onOpenFilePreview} />;
}
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
return (
<code
className={cn(
"block min-w-0 whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
"leading-snug text-inherit",
cls,
)}
{...props}
>
{kids}
</code>
);
}
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
},
pre({ children: markdownChildren }) {
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (isRenderedCodeBlock(lone)) {
return <>{markdownChildren}</>;
}
const fence = codeFenceFromPreChild(lone);
if (fence) {
return (
<CodeBlock
language={fence.language || "text"}
code={fence.code}
className="my-3"
highlight={highlightCode}
/>
);
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
"whitespace-pre [overflow-wrap:normal]",
)}
>
{markdownChildren}
</pre>
);
},
a({ href, children: markdownChildren, ...props }) {
const filePath = fileReferenceFromLink(href);
if (filePath) {
const label = nodeText(markdownChildren).trim();
return (
<FileReferenceChip
path={label || filePath}
tooltipPath={filePath}
previewPath={filePath}
onOpen={onOpenFilePreview}
/>
);
}
if (isNonNavigableFilePatternLink(href)) {
return <>{markdownChildren}</>;
}
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-blue-500 underline underline-offset-2 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-200"
{...props}
>
{markdownChildren}
</a>
);
},
li({ children: markdownChildren, className: itemClassName }) {
const link = inlineLinkPreviewFromChildren(markdownChildren);
if (link) {
return (
<li className={cn("list-none pl-0", itemClassName)}>
<InlineLinkPreviewRow link={link} />
</li>
);
}
return (
<li className={itemClassName}>
{markdownChildren}
</li>
);
},
input({ type, checked }) {
if (type !== "checkbox") return null;
return (
<span
aria-hidden
data-testid="markdown-task-checkbox"
className={cn(
"mr-2 inline-grid h-4 w-4 translate-y-[2px] place-items-center rounded-[4px]",
"border border-border/70 bg-muted/55 text-background",
checked && "border-foreground/55 bg-foreground/65",
)}
>
{checked ? <Check className="h-3 w-3 stroke-[3]" /> : null}
</span>
);
},
mark({ children: markdownChildren }) {
return (
<mark className="rounded-[5px] bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
{markdownChildren}
</mark>
);
},
sub({ children: markdownChildren }) {
return <sub className="text-[0.72em] leading-none">{markdownChildren}</sub>;
},
sup({ children: markdownChildren }) {
return <sup className="text-[0.72em] leading-none">{markdownChildren}</sup>;
},
details({ children: markdownChildren }) {
return (
<details className="my-3 rounded-xl border border-border/65 bg-muted/25 px-4 py-3 open:pb-4">
{markdownChildren}
</details>
);
},
summary({ children: markdownChildren }) {
return (
<summary className="cursor-pointer select-none text-sm font-medium text-foreground/88 marker:text-muted-foreground">
{markdownChildren}
</summary>
);
},
img({ src, alt, node: _node, className: imgClassName, ...props }) {
void _node;
void imgClassName;
void props;
const source = typeof src === "string" ? src : "";
if (!source) return null;
const label = typeof alt === "string" ? alt : "";
const kind = markdownAttachmentKind(source, label);
return (
<AttachmentTile
attachment={{
kind,
url: source,
name: label,
}}
inline
/>
);
},
}),
[highlightCode, onOpenFilePreview],
);
return (
<div
className={cn(
"markdown-content prose max-w-none dark:prose-invert",
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
"prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-[13px]",
"prose-p:my-2",
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
"prose-blockquote:not-italic prose-blockquote:text-foreground/80",
"prose-a:text-blue-500 prose-a:underline-offset-2 hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
"prose-hr:my-6",
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
"prose-table:my-3 prose-th:text-left prose-th:font-medium",
className,
)}
style={{ lineHeight: "var(--cjk-line-height)" }}
>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{children}
</ReactMarkdown>
</div>
);
}

View File

@ -0,0 +1,755 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
Check,
ChevronRight,
Clock3,
Copy,
ImageIcon,
Sparkles,
Wrench,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { formatTurnLatency } from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import type {
CliAppInfo,
McpPresetInfo,
UICliAppAttachment,
UIMcpPresetAttachment,
UIImage,
UIMediaAttachment,
UIMessage,
} from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
onForkFromHere?: () => void;
}
function ForkArrowIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden
>
<path d="M16 3h5v5" />
<path d="M8 3H3v5" />
<path d="m21 3-7.536 7.536A5 5 0 0 0 12 14.07V21" />
<path d="m3 3 7.536 7.536A5 5 0 0 1 12 14.07V15" />
</svg>
);
}
/**
* Render a single message. Following agent-chat-ui: user turns are a rounded
* "pill" right-aligned with a muted fill; assistant turns render as bare
* markdown so prose/code read like a document rather than a chat bubble.
* Each turn fades+slides in for a touch of motion polish.
*
* Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
* collapsible group so intermediate steps never masquerade as replies.
*/
export function MessageBubble({
message,
showAssistantCopyAction = true,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
onForkFromHere,
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
const mentionCliApps = useMemo(
() => mergeCliMentionApps(cliApps, message.cliApps),
[cliApps, message.cliApps],
);
const mentionMcpPresets = useMemo(
() => mergeMcpMentionPresets(mcpPresets, message.mcpPresets),
[mcpPresets, message.mcpPresets],
);
useEffect(() => {
return () => {
if (copyResetRef.current !== null) {
window.clearTimeout(copyResetRef.current);
}
};
}, []);
const onCopyAssistantReply = useCallback(() => {
void copyTextToClipboard(message.content).then((ok) => {
if (!ok) return;
setCopied(true);
if (copyResetRef.current !== null) {
window.clearTimeout(copyResetRef.current);
}
copyResetRef.current = window.setTimeout(() => {
setCopied(false);
copyResetRef.current = null;
}, 1_500);
});
}, [message.content]);
if (message.kind === "trace") {
return <TraceGroup message={message} animClass={baseAnim} />;
}
if (message.role === "user") {
const images = message.images ?? [];
const media = message.media ?? [];
const hasImages = images.length > 0;
const hasMedia = media.length > 0;
const hasText = message.content.trim().length > 0;
return (
<div
className={cn(
"group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5",
baseAnim,
)}
>
{hasImages ? <UserImages images={images} align="right" /> : null}
{hasMedia ? (
<MessageMedia media={media} align="right" />
) : null}
{hasText ? (
<p
className={cn(
"ml-auto w-fit rounded-[18px] bg-secondary/70 px-4 py-2",
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
)}
>
<CliAppMentionText
text={message.content}
cliApps={mentionCliApps}
mcpPresets={mentionMcpPresets}
/>
</p>
) : null}
</div>
);
}
const empty = message.content.trim().length === 0;
const media = message.media ?? [];
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
const automationSourceLabel = message.source?.kind === "cron"
? (message.source.label?.trim() || t("message.automationSourceFallback"))
: "";
const automationTriggeredLabel = t("message.automationTriggered");
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
const showCopyButton = showAssistantCopyAction && showAssistantActions;
const showForkButton = showAssistantActions && !!onForkFromHere;
const copyReplyLabel = copied ? t("message.copiedReply") : t("message.copyReply");
const forkLabel = t("message.forkFromHere");
const latencyMs = message.latencyMs;
const showLatencyFooter =
message.role === "assistant"
&& latencyMs != null
&& !message.isStreaming
&& (!empty || hasReasoning || media.length > 0);
const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter;
return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
<ReasoningBubble
text={reasoning}
streaming={reasoningStreaming}
hasBodyBelow={!empty}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
{empty && message.isStreaming && !hasReasoning ? (
<TypingDots />
) : empty && message.isStreaming ? null : (
<>
{automationSourceLabel ? (
<AutomationSourceBadge
label={automationSourceLabel}
triggerLabel={automationTriggeredLabel}
/>
) : null}
<MarkdownText
streaming={!!message.isStreaming}
onOpenFilePreview={onOpenFilePreview}
>
{message.content}
</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
{showCopyButton ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onCopyAssistantReply}
aria-label={copyReplyLabel}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" align="center">{copyReplyLabel}</TooltipContent>
</Tooltip>
) : null}
{showForkButton ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onForkFromHere}
aria-label={forkLabel}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<ForkArrowIcon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip>
) : null}
{showLatencyFooter ? (
<span
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={t("message.turnLatencyTitle")}
>
{formatTurnLatency(latencyMs)}
</span>
) : null}
</div>
</TooltipProvider>
) : null}
</>
)}
</div>
);
}
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
return (
<div
className={cn(
"mb-2 inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-1",
"border border-sky-500/15 bg-sky-500/[0.06]",
"text-[11px] font-medium leading-none text-sky-700",
"dark:border-sky-300/15 dark:bg-sky-300/[0.08] dark:text-sky-200/80",
)}
title={triggerLabel}
>
<Clock3 className="h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0 truncate">{label}</span>
<span className="text-current/45" aria-hidden>·</span>
<span className="shrink-0">{triggerLabel}</span>
</div>
);
}
function mergeMcpMentionPresets(
presets: McpPresetInfo[],
attachments: UIMcpPresetAttachment[] | undefined,
): McpPresetInfo[] {
if (!attachments?.length) return presets;
const byName = new Map(presets.map((preset) => [preset.name.toLowerCase(), preset]));
for (const attachment of attachments) {
const name = attachment.name?.trim();
if (!name) continue;
const existing = byName.get(name.toLowerCase());
byName.set(name.toLowerCase(), {
name,
display_name: attachment.display_name || existing?.display_name || name,
category: attachment.category || existing?.category || "mcp",
description: existing?.description || "",
docs_url: existing?.docs_url || "",
transport: attachment.transport || existing?.transport || "mcp",
requires: existing?.requires || "",
note: existing?.note || "",
install_supported: existing?.install_supported ?? true,
installed: true,
configured: attachment.configured ?? existing?.configured ?? true,
available: existing?.available ?? true,
status: attachment.status || existing?.status || "configured",
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
required_fields: existing?.required_fields || [],
connection_summary: existing?.connection_summary || "",
});
}
return Array.from(byName.values());
}
function mergeCliMentionApps(
cliApps: CliAppInfo[],
attachments: UICliAppAttachment[] | undefined,
): CliAppInfo[] {
if (!attachments?.length) return cliApps;
const byName = new Map(cliApps.map((app) => [app.name.toLowerCase(), app]));
for (const attachment of attachments) {
const name = attachment.name?.trim();
if (!name) continue;
const existing = byName.get(name.toLowerCase());
byName.set(name.toLowerCase(), {
name,
display_name: attachment.display_name || existing?.display_name || name,
category: attachment.category || existing?.category || "cli",
description: existing?.description || "",
requires: existing?.requires || "",
source: existing?.source || "attached",
entry_point: attachment.entry_point || existing?.entry_point || "",
install_supported: existing?.install_supported ?? true,
installed: true,
available: existing?.available ?? true,
status: existing?.status || "installed",
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
skill_installed: existing?.skill_installed ?? true,
});
}
return Array.from(byName.values());
}
function MessageMedia({
media,
align,
}: {
media: UIMediaAttachment[];
align: "left" | "right";
}) {
if (media.length === 0) return null;
const images: UIImage[] = [];
const nonImages: UIMediaAttachment[] = [];
for (const item of media) {
const normalized = toMediaAttachment(item);
if (normalized.kind === "image") {
images.push({ url: normalized.url, name: normalized.name });
} else {
nonImages.push(normalized);
}
}
return (
<div
className={cn(
"mt-2 flex flex-wrap gap-2",
align === "right" ? "justify-end" : "justify-start",
)}
>
{images.length > 0 ? (
<UserImages images={images} align={align} size={align === "left" ? "large" : "compact"} />
) : null}
{nonImages.map((item, i) => (
<AttachmentTile key={`${item.url ?? item.name ?? item.kind}-${i}`} attachment={item} />
))}
</div>
);
}
/**
* Right-aligned preview row for images attached to a user turn.
*
* Visual follows agent-chat-ui: a single wrapping row of fixed-size square
* thumbnails that stay modest next to the text pill regardless of how many
* images are attached.
*
* The URL is expected to be a self-contained ``data:`` URL (the Composer
* hands the normalized base64 payload to the optimistic bubble so that the
* preview survives React StrictMode double-mount blob URLs would be
* revoked by the Composer's cleanup before remount). Historical replays
* have no URL (the backend strips data URLs before persisting), so we
* render a labelled placeholder tile instead of a broken ``<img>``.
*/
function UserImages({
images,
align = "right",
size = "compact",
}: {
images: UIImage[];
align?: "left" | "right";
size?: "compact" | "large";
}) {
const { t } = useTranslation();
// Only real-URL images can open in the lightbox; historical-replay
// placeholders (no URL) have nothing to zoom into.
const viewableImages: UIImage[] = [];
const originalToViewable = new Map<number, number>();
for (let i = 0; i < images.length; i += 1) {
const img = images[i];
if (typeof img.url !== "string" || img.url.length === 0) continue;
originalToViewable.set(i, viewableImages.length);
viewableImages.push(img);
}
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
return (
<>
<div
className={cn(
"flex flex-wrap items-end gap-2",
size === "large" && "gap-3",
align === "right" ? "ml-auto justify-end" : "mr-auto justify-start",
)}
>
{images.map((img, i) => (
<UserImageCell
key={`${img.url ?? "placeholder"}-${i}`}
image={img}
size={size}
placeholderLabel={t("message.imageAttachment")}
openLabel={t("lightbox.open")}
onOpen={
originalToViewable.has(i)
? () => setLightboxIndex(originalToViewable.get(i)!)
: undefined
}
/>
))}
</div>
<ImageLightbox
images={viewableImages}
index={lightboxIndex}
onIndexChange={setLightboxIndex}
onOpenChange={(open) => {
if (!open) setLightboxIndex(null);
}}
/>
</>
);
}
function UserImageCell({
image,
size,
placeholderLabel,
openLabel,
onOpen,
}: {
image: UIImage;
size: "compact" | "large";
placeholderLabel: string;
openLabel: string;
onOpen?: () => void;
}) {
const hasUrl = typeof image.url === "string" && image.url.length > 0;
const tileClasses = cn(
"relative overflow-hidden border border-border/60 bg-muted/40",
size === "large"
? "w-[min(100%,34rem)] rounded-[20px] bg-transparent"
: "h-24 w-24 rounded-[14px]",
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
);
if (hasUrl && onOpen) {
return (
<button
type="button"
onClick={onOpen}
aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
className={cn(
tileClasses,
"block cursor-zoom-in p-0 transition-transform duration-150 motion-reduce:transition-none",
"hover:scale-[1.01] hover:ring-2 hover:ring-primary/25",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
)}
>
<img
src={image.url}
alt={image.name ?? ""}
loading="lazy"
decoding="async"
draggable={false}
className={cn(
"block",
size === "large"
? "h-auto max-h-[36rem] w-full rounded-[inherit] object-contain"
: "h-full w-full object-cover",
)}
/>
</button>
);
}
return (
<div className={tileClasses} title={image.name ?? undefined}>
<div
className="flex h-full w-full flex-col items-center justify-center gap-1 px-2 text-[11px] text-muted-foreground"
aria-label={placeholderLabel}
>
<ImageIcon className="h-4 w-4 flex-none" aria-hidden />
<span className="line-clamp-2 text-center leading-tight">
{image.name ?? placeholderLabel}
</span>
</div>
</div>
);
}
/** Pre-token-arrival placeholder: three bouncing dots. */
function TypingDots() {
const { t } = useTranslation();
return (
<span
aria-label={t("message.assistantTyping")}
className="inline-flex items-center gap-1 py-1"
>
<Dot delay="0ms" />
<Dot delay="150ms" />
<Dot delay="300ms" />
</span>
);
}
function Dot({ delay }: { delay: string }) {
return (
<span
style={{ animationDelay: delay }}
className={cn(
"inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60",
"animate-bounce",
)}
/>
);
}
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
export function StreamingLabelSheen({
children,
active,
className,
}: {
children: ReactNode;
active: boolean;
className?: string;
}) {
const sheenText =
typeof children === "string" || typeof children === "number"
? String(children)
: undefined;
return (
<span className={cn("block min-w-0 overflow-hidden py-px", className)}>
<span
data-sheen-text={active ? sheenText : undefined}
className={cn(
"block w-fit max-w-full truncate font-medium leading-normal",
active ? "streaming-text-sheen" : "text-muted-foreground",
)}
>
{children}
</span>
</span>
);
}
interface ReasoningBubbleProps {
text: string;
streaming: boolean;
hasBodyBelow: boolean;
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
embeddedInCluster?: boolean;
onOpenFilePreview?: (path: string) => void;
}
/**
* Subordinate "thinking" trace shown above an assistant turn.
*
* Lifecycle:
* - While ``streaming`` is true (``reasoning_delta`` frames still arriving),
* the bubble defaults to open and the header shows a sheen + pulse so
* the user sees the model "thinking out loud" in real time.
* - Expanded reasoning uses the same Markdown pipeline as assistant replies
* (deferred while streaming to reduce parser thrash), so headings and
* emphasis render instead of leaking raw ``###`` / ``**``.
* - On ``reasoning_end`` the bubble auto-collapses for prose density
* the user can re-expand to inspect the chain of thought. The local
* toggle persists once the user interacts.
*/
export function ReasoningBubble({
text,
streaming,
hasBodyBelow,
embeddedInCluster = false,
onOpenFilePreview,
}: ReasoningBubbleProps) {
const { t } = useTranslation();
const [userToggled, setUserToggled] = useState(false);
const [openLocal, setOpenLocal] = useState(true);
const open = userToggled ? openLocal : streaming;
const onToggle = () => {
setUserToggled(true);
setOpenLocal((v) => (userToggled ? !v : !open));
};
useEffect(() => {
if (open && text.length > 0) {
preloadMarkdownText();
}
}, [open, text.length]);
return (
<div
className={cn(
"w-full",
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
hasBodyBelow && "mb-2",
)}
>
<button
type="button"
onClick={onToggle}
className={cn(
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
)}
aria-expanded={open}
aria-live={streaming ? "polite" : undefined}
>
<Sparkles
className={cn("h-3.5 w-3.5", streaming && "animate-pulse")}
aria-hidden
/>
<StreamingLabelSheen active={streaming} className="min-w-0 flex-1 text-left">
{streaming
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
: t("message.reasoning", { defaultValue: "Thinking" })}
</StreamingLabelSheen>
<ChevronRight
aria-hidden
className={cn(
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
open && "rotate-90",
)}
/>
</button>
{open && text.length > 0 && (
<div
className={cn(
"mt-1 min-w-0 border-l border-muted-foreground/20 pl-3",
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
)}
>
<MarkdownText
streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
className={cn(
"text-[12.5px] italic text-muted-foreground/88",
"prose-p:my-1.5 prose-li:my-0.5",
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
"prose-headings:text-muted-foreground/92 prose-strong:text-muted-foreground",
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
"prose-a:text-blue-500 prose-a:underline hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
"prose-code:text-[0.92em]",
)}
>
{text}
</MarkdownText>
</div>
)}
</div>
);
}
interface TraceGroupProps {
message: UIMessage;
animClass: string;
}
/**
* Collapsible group of tool-call / progress breadcrumbs. Defaults to
* collapsed because tool traces are supporting evidence, not the answer.
* A single click expands the exact calls when the user wants details.
*/
export function TraceGroup({ message, animClass }: TraceGroupProps) {
const { t } = useTranslation();
const lines = message.traces ?? [message.content];
const count = lines.length;
const [open, setOpen] = useState(false);
return (
<div className={cn("w-full", animClass)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
)}
aria-expanded={open}
>
<Wrench className="h-3.5 w-3.5" aria-hidden />
<span className="font-medium">
{count === 1
? t("message.toolSingle")
: t("message.toolMany", { count })}
</span>
<ChevronRight
aria-hidden
className={cn(
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
open && "rotate-90",
)}
/>
</button>
{open && (
<ul
className={cn(
"mt-1 space-y-0.5 border-l border-muted-foreground/20 pl-3",
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
)}
>
{lines.map((line, i) => (
<li
key={i}
className="whitespace-pre-wrap break-words font-mono text-[11.5px] leading-relaxed text-muted-foreground/90"
>
{line}
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
interface RenameChatDialogProps {
open: boolean;
title: string;
dialogTitle?: string;
description?: string;
placeholder?: string;
onCancel: () => void;
onConfirm: (title: string) => void;
}
export function RenameChatDialog({
open,
title,
dialogTitle,
description,
placeholder,
onCancel,
onConfirm,
}: RenameChatDialogProps) {
const { t } = useTranslation();
const [value, setValue] = useState(title);
useEffect(() => {
if (open) setValue(title);
}, [open, title]);
const trimmed = value.trim();
return (
<Dialog open={open} onOpenChange={(next) => {
if (!next) onCancel();
}}>
<DialogContent className="max-w-sm rounded-[22px] border-border/70 bg-popover p-5 shadow-2xl">
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
if (!trimmed) return;
onConfirm(trimmed);
}}
>
<DialogHeader className="text-left">
<DialogTitle>{dialogTitle ?? t("chat.renameTitle")}</DialogTitle>
<DialogDescription>
{description ?? t("chat.renameDescription")}
</DialogDescription>
</DialogHeader>
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={placeholder ?? t("chat.renamePlaceholder")}
autoFocus
maxLength={160}
/>
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" onClick={onCancel}>
{t("deleteConfirm.cancel")}
</Button>
<Button type="submit" disabled={!trimmed}>
{t("chat.renameSave")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,238 @@
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { deriveTitle } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
interface SessionSearchDialogProps {
open: boolean;
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
titleOverrides?: Record<string, string>;
onOpenChange: (open: boolean) => void;
onSelect: (key: string) => void;
}
export function SessionSearchDialog({
open,
sessions,
activeKey,
loading,
titleOverrides = {},
onOpenChange,
onSelect,
}: SessionSearchDialogProps) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const normalizedQuery = query.trim().toLowerCase();
const sessionResults = useMemo(() => {
if (!open) return [];
if (!normalizedQuery) return sessions;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return sessions.filter((session) =>
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
);
}, [normalizedQuery, open, sessions, titleOverrides]);
const itemCount = sessionResults.length;
useEffect(() => {
if (!open) return;
setQuery("");
setHighlightedIndex(0);
window.setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
setHighlightedIndex(0);
}, [normalizedQuery]);
useEffect(() => {
setHighlightedIndex((index) =>
itemCount === 0 ? 0 : Math.min(index, itemCount - 1),
);
}, [itemCount]);
useEffect(() => {
itemRefs.current = itemRefs.current.slice(0, itemCount);
}, [itemCount]);
useEffect(() => {
if (!open) return;
itemRefs.current[highlightedIndex]?.scrollIntoView({
block: "nearest",
inline: "nearest",
});
}, [highlightedIndex, open]);
const handleSelect = (key: string) => {
onOpenChange(false);
onSelect(key);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((index) =>
itemCount === 0 ? 0 : (index + 1) % itemCount,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((index) =>
itemCount === 0 ? 0 : (index - 1 + itemCount) % itemCount,
);
return;
}
if (event.key === "Enter") {
const highlighted = sessionResults[highlightedIndex];
if (!highlighted) return;
event.preventDefault();
handleSelect(highlighted.key);
}
};
const emptyLabel = normalizedQuery
? t("sidebar.noSearchResults")
: t("chat.noSessions");
const sectionLabel = normalizedQuery
? t("sidebar.searchResults")
: t("sidebar.recent");
if (!open) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
"flex max-h-[min(40rem,calc(100vh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
"rounded-[22px] border border-border bg-background text-foreground shadow-[0_22px_70px_rgba(0,0,0,0.22)]",
"dark:border-white/14 dark:bg-[#2b2b2b] dark:shadow-[0_26px_90px_rgba(0,0,0,0.44)] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">{t("sidebar.searchAria")}</DialogTitle>
<DialogDescription className="sr-only">
{t("sidebar.searchPlaceholder")}
</DialogDescription>
<div className="flex h-[62px] shrink-0 items-center gap-3 border-b border-border px-[18px]">
<Search
className="h-[18px] w-[18px] shrink-0 text-muted-foreground"
aria-hidden
/>
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className="h-full min-w-0 flex-1 bg-transparent text-[19px] font-normal leading-none text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<div
data-testid="session-search-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2.5 scrollbar-thin scrollbar-track-transparent"
>
<section>
<div className="px-2.5 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground">
{sectionLabel}
</div>
{loading && sessions.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{t("chat.loading")}
</div>
) : sessionResults.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{emptyLabel}
</div>
) : (
<ul className="space-y-0.5">
{sessionResults.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, t("chat.newChat"));
const preview = session.preview.trim();
const showPreview =
preview.length > 0 &&
preview.toLowerCase() !== title.trim().toLowerCase();
const highlighted = index === highlightedIndex;
const active = session.key === activeKey;
return (
<li key={session.key}>
<button
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
onClick={() => handleSelect(session.key)}
onMouseEnter={() => setHighlightedIndex(index)}
aria-current={active ? "page" : undefined}
className={cn(
"grid min-h-[54px] w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[11px] px-3 py-2 text-left transition-colors",
highlighted
? "bg-muted text-foreground"
: "text-foreground hover:bg-muted",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium leading-5">
{title}
</span>
{showPreview ? (
<span
className="block truncate text-[12px] leading-4 text-muted-foreground"
>
{preview}
</span>
) : null}
</span>
{active ? (
<span className="shrink-0 rounded-full bg-muted-foreground/10 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{t("common.current", { defaultValue: "Current" })}
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</section>
</div>
</DialogContent>
</Dialog>
);
}
function sessionMatchesTerms(
session: ChatSummary,
terms: string[],
titleOverride?: string,
) {
const haystack = [
titleOverride,
session.title,
session.preview,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return terms.every((term) => haystack.includes(term));
}

View File

@ -0,0 +1,377 @@
import { useState, type ReactNode } from "react";
import {
Archive,
Folder,
Menu,
Plus,
Search,
SquarePen,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type {
ChatSummary,
SidebarViewState,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { sameWorkspacePath } from "@/lib/workspace";
interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
onNewChat: () => void;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void;
onSelectProjectWorkspace: (projectPath: string, projectName: string) => void;
onAddProjectWorkspace: () => void;
onRemoveProjectWorkspace: (projectPath: string) => void;
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSkills: () => void;
onOpenAutomations: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | "skills" | "automations" | null;
onToggleArchived: () => void;
onCollapse: () => void;
onExpand?: () => void;
containActionMenus?: boolean;
collapsed?: boolean;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
projectPaths?: string[];
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
updatedChatIds?: string[];
viewState?: SidebarViewState;
showArchived?: boolean;
archivedCount?: number;
defaultWorkspacePath?: string | null;
currentWorkspacePath?: string | null;
hostChromeInset?: boolean;
}
type NavigatorWithUserAgentData = Navigator & {
userAgentData?: { platform?: string };
};
function isApplePlatform(): boolean {
if (typeof navigator === "undefined") return false;
const platform = navigator.platform || "";
const userAgentPlatform =
(navigator as NavigatorWithUserAgentData).userAgentData?.platform || "";
return /mac|iphone|ipad|ipod/i.test(`${platform} ${userAgentPlatform}`);
}
function newChatShortcutLabel(): string {
return isApplePlatform() ? "Cmd+Shift+O" : "Ctrl+Shift+O";
}
export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
const [menuPortalContainer, setMenuPortalContainer] =
useState<HTMLElement | null>(null);
const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel();
return (
<nav
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
aria-label={t("sidebar.navigation")}
className={cn(
"flex h-full w-full min-w-0 flex-col text-sidebar-foreground",
props.hostChromeInset ? "bg-transparent" : "bg-sidebar",
!props.hostChromeInset && "border-r border-sidebar-border/60",
)}
>
<div
className={cn(
"flex items-center px-3 pb-2.5",
props.hostChromeInset ? "pt-[2.85rem]" : "pt-3",
collapsed ? "w-14 justify-start" : "justify-between",
)}
>
<button
type="button"
aria-label={collapsed ? toggleLabel : undefined}
aria-hidden={collapsed ? undefined : true}
title={collapsed ? toggleLabel : undefined}
onClick={collapsed ? props.onExpand : undefined}
tabIndex={collapsed ? 0 : -1}
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-colors",
collapsed
? "-ml-0.5 hover:bg-sidebar-accent/75"
: "pointer-events-none -ml-0.5",
)}
>
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-8 w-8 select-none object-contain"
draggable={false}
/>
</button>
{!collapsed && !props.hostChromeInset && (
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<Menu className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div
className={cn(
"space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.newChat")}
onClick={props.onNewChat}
icon={<SquarePen className="h-4 w-4" />}
shortcut={newChatShortcut}
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.searchAria")}
onClick={props.onOpenSearch}
icon={<Search className="h-4 w-4" />}
/>
{props.archivedCount ? (
<SidebarActionButton
collapsed={collapsed}
label={props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
onClick={props.onToggleArchived}
icon={<Archive className="h-4 w-4" />}
/>
) : null}
</div>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
collapsed && "pointer-events-none opacity-0",
)}
>
{!collapsed && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<ProjectWorkspaceSection
projectPaths={props.projectPaths ?? []}
projectNameOverrides={props.projectNameOverrides ?? {}}
currentWorkspacePath={props.currentWorkspacePath}
onAddProjectWorkspace={props.onAddProjectWorkspace}
onRemoveProjectWorkspace={props.onRemoveProjectWorkspace}
onSelectProjectWorkspace={props.onSelectProjectWorkspace}
/>
<div className="min-h-0 flex-1">
<ChatList
sessions={props.sessions}
activeKey={props.activeKey}
loading={props.loading}
emptyLabel={t("chat.noSessions")}
onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete}
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject}
onSelectProjectWorkspace={props.onSelectProjectWorkspace}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds}
updatedChatIds={props.updatedChatIds}
density={props.viewState?.density}
showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort}
showArchived={props.showArchived}
defaultWorkspacePath={props.defaultWorkspacePath}
currentWorkspacePath={props.currentWorkspacePath}
actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined
}
/>
</div>
</div>
)}
</div>
<Separator className="bg-sidebar-border/50" />
<div
className={cn(
"flex items-center gap-1 px-2.5 py-2.5 text-xs",
collapsed && "w-14 flex-col px-0",
)}
>
<ConnectionBadge />
</div>
</nav>
);
}
function ProjectWorkspaceSection({
projectPaths,
projectNameOverrides,
currentWorkspacePath,
onAddProjectWorkspace,
onRemoveProjectWorkspace,
onSelectProjectWorkspace,
}: {
projectPaths: string[];
projectNameOverrides: Record<string, string>;
currentWorkspacePath?: string | null;
onAddProjectWorkspace: () => void;
onRemoveProjectWorkspace: (projectPath: string) => void;
onSelectProjectWorkspace: (projectPath: string, projectName: string) => void;
}) {
const { t } = useTranslation();
return (
<section className="border-b border-sidebar-border/45 px-2 pb-2" aria-label="Projects">
<div className="flex items-center justify-between px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
<span>{t("chat.groups.projects")}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onAddProjectWorkspace}
className="h-6 w-6 rounded-md text-muted-foreground/75 hover:bg-sidebar-accent/70 hover:text-sidebar-foreground"
aria-label="Add project"
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-0.5">
{projectPaths.length === 0 ? (
<div className="px-2 py-1.5 text-[12px] leading-5 text-muted-foreground/60">
No projects yet
</div>
) : (
projectPaths.map((projectPath) => {
const projectName =
projectNameOverrides[projectPath]
|| projectPath.replace(/\\/g, "/").split("/").filter(Boolean).pop()
|| projectPath;
const active = sameWorkspacePath(currentWorkspacePath, projectPath);
return (
<div key={projectPath} className="group flex items-center gap-1">
<button
type="button"
onClick={() => onSelectProjectWorkspace(projectPath, projectName)}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 rounded-xl px-2 py-1.5 text-left text-[13px] transition-colors",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
title={projectPath}
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{projectName}</span>
</button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemoveProjectWorkspace(projectPath)}
className="h-7 w-7 rounded-md text-muted-foreground/70 opacity-45 hover:bg-sidebar-accent hover:text-destructive group-hover:opacity-100"
aria-label={`Remove ${projectName}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})
)}
</div>
</section>
);
}
function SidebarActionButton({
collapsed,
label,
icon,
onClick,
active = false,
className,
shortcut,
ariaKeyShortcuts,
}: {
collapsed: boolean;
label: string;
icon: ReactNode;
onClick: () => void;
active?: boolean;
className?: string;
shortcut?: string;
ariaKeyShortcuts?: string;
}) {
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
return (
<Button
type="button"
variant="ghost"
aria-label={label}
aria-current={active ? "page" : undefined}
aria-keyshortcuts={ariaKeyShortcuts}
title={title}
onClick={() => onClick()}
className={cn(
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
className,
)}
>
<span
className={cn(
"flex shrink-0 items-center justify-center transition-transform duration-300 ease-out",
collapsed ? "translate-x-0" : "translate-x-0",
)}
aria-hidden
>
{icon}
</span>
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
collapsed
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
{label}
</span>
</Button>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,417 @@
import { useEffect, useState, type ReactNode } from "react";
import type { TFunction } from "i18next";
import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import { fetchSkillDetail } from "@/lib/api";
import type { SkillDetail, SkillSummary } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
const { t } = useTranslation();
const availableCount = skills.filter((skill) => skill.available).length;
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
return (
<div className="space-y-7">
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{t("settings.skills.description", {
defaultValue: "Review the instruction skills this agent can load during a conversation.",
})}
</p>
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.skills.caption", {
available: availableCount,
total: skills.length,
defaultValue: "{{available}} available · {{total}} total",
})}
</span>
</section>
<section>
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{skills.length}
</span>
</div>
{skills.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
skill={skill}
onSelect={setSelectedSkill}
/>
))}
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
</div>
)}
</section>
<SkillDetailSheet
skill={selectedSkill}
open={selectedSkill !== null}
onOpenChange={(open) => {
if (!open) setSelectedSkill(null);
}}
/>
</div>
);
}
function SkillCatalogRow({
skill,
onSelect,
}: {
skill: SkillSummary;
onSelect: (skill: SkillSummary) => void;
}) {
const { t } = useTranslation();
const sourceLabel = skillSourceLabel(skill.source, t);
const StatusIcon = skill.available ? Check : CircleAlert;
const statusLabel = skill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
return (
<button
type="button"
aria-label={t("settings.skills.openDetails", {
name: skill.name,
defaultValue: "Open details for {{name}}",
})}
onClick={() => onSelect(skill)}
className={cn(
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!skill.available && "opacity-65",
)}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
{skill.name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
{sourceLabel}
</span>
</div>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
{skill.description}
</p>
{!skill.available && skill.unavailable_reason ? (
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
{t("settings.skills.unavailableReason", {
reason: skill.unavailable_reason,
defaultValue: "Missing: {{reason}}",
})}
</p>
) : null}
</div>
<span
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
className={cn(
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
skill.available
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
{statusLabel}
</span>
</button>
);
}
function SkillDetailSheet({
skill,
open,
onOpenChange,
}: {
skill: SkillSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { token } = useClient();
const { t } = useTranslation();
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
if (!open || !skill) return;
let cancelled = false;
setDetail(null);
setLoading(true);
setLoadFailed(false);
fetchSkillDetail(token, skill.name)
.then((payload) => {
if (!cancelled) setDetail(payload);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, skill, token]);
if (!skill) return null;
const activeSkill = detail ?? skill;
const sourceLabel = skillSourceLabel(activeSkill.source, t);
const statusLabel = activeSkill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0">
<SheetTitle className="truncate text-[20px] font-semibold">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
{t("settings.skills.detailDescription", {
name: activeSkill.name,
defaultValue: "Details for {{name}}.",
})}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<Pill>{sourceLabel}</Pill>
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
</div>
</div>
</div>
{loading ? (
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
<div className="mt-7 space-y-6">
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
</DetailSection>
<div className="grid grid-cols-2 gap-2">
<MetaItem
label={t("settings.skills.source", { defaultValue: "Source" })}
value={sourceLabel}
/>
<MetaItem
label={t("settings.skills.status", { defaultValue: "Status" })}
value={statusLabel}
/>
</div>
{!activeSkill.available && activeSkill.unavailable_reason ? (
<DetailSection
title={t("settings.skills.unavailableReasonLabel", {
defaultValue: "Unavailable reason",
})}
>
<p className="text-[13px] leading-5 text-destructive/85">
{activeSkill.unavailable_reason}
</p>
</DetailSection>
) : null}
{detail ? <RequirementsSection detail={detail} /> : null}
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
function RawInstructionsBlock({ markdown }: { markdown: string }) {
const { t } = useTranslation();
const content =
markdown ||
t("settings.skills.rawInstructionsEmpty", {
defaultValue: "No raw instructions.",
});
return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
</summary>
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
<pre
className={cn(
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
"whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.7] text-foreground/62",
"scrollbar-thin scrollbar-track-transparent",
"[&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/25",
)}
>
{content}
</pre>
</div>
</details>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
</div>
);
}
function RequirementsSection({ detail }: { detail: SkillDetail }) {
const { t } = useTranslation();
const { bins, env, missing_bins, missing_env } = detail.requirements;
const hasRequirements = bins.length > 0 || env.length > 0;
return (
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
{hasRequirements ? (
<div className="space-y-3">
{missing_bins.length ? (
<RequirementLine
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
items={missing_bins}
tone="danger"
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{missing_env.length ? (
<RequirementLine
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
items={missing_env}
tone="danger"
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{bins.length ? (
<RequirementLine
title={t("settings.skills.commands", { defaultValue: "Commands" })}
items={bins}
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{env.length ? (
<RequirementLine
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
items={env}
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
</p>
)}
</DetailSection>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
{children}
</section>
);
}
function RequirementLine({
title,
items,
icon,
tone = "muted",
}: {
title: string;
items: string[];
icon: ReactNode;
tone?: "muted" | "danger";
}) {
return (
<div className="space-y-1.5">
<div
className={cn(
"flex items-center gap-1.5 text-[12px]",
tone === "danger" ? "text-destructive" : "text-muted-foreground",
)}
>
{icon}
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map((item) => (
<Pill key={item}>{item}</Pill>
))}
</div>
</div>
);
}
function Pill({
children,
tone = "muted",
}: {
children: ReactNode;
tone?: "muted" | "success";
}) {
return (
<span
className={cn(
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
tone === "success"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
{children}
</span>
);
}
function skillSourceLabel(source: string, t: TFunction): string {
if (source === "workspace") {
return t("settings.skills.sourceWorkspace", { defaultValue: "Custom" });
}
if (source === "builtin") {
return t("settings.skills.sourceBuiltin", { defaultValue: "Built-in" });
}
return source;
}

View File

@ -0,0 +1,250 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { SettingsPayload } from "@/lib/types";
type TokenUsagePayload = NonNullable<SettingsPayload["usage"]>;
type TokenUsageDay = TokenUsagePayload["days"][number];
type TokenUsageCell = {
date: string;
total: number;
estimated: number;
requests: number;
sources: NonNullable<TokenUsageDay["sources"]>;
future: boolean;
};
type TokenUsageMonthLabel = {
label: string;
column: number;
};
const TOKEN_HEATMAP_CELLS = 371;
const TOKEN_HEATMAP_COLUMNS = Math.ceil(TOKEN_HEATMAP_CELLS / 7);
const TOKEN_USAGE_SOURCE_ORDER = ["user", "api", "cron", "dream", "system"] as const;
function addUtcDays(date: Date, days: number): Date {
const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function isoDay(date: Date): string {
return date.toISOString().slice(0, 10);
}
function utcDateFromIsoDay(day: string): Date {
const [year, month, date] = day.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, date));
}
function isoDayInTimeZone(date: Date, timeZone: string | undefined): string {
if (!timeZone) return isoDay(date);
try {
const parts = new Intl.DateTimeFormat("en", {
calendar: "gregory",
numberingSystem: "latn",
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
if (values.year && values.month && values.day) {
return [
values.year.padStart(4, "0"),
values.month.padStart(2, "0"),
values.day.padStart(2, "0"),
].join("-");
}
} catch {
// Fall through to UTC when the browser cannot resolve the configured timezone.
}
return isoDay(date);
}
function buildTokenUsageCalendar(
days: TokenUsageDay[] | undefined,
monthFormatter: Intl.DateTimeFormat,
timeZone: string | undefined,
): { cells: TokenUsageCell[]; monthLabels: TokenUsageMonthLabel[] } {
const byDate = new Map((days ?? []).map((day) => [day.date, day]));
const today = utcDateFromIsoDay(isoDayInTimeZone(new Date(), timeZone));
const end = addUtcDays(today, 6 - today.getUTCDay());
const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1));
const monthLabels: TokenUsageMonthLabel[] = [];
const cells = Array.from({ length: TOKEN_HEATMAP_CELLS }, (_, index) => {
const date = addUtcDays(start, index);
const key = isoDay(date);
const row = byDate.get(key);
if (date.getUTCDate() === 1) {
monthLabels.push({
label: monthFormatter.format(date),
column: Math.floor(index / 7) + 1,
});
}
return {
date: key,
total: row?.total_tokens ?? 0,
estimated: row?.estimated_tokens ?? 0,
requests: row?.requests ?? 0,
sources: row?.sources ?? {},
future: date > today,
};
});
return { cells, monthLabels };
}
function tokenUsageSourceLabel(
source: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (source === "user") return tx("settings.usage.sources.user", "Chat");
if (source === "api") return tx("settings.usage.sources.api", "API");
if (source === "cron") return tx("settings.usage.sources.cron", "Automations");
if (source === "dream") return tx("settings.usage.sources.dream", "Memory");
return tx("settings.usage.sources.system", "System");
}
function tokenUsageSourceBreakdown(
cell: TokenUsageCell,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
const known = TOKEN_USAGE_SOURCE_ORDER.filter((source) => cell.sources[source]?.total_tokens > 0);
const extra = Object.keys(cell.sources)
.filter((source) => !TOKEN_USAGE_SOURCE_ORDER.includes(source as typeof TOKEN_USAGE_SOURCE_ORDER[number]))
.filter((source) => cell.sources[source]?.total_tokens > 0)
.sort();
return [...known, ...extra]
.map((source) => {
const label = tokenUsageSourceLabel(source, tx);
const tokens = formatCompactTokens(cell.sources[source]?.total_tokens ?? 0);
return `${label} ${tokens}`;
})
.join(" · ");
}
function formatCompactTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1)}K`;
return String(tokens);
}
function tokenUsageLevel(tokens: number, max: number): number {
if (tokens <= 0 || max <= 0) return 0;
const ratio = tokens / max;
if (ratio >= 0.75) return 4;
if (ratio >= 0.45) return 3;
if (ratio >= 0.2) return 2;
return 1;
}
function tokenUsageCellClass(level: number, future: boolean): string {
if (future) return "bg-transparent ring-1 ring-neutral-200/70 dark:ring-white/[0.045]";
if (level === 4) return "bg-sky-300 dark:bg-sky-300";
if (level === 3) return "bg-sky-400/85 dark:bg-sky-500/80";
if (level === 2) return "bg-sky-500/60 dark:bg-sky-700/85";
if (level === 1) return "bg-sky-500/30 dark:bg-sky-900/80";
return "bg-neutral-200/70 ring-1 ring-black/[0.025] dark:bg-white/[0.08] dark:ring-white/[0.035]";
}
export function TokenUsageHeatmap({
usage,
timeZone,
}: {
usage?: TokenUsagePayload;
timeZone?: string;
}) {
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const monthFormatter = useMemo(
() => new Intl.DateTimeFormat(i18n.language, { month: "short", timeZone: "UTC" }),
[i18n.language],
);
const { cells, monthLabels } = useMemo(
() => buildTokenUsageCalendar(usage?.days, monthFormatter, timeZone),
[monthFormatter, timeZone, usage?.days],
);
const maxTokens = Math.max(0, ...cells.map((cell) => cell.total));
return (
<div className="overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="mx-auto w-full min-w-0 max-w-[1054px] px-0.5 sm:min-w-[760px]">
<div className="mb-2 flex justify-end">
<span className="text-[11px] font-normal leading-none text-muted-foreground/64">
{tx("settings.usage.shortTitle", "Token Usage")}
</span>
</div>
<div className="relative mb-2 h-4 text-[10px] font-normal leading-4 text-muted-foreground/62" aria-hidden>
{monthLabels.map((month) => (
<span
key={`${month.label}-${month.column}`}
className="absolute top-0 whitespace-nowrap"
style={{ left: `${((month.column - 1) / TOKEN_HEATMAP_COLUMNS) * 100}%` }}
>
{month.label}
</span>
))}
</div>
<div
className="grid grid-flow-col grid-rows-7 gap-[3px] sm:gap-1.5"
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
aria-label={tx("settings.usage.title", "Token activity")}
>
<TooltipProvider delayDuration={120} skipDelayDuration={80}>
{cells.map((cell) => {
const level = tokenUsageLevel(cell.total, maxTokens);
const baseLabel = cell.future
? cell.date
: tx("settings.usage.cellTitle", "{{date}}: {{tokens}} tokens, {{requests}} requests", {
date: cell.date,
tokens: formatCompactTokens(cell.total),
requests: cell.requests,
});
const label = cell.future || cell.estimated <= 0
? baseLabel
: `${baseLabel} · ${
cell.estimated >= cell.total
? tx("settings.usage.estimated", "estimated")
: tx("settings.usage.includesEstimates", "includes estimates")
}`;
const breakdown = cell.future ? "" : tokenUsageSourceBreakdown(cell, tx);
const ariaLabel = breakdown ? `${label} · ${breakdown}` : label;
return (
<Tooltip key={cell.date}>
<TooltipTrigger asChild>
<span
aria-label={ariaLabel}
className={cn(
"aspect-square w-full rounded-[2px] transition-transform hover:scale-110 sm:rounded-[4px]",
tokenUsageCellClass(level, cell.future),
)}
/>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="rounded-[10px] border-border/45 bg-popover px-2.5 py-1.5 text-[11px] font-normal text-popover-foreground shadow-lg"
>
<span className="block">{label}</span>
{breakdown ? (
<span className="mt-1 block text-muted-foreground">{breakdown}</span>
) : null}
</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,149 @@
import { useMemo, useState } from "react";
import { ListTree, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
import {
type PromptAnchor,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
import { fmtDateTime } from "@/lib/format";
import type { UIMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
interface PromptNavigatorProps {
messages: UIMessage[];
onJumpToPrompt: (promptId: string) => void;
}
export function PromptNavigator({
messages,
onJumpToPrompt,
}: PromptNavigatorProps) {
const { i18n, t } = useTranslation();
const prompts = useMemo(() => userPromptAnchors(messages), [messages]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filteredPrompts = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return prompts;
return prompts.filter((prompt) =>
`${prompt.label}\n${prompt.preview}`.toLocaleLowerCase().includes(needle),
);
}, [prompts, query]);
if (prompts.length === 0) return null;
const jump = (promptId: string) => {
setOpen(false);
onJumpToPrompt(promptId);
};
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/80",
"hover:bg-accent/40 hover:text-foreground",
)}
aria-label={t("thread.promptNavigator.open")}
onClick={() => setOpen(true)}
>
<ListTree className="h-4 w-4" />
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
aria-describedby={undefined}
className="w-[min(92vw,24rem)] gap-0 p-0 sm:max-w-[24rem]"
>
<div className="border-b px-5 pb-4 pt-5">
<SheetTitle className="text-base font-medium">
{t("thread.promptNavigator.title")}
</SheetTitle>
<div className="relative mt-4">
<Search
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label={t("thread.promptNavigator.search")}
placeholder={t("thread.promptNavigator.search")}
className={cn(
"h-10 w-full rounded-full border border-border bg-background pl-9 pr-3 text-sm",
"outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/20",
)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{filteredPrompts.length > 0 ? (
<div className="space-y-1">
{filteredPrompts.map((prompt) => (
<PromptNavigatorRow
key={prompt.id}
locale={i18n.resolvedLanguage || i18n.language}
prompt={prompt}
onJump={jump}
/>
))}
</div>
) : (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
{t("thread.promptNavigator.noResults")}
</div>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}
interface PromptNavigatorRowProps {
locale: string;
onJump: (promptId: string) => void;
prompt: PromptAnchor;
}
function PromptNavigatorRow({
locale,
onJump,
prompt,
}: PromptNavigatorRowProps) {
const { t } = useTranslation();
const timestamp = fmtDateTime(prompt.createdAt, locale);
return (
<button
type="button"
className={cn(
"w-full rounded-xl px-3 py-3 text-left transition",
"hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
)}
aria-label={t("thread.promptNavigator.jumpTo", { label: prompt.label })}
onClick={() => onJump(prompt.id)}
>
<div className="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-sm leading-5 text-foreground">
{prompt.preview}
</div>
{timestamp ? (
<div className="mt-1 text-[10px] leading-4 text-muted-foreground/75">
{timestamp}
</div>
) : null}
</button>
);
}

View File

@ -0,0 +1,344 @@
import {
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import {
findPromptElement,
jumpToPrompt,
type PromptAnchor,
promptTop,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
interface PromptRailProps {
bottomOffset: number;
messages: UIMessage[];
scrollRef: RefObject<HTMLDivElement>;
}
interface MeasuredPrompt extends PromptAnchor {
top: number;
topPercent: number;
}
interface PromptMarker {
count: number;
ids: string[];
label: string;
preview: string;
topPercent: number;
}
const MIN_PROMPTS_FOR_RAIL = 3;
const RAIL_MIN_SCROLL_RANGE_PX = 80;
const DENSE_PROMPT_THRESHOLD = 30;
const DENSE_BUCKET_HEIGHT_PX = 12;
const DENSE_BUCKET_FALLBACK_COUNT = 32;
const DENSE_BUCKET_MAX_COUNT = 42;
const MARKER_MIN_GAP_PX = 9;
const MARKER_BASE_WIDTH_PX = 16;
const MARKER_MAX_WIDTH_PX = 28;
const MEASURE_RETRY_FRAMES = 4;
const RAIL_REVEAL_MS = 1400;
export function PromptRail({
bottomOffset,
messages,
scrollRef,
}: PromptRailProps) {
const railRef = useRef<HTMLDivElement>(null);
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
const [markers, setMarkers] = useState<PromptMarker[]>([]);
const [activePromptId, setActivePromptId] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const revealTimeoutRef = useRef<number | null>(null);
const revealTemporarily = useCallback(() => {
setRevealed(true);
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
revealTimeoutRef.current = window.setTimeout(() => {
setRevealed(false);
revealTimeoutRef.current = null;
}, RAIL_REVEAL_MS);
}, []);
const updateMarkers = useCallback(() => {
const scrollEl = scrollRef.current;
if (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) {
setMarkers([]);
setActivePromptId(null);
return;
}
const scrollRange = scrollEl.scrollHeight - scrollEl.clientHeight;
if (scrollRange < RAIL_MIN_SCROLL_RANGE_PX) {
setMarkers([]);
setActivePromptId(null);
return;
}
const measured = measurePrompts(scrollEl, promptAnchors, scrollRange);
setMarkers(groupPromptMarkers(measured, railRef.current?.clientHeight ?? 0));
setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop));
}, [promptAnchors, scrollRef]);
useEffect(() => {
let frame = 0;
let remainingFrames = MEASURE_RETRY_FRAMES;
const measure = () => {
updateMarkers();
remainingFrames -= 1;
if (remainingFrames > 0) {
frame = window.requestAnimationFrame(measure);
}
};
measure();
return () => window.cancelAnimationFrame(frame);
}, [bottomOffset, updateMarkers]);
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl) return undefined;
let frame = 0;
const schedule = () => {
window.cancelAnimationFrame(frame);
revealTemporarily();
frame = window.requestAnimationFrame(updateMarkers);
};
scrollEl.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
return () => {
window.cancelAnimationFrame(frame);
scrollEl.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
};
}, [revealTemporarily, scrollRef, updateMarkers]);
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl || typeof ResizeObserver === "undefined") return undefined;
const observer = new ResizeObserver(() => updateMarkers());
observer.observe(scrollEl);
if (scrollEl.firstElementChild) observer.observe(scrollEl.firstElementChild);
return () => observer.disconnect();
}, [scrollRef, updateMarkers]);
useEffect(() => {
return () => {
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
};
}, []);
if (markers.length === 0) return null;
const maxMarkerCount = Math.max(...markers.map((marker) => marker.count));
const activeMarkerIndex = markers.findIndex((marker) =>
marker.ids.includes(activePromptId ?? ""),
);
return (
<div
ref={railRef}
aria-label="User prompt navigation"
className={cn(
"group pointer-events-auto absolute right-4 top-14 z-20 hidden w-8 opacity-70 md:block",
"transition-opacity duration-200 hover:opacity-100",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
)}
style={{ bottom: Math.max(80, bottomOffset) }}
>
{markers.map((marker, index) => {
const active = marker.ids.includes(activePromptId ?? "");
const nearActive = activeMarkerIndex < 0 || Math.abs(index - activeMarkerIndex) <= 1;
return (
<button
key={marker.ids.join("|")}
type="button"
aria-label={`Jump to prompt: ${marker.label}`}
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
className={cn(
"group/marker absolute right-0 h-5 -translate-y-1/2 overflow-visible rounded-full",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
)}
style={{
top: `${marker.topPercent}%`,
width: markerWidth(marker.count, maxMarkerCount, active),
}}
>
<span
aria-hidden
className={cn(
"absolute right-0 top-1/2 h-[3px] w-full -translate-y-1/2 rounded-full",
"bg-foreground/20 transition-[background-color,opacity,transform,height] duration-200",
"group-hover/marker:bg-blue-500/70 group-hover/marker:opacity-100 group-hover/marker:scale-x-110",
"group-focus-visible/marker:bg-blue-500 group-focus-visible/marker:opacity-100 group-focus-visible/marker:scale-x-110",
marker.count > 1 && "bg-foreground/30",
active && "h-1 bg-foreground/65 opacity-80 shadow-sm",
!active && nearActive && "opacity-25 group-hover:opacity-55",
!active && !nearActive && !revealed && "opacity-0 group-hover:opacity-40",
!active && !nearActive && revealed && "opacity-35",
)}
/>
<span
aria-hidden
className={cn(
"pointer-events-none absolute right-9 top-1/2 z-30 w-64 -translate-y-1/2 rounded-lg px-3 py-2 text-left",
"bg-background/95 text-xs leading-5 text-foreground shadow-lg ring-1 ring-border/80 backdrop-blur",
"opacity-0 translate-x-1 transition-[opacity,transform] duration-150",
"group-hover/marker:opacity-100 group-hover/marker:translate-x-0",
"group-focus-visible/marker:opacity-100 group-focus-visible/marker:translate-x-0",
)}
>
<span className="block max-h-24 overflow-hidden whitespace-pre-wrap break-words">
{marker.preview}
</span>
</span>
</button>
);
})}
</div>
);
}
function measurePrompts(
scrollEl: HTMLElement,
anchors: PromptAnchor[],
scrollRange: number,
): MeasuredPrompt[] {
return anchors.flatMap((anchor) => {
const target = findPromptElement(scrollEl, anchor.id);
if (!target) return [];
const top = Math.max(0, Math.min(scrollRange, promptTop(scrollEl, target) - 16));
return [{
...anchor,
top,
topPercent: clamp((top / scrollRange) * 100, 2, 98),
}];
});
}
function groupPromptMarkers(
measured: MeasuredPrompt[],
railHeight: number,
): PromptMarker[] {
if (measured.length === 0) return [];
if (measured.length >= DENSE_PROMPT_THRESHOLD) {
return bucketPromptMarkers(measured, railHeight);
}
const minGapPercent = railHeight > 0
? (MARKER_MIN_GAP_PX / railHeight) * 100
: 2;
const groups: PromptMarker[] = [];
for (const prompt of measured) {
const last = groups[groups.length - 1];
if (last && prompt.topPercent - last.topPercent < minGapPercent) {
last.count += 1;
last.ids.push(prompt.id);
last.label = groupedPromptLabel(last.count, prompt.label);
last.preview = groupedPromptPreview(last.count, prompt.preview);
continue;
}
groups.push({
count: 1,
ids: [prompt.id],
label: prompt.label,
preview: prompt.preview,
topPercent: prompt.topPercent,
});
}
return groups;
}
function bucketPromptMarkers(
measured: MeasuredPrompt[],
railHeight: number,
): PromptMarker[] {
const bucketCount = railHeight > 0
? clamp(
Math.floor(railHeight / DENSE_BUCKET_HEIGHT_PX),
1,
DENSE_BUCKET_MAX_COUNT,
)
: DENSE_BUCKET_FALLBACK_COUNT;
const buckets = Array.from({ length: bucketCount }, () => [] as MeasuredPrompt[]);
for (const prompt of measured) {
const bucketIndex = clamp(
Math.floor((prompt.topPercent / 100) * bucketCount),
0,
bucketCount - 1,
);
buckets[bucketIndex].push(prompt);
}
return buckets.flatMap((bucket) => {
if (bucket.length === 0) return [];
const latest = bucket[bucket.length - 1];
const topPercent =
bucket.reduce((sum, prompt) => sum + prompt.topPercent, 0) / bucket.length;
return [{
count: bucket.length,
ids: bucket.map((prompt) => prompt.id),
label: bucket.length === 1
? latest.label
: groupedPromptLabel(bucket.length, latest.label),
preview: bucket.length === 1
? latest.preview
: groupedPromptPreview(bucket.length, latest.preview),
topPercent,
}];
});
}
function activePromptForScroll(
measured: MeasuredPrompt[],
scrollTop: number,
): string | null {
if (measured.length === 0) return null;
let active = measured[0];
const cursor = scrollTop + 96;
for (const prompt of measured) {
if (prompt.top <= cursor) {
active = prompt;
continue;
}
break;
}
return active.id;
}
function groupedPromptLabel(count: number, latestLabel: string): string {
return `${count} prompts, latest: ${latestLabel}`;
}
function groupedPromptPreview(count: number, latestPreview: string): string {
return `${count} prompts\n\n${latestPreview}`;
}
function markerWidth(count: number, maxCount: number, active: boolean): number {
if (maxCount <= 1) return active ? 34 : MARKER_BASE_WIDTH_PX;
const density = Math.log2(count + 1) / Math.log2(maxCount + 1);
const width = MARKER_BASE_WIDTH_PX
+ (MARKER_MAX_WIDTH_PX - MARKER_BASE_WIDTH_PX) * density;
return Math.round(active ? width + 4 : width);
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}

View File

@ -0,0 +1,227 @@
import { useState } from "react";
import {
CalendarClock,
CircleAlert,
ListTodo,
RefreshCcw,
} from "lucide-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs";
import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format";
import type { SessionAutomationJob } from "@/lib/types";
import { cn } from "@/lib/utils";
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.345, "week"],
[12, "month"],
[Number.POSITIVE_INFINITY, "year"],
];
interface SessionInfoPopoverProps {
sessionKey: string;
token: string;
title: string;
}
export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopoverProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
const automationContent = loading ? (
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
{t("thread.sessionInfo.loading")}
</div>
) : loadFailed ? (
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
<CircleAlert className="h-3.5 w-3.5" />
{t("thread.sessionInfo.loadFailed")}
</div>
) : jobs.length ? (
<div className="space-y-1.5">
{jobs.map((job) => (
<AutomationRow key={job.id} job={job} now={now} />
))}
</div>
) : (
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
{t("thread.sessionInfo.empty")}
</div>
);
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.sessionInfo")}
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85",
"hover:bg-accent/40 hover:text-foreground",
)}
>
<ListTodo className="h-4 w-4 stroke-[1.75]" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-[min(23rem,calc(100vw-1.5rem))] rounded-[24px] p-0"
>
<div className="space-y-3 px-4 py-3.5">
<div className="min-w-0">
<div className="text-[12px] font-normal text-muted-foreground/75">
{t("thread.sessionInfo.title")}
</div>
<div className="mt-0.5 truncate text-[14px] font-medium text-foreground">
{title || t("thread.sessionInfo.untitled")}
</div>
</div>
<div className="h-px bg-border/45" />
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
<span className="truncate text-[13px] font-medium text-foreground">
{t("thread.sessionInfo.automations")}
</span>
</div>
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground">
{t("thread.sessionInfo.count", { count: jobs.length })}
</span>
</div>
{automationContent}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number }) {
const { t } = useTranslation("common");
const schedule = formatSchedule(job, t);
const nextRun = formatNextRun(job, t, now);
const statusClass = job.enabled
? job.state.last_status === "error"
? "bg-destructive"
: "bg-emerald-500"
: "bg-muted-foreground/35";
return (
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-2.5">
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">{job.name}</span>
{!job.enabled ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10.5px] text-muted-foreground">
{t("thread.sessionInfo.disabled")}
</span>
) : null}
</div>
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
{job.payload.message}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-muted-foreground/80">
<span>{schedule}</span>
<span aria-hidden>·</span>
<span title={nextRun.title}>{nextRun.label}</span>
</div>
</div>
</div>
</div>
);
}
function formatSchedule(job: SessionAutomationJob, t: TFunction) {
const locale = currentLocale();
if (job.schedule.kind === "at" && job.schedule.at_ms) {
return t("thread.sessionInfo.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
}
if (job.schedule.kind === "every" && job.schedule.every_ms) {
return t("thread.sessionInfo.schedule.every", {
duration: formatDuration(job.schedule.every_ms, locale),
});
}
if (job.schedule.kind === "cron" && job.schedule.expr) {
return job.schedule.tz
? t("thread.sessionInfo.schedule.cronWithTz", {
expr: job.schedule.expr,
tz: job.schedule.tz,
})
: t("thread.sessionInfo.schedule.cron", { expr: job.schedule.expr });
}
return t("thread.sessionInfo.schedule.unknown");
}
function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
const locale = currentLocale();
if (!job.enabled) {
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
}
if (job.state.pending) {
return { label: t("thread.sessionInfo.next.pending"), title: "" };
}
const next = job.state.next_run_at_ms;
if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" };
}
return {
label: t("thread.sessionInfo.next.label", { time: relativeTimeFrom(next, now, locale) }),
title: fmtDateTime(next, locale),
};
}
function relativeTimeFrom(value: number, now: number, locale: string): string {
let delta = (value - now) / 1000;
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const [step, unit] of RELATIVE_THRESHOLDS) {
if (Math.abs(delta) < step) {
return formatter.format(Math.round(delta), unit);
}
delta /= step;
}
return formatter.format(Math.round(delta), "year");
}
function formatDuration(ms: number, locale: string): string {
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
["second", 1000],
];
for (const [unit, size] of units) {
if (ms >= size && ms % size === 0) {
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
maximumFractionDigits: 0,
}).format(ms / size);
}
}
return new Intl.NumberFormat(locale, {
style: "unit",
unit: "minute",
unitDisplay: "long",
maximumFractionDigits: 1,
}).format(ms / 60_000);
}

View File

@ -0,0 +1,77 @@
import { AlertTriangle, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { StreamError } from "@/lib/nanobot-client";
interface StreamErrorNoticeProps {
error: StreamError;
onDismiss: () => void;
}
/**
* Dismissible banner that surfaces transport-level faults the user needs to
* know about. Rendered above the composer so the message the fault referred
* to remains in view just above. ``role="alert"`` + ``aria-live="assertive"``
* ensures screen readers announce the failure.
*/
export function StreamErrorNotice({ error, onDismiss }: StreamErrorNoticeProps) {
const { t } = useTranslation();
const { title, body } = resolveCopy(error, t);
return (
<div
role="alert"
aria-live="assertive"
className={cn(
"mb-2 flex items-start gap-2 rounded-lg border border-destructive/30",
"bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive",
"animate-in fade-in-0 slide-in-from-bottom-1",
)}
>
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden
/>
<div className="flex-1">
<p className="font-medium">{title}</p>
<p className="mt-0.5 text-destructive/80">{body}</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={onDismiss}
aria-label={t("common.dismiss")}
className="h-6 w-6 shrink-0 text-destructive hover:bg-destructive/15 hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function resolveCopy(
error: StreamError,
t: (key: string) => string,
): { title: string; body: string } {
switch (error.kind) {
case "message_too_big":
return {
title: t("errors.messageTooBig.title"),
body: t("errors.messageTooBig.body"),
};
case "workspace_scope_rejected":
return {
title: t("errors.workspaceScopeRejected.title"),
body: t("errors.workspaceScopeRejected.body"),
};
default: {
// Exhaustiveness guard: if a new StreamError kind is added, TS will
// complain here until we add a corresponding i18n branch.
const _exhaustive: never = error;
return { title: String(_exhaustive), body: "" };
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,111 @@
import { Menu, Moon, Sun } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ThreadHeaderProps {
title: string;
onToggleSidebar: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode;
}
export function ThreadHeader({
title,
onToggleSidebar,
theme,
onToggleTheme,
hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false,
minimal = false,
promptNavigatorAction,
sessionInfoAction,
}: ThreadHeaderProps) {
const { t } = useTranslation();
return (
<div
className={cn(
"relative z-10 flex items-center justify-between gap-3 px-3 py-2",
minimal && "h-11",
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
)}
>
<div className="relative flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
{!minimal ? (
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div>
) : null}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction}
{promptNavigatorAction}
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
/>
) : null}
</div>
{!minimal ? (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
) : null}
</div>
);
}
function ThemeButton({
theme,
onToggleTheme,
label,
className,
}: {
theme: "light" | "dark";
onToggleTheme: () => void;
label: string;
className?: string;
}) {
return (
<Button
variant="ghost"
size="icon"
aria-label={label}
onClick={onToggleTheme}
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground",
className,
)}
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
);
}

View File

@ -0,0 +1,285 @@
import { Fragment, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
/** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean;
hiddenUserMessageCount?: number;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
forkBoundaryMessageCount?: number | null;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
}
export type DisplayUnit = TurnUnit;
/** True when this unit index is the last assistant text slice before the next user message (or end of thread). */
export function isFinalAssistantSliceBeforeNextUser(
units: DisplayUnit[],
index: number,
): boolean {
const u = units[index];
if (u.type !== "message" || u.message.role !== "assistant") return true;
for (let j = index + 1; j < units.length; j++) {
const v = units[j];
if (v.type === "message" && v.message.role === "user") break;
return false;
}
return true;
}
export function buildDisplayUnits(
messages: UIMessage[],
isStreaming = false,
): DisplayUnit[] {
const units = normalizeActivityTimeline(messages, {
preserveTrailingActivity: isStreaming,
});
return isStreaming ? placeLiveActivityAtTurnBottom(units) : units;
}
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
const flags = new Array<boolean>(units.length).fill(true);
let hasLaterUnitBeforeUser = false;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "message" && unit.message.role === "user") {
hasLaterUnitBeforeUser = false;
continue;
}
if (unit.type === "message" && unit.message.role === "assistant") {
flags[i] = !hasLaterUnitBeforeUser;
}
hasLaterUnitBeforeUser = true;
}
return flags;
}
export function ThreadMessages({
messages,
isStreaming = false,
hiddenUserMessageCount = 0,
cliApps = [],
mcpPresets = [],
forkBoundaryMessageCount = null,
onOpenFilePreview,
onForkFromMessage,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
const forkBoundaryAfterUnitIndex = useMemo(
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
[forkBoundaryMessageCount, units],
);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units],
);
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
let nextUserIndex = hiddenUserMessageCount;
return (
<div className="flex w-full flex-col">
{units.map((unit, index) => {
const prev = units[index - 1];
const marginTop =
index > 0
? marginAfterPrevUnit(prev)
: "";
const next = units[index + 1];
const hasBodyBelow =
unit.type === "activity"
&& next?.type === "message"
&& next.message.role === "assistant";
const userPromptId =
unit.type === "message" && unit.message.role === "user"
? unit.message.id
: undefined;
const forkIndex =
unit.type === "message" && unit.message.role === "assistant" && copyFlags[index]
? nextUserIndex
: undefined;
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
return (
<Fragment key={unitKeys[index]}>
<div className={marginTop} data-user-prompt-id={userPromptId}>
{unit.type === "activity" ? (
<AgentActivityCluster
messages={unit.messages}
isTurnStreaming={liveActivityClusterIndices.has(index)}
hasBodyBelow={hasBodyBelow}
turnLatencyMs={unit.turnLatencyMs}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
) : (
<MessageBubble
message={unit.message}
showAssistantCopyAction={
unit.message.role === "assistant"
? copyFlags[index]
: true
}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
onForkFromHere={
onForkFromMessage && forkIndex !== undefined
? () => onForkFromMessage(forkIndex)
: undefined
}
/>
)}
</div>
{index === forkBoundaryAfterUnitIndex ? (
<ForkBoundaryDivider label={t("thread.forkedFromHistory")} />
) : null}
</Fragment>
);
})}
</div>
);
}
function unitIndexAfterMessageCount(
units: DisplayUnit[],
messageCount: number | null | undefined,
): number | null {
if (messageCount == null || messageCount <= 0) return null;
let seen = 0;
for (let i = 0; i < units.length; i += 1) {
const unit = units[i];
seen += unit.type === "activity" ? unit.messages.length : 1;
if (seen >= messageCount) return i;
}
return null;
}
function ForkBoundaryDivider({ label }: { label: string }) {
return (
<div className="my-5 flex items-center gap-3 text-[11px] text-muted-foreground/80">
<span aria-hidden className="h-px flex-1 bg-border/70" />
<span className="shrink-0">{label}</span>
<span aria-hidden className="h-px flex-1 bg-border/70" />
</div>
);
}
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
const indices = new Set<number>();
let markedCurrentActivity = false;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "activity") {
if (!markedCurrentActivity) {
indices.add(i);
markedCurrentActivity = true;
}
continue;
}
if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
if (unit.message.role === "user") break;
}
return indices;
}
function placeLiveActivityAtTurnBottom(units: DisplayUnit[]): DisplayUnit[] {
if (units.length < 2) return units;
let turnStart = units.length - 1;
while (turnStart > 0) {
const previous = units[turnStart - 1];
if (previous.type === "message" && previous.message.role === "user") break;
turnStart -= 1;
}
const trailingTurn = units.slice(turnStart);
let liveActivityIndex = -1;
for (let i = trailingTurn.length - 1; i >= 0; i -= 1) {
if (trailingTurn[i]?.type === "activity") {
liveActivityIndex = i;
break;
}
}
if (liveActivityIndex === -1 || liveActivityIndex === trailingTurn.length - 1) {
return units;
}
const hasNonActivityAfterLiveActivity = trailingTurn
.slice(liveActivityIndex + 1)
.some((unit) => unit.type !== "activity");
if (hasNonActivityAfterLiveActivity) {
return units;
}
const liveActivityUnit = trailingTurn[liveActivityIndex];
if (!liveActivityUnit || liveActivityUnit.type !== "activity") return units;
const reorderedTurn = [
...trailingTurn.slice(0, liveActivityIndex),
...trailingTurn.slice(liveActivityIndex + 1),
liveActivityUnit,
];
return [...units.slice(0, turnStart), ...reorderedTurn];
}
export function unitKeysForDisplay(units: DisplayUnit[]): string[] {
const occurrences = new Map<string, number>();
return units.map((unit, index) => {
const base = unitKeyBase(unit, index);
if (!base.startsWith("turn-") || base.endsWith("-user")) return base;
const next = (occurrences.get(base) ?? 0) + 1;
occurrences.set(base, next);
return `${base}-${next}`;
});
}
function unitKeyBase(unit: DisplayUnit, index: number): string {
if (unit.type === "activity") {
const anchor = unit.messages[0];
const turnKey = stableTurnMessageKey(anchor, "activity");
if (turnKey) return turnKey;
const anchorId = anchor?.id;
return anchorId != null ? `activity-${anchorId}` : `activity-idx-${index}`;
}
const turnKey = stableTurnMessageKey(unit.message);
if (turnKey) return turnKey;
return unit.message.id;
}
function stableTurnMessageKey(message: UIMessage | undefined, fallbackPhase?: string): string | null {
if (!message?.turnId) return null;
const phase = message.turnPhase ?? fallbackPhase ?? message.kind ?? message.role;
if (message.role === "user") return `turn-${message.turnId}-user`;
if (message.kind === "trace") {
return `turn-${message.turnId}-${phase}-${message.activitySegmentId ?? "activity"}`;
}
return `turn-${message.turnId}-${phase}`;
}
function marginAfterPrevUnit(prev: DisplayUnit): string {
if (prev.type === "activity") {
return "mt-4";
}
const p = prev.message;
const denseP =
p.kind === "trace"
|| (
p.role === "assistant"
&& p.content.trim().length === 0
&& (!!p.reasoning || !!p.reasoningStreaming)
);
if (denseP) {
return "mt-2";
}
return "mt-5";
}

View File

@ -0,0 +1,869 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import {
fetchInstalledCliApps,
fetchMcpPresets,
fetchSettings,
listSlashCommands,
} from "@/lib/api";
import {
CLI_APPS_CHANGED_EVENT,
installedCliAppsFromPayload,
isCliAppsPayload,
} from "@/lib/cli-app-events";
import {
MCP_PRESETS_CHANGED_EVENT,
installedMcpPresetsFromPayload,
isMcpPresetsPayload,
} from "@/lib/mcp-preset-events";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type {
ChatSummary,
KnowledgeBaseCard,
SettingsPayload,
SlashCommand,
UIMessage,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
}
type MessageShape = Pick<UIMessage, "role" | "kind" | "content">;
function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
return (
a.role === b.role
&& (a.kind ?? "") === (b.kind ?? "")
&& a.content === b.content
);
}
function durableMessageShape(message: UIMessage): MessageShape | null {
if (message.kind === "trace") return null;
if (message.role !== "user" && message.role !== "assistant") return null;
if (message.role === "assistant" && !message.content.trim() && !message.media?.length) {
return null;
}
return {
role: message.role,
kind: message.kind,
content: message.content,
};
}
function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]): boolean {
// Canonical history refreshes can race with live websocket messages after fork/send.
// Never accept a refreshed snapshot that drops a user/assistant message already shown.
const expected = current
.map(durableMessageShape)
.filter((message): message is MessageShape => message !== null);
if (expected.length === 0) return true;
const candidates = snapshot
.map(durableMessageShape)
.filter((message): message is MessageShape => message !== null);
let cursor = 0;
for (const message of expected) {
let found = false;
while (cursor < candidates.length) {
const candidate = candidates[cursor];
cursor += 1;
if (sameMessageShape(message, candidate)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
}
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
if (current.length === 0) return false;
if (snapshot.length === 0) return true;
if (!preservesDurableMessages(current, snapshot)) return true;
if (snapshot.length >= current.length) return false;
return snapshot.every((message, index) => sameMessageShape(current[index], message));
}
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
const FILE_PREVIEW_MIN_WIDTH = 360;
const FILE_PREVIEW_MAX_WIDTH = 860;
const FILE_PREVIEW_MIN_MAIN_WIDTH = 420;
const FILE_PREVIEW_CLOSE_ANIMATION_MS = 320;
function clampFilePreviewWidth(width: number, maxWidth: number): number {
return Math.min(Math.max(width, FILE_PREVIEW_MIN_WIDTH), maxWidth);
}
function maxFilePreviewWidth(containerWidth: number): number {
return Math.max(
FILE_PREVIEW_MIN_WIDTH,
Math.min(FILE_PREVIEW_MAX_WIDTH, containerWidth - FILE_PREVIEW_MIN_MAIN_WIDTH),
);
}
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
onToggleSidebar: () => void;
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: (
workspaceScope?: WorkspaceScopePayload | null,
knowledgeBaseId?: string | null,
) => Promise<string | null>;
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
knowledgeBases?: KnowledgeBaseCard[];
selectedKnowledgeBaseId?: string | null;
onKnowledgeBaseChange?: (knowledgeBaseId: string | null) => void;
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
}
function toModelBadgeLabel(modelName: string | null): string | null {
if (!modelName) return null;
const trimmed = modelName.trim();
if (!trimmed) return null;
const leaf = trimmed.split("/").pop() ?? trimmed;
return leaf || trimmed;
}
interface ModelBadgeInfo {
label: string | null;
provider: string | null;
providerLabel: string | null;
needsSetup: boolean;
}
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
if (!settings) return null;
const configured = settings.agent.model_preset || "default";
return (
settings.model_presets.find((preset) => preset.name === configured)
?? settings.model_presets.find((preset) => preset.active)
?? null
);
}
function resolvedModelProvider(settings: SettingsPayload | null, modelName: string | null): string | null {
const preset = activeModelPreset(settings);
const rawProvider = preset?.provider || settings?.agent.provider || null;
if (rawProvider === "auto") {
return settings?.agent.resolved_provider || inferProviderFromModelName(modelName) || null;
}
return rawProvider || inferProviderFromModelName(modelName);
}
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
const model = modelName || settings?.agent.model || null;
const label = toModelBadgeLabel(model);
const provider = resolvedModelProvider(settings, model);
const providerRow = provider
? settings?.providers.find((item) => item.name === provider)
: null;
const needsSetup = Boolean(
settings && (!model || !provider || !providerRow || !providerRow.configured),
);
return {
label,
provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
needsSetup,
};
}
const HERO_GREETING_KEYS = [
"thread.empty.greetings.workOn",
"thread.empty.greetings.start",
"thread.empty.greetings.build",
"thread.empty.greetings.tackle",
] as const;
function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
const index = Math.floor(Math.random() * HERO_GREETING_KEYS.length);
return HERO_GREETING_KEYS[index] ?? HERO_GREETING_KEYS[0];
}
interface PendingFirstMessage {
content: string;
images?: SendImage[];
options?: SendOptions;
}
interface InstalledSettingItemsOptions<Payload, Item> {
token: string;
eventName: string;
fetchPayload: (token: string) => Promise<Payload>;
isPayload: (value: unknown) => value is Payload;
selectItems: (payload: Payload) => Item[];
}
function useInstalledSettingItems<Payload, Item>({
token,
eventName,
fetchPayload,
isPayload,
selectItems,
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
const [items, setItems] = useState<Item[]>([]);
const refresh = useCallback(async (isCancelled?: () => boolean) => {
try {
const payload = await fetchPayload(token);
if (!isCancelled?.()) setItems(selectItems(payload));
} catch {
if (!isCancelled?.()) setItems([]);
}
}, [fetchPayload, selectItems, token]);
useEffect(() => {
let cancelled = false;
void refresh(() => cancelled);
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refresh();
};
const refreshOnChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isPayload(payload)) {
setItems(selectItems(payload));
return;
}
void refresh();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
window.addEventListener(eventName, refreshOnChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(eventName, refreshOnChanged);
};
}, [eventName, isPayload, refresh, selectItems]);
return items;
}
export function ThreadShell({
session,
title,
onToggleSidebar,
onCreateChat,
onForkChat,
onTurnEnd,
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false,
hideHeader = false,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
workspaceError = null,
onWorkspaceScopeChange,
knowledgeBases = [],
selectedKnowledgeBaseId = null,
onKnowledgeBaseChange,
settingsSnapshot = null,
onOpenModelSettings,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const {
messages: historical,
loading,
loadingOlder,
loadOlder,
hasMoreBefore,
userMessageOffset,
hasPendingToolCalls,
refresh: refreshHistory,
version: historyVersion,
forkBoundaryMessageCount,
} = useSessionHistory(historyKey);
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const cliApps = useInstalledSettingItems({
token,
eventName: CLI_APPS_CHANGED_EVENT,
fetchPayload: fetchInstalledCliApps,
isPayload: isCliAppsPayload,
selectItems: installedCliAppsFromPayload,
});
const mcpPresets = useInstalledSettingItems({
token,
eventName: MCP_PRESETS_CHANGED_EVENT,
fetchPayload: fetchMcpPresets,
isPayload: isMcpPresetsPayload,
selectItems: installedMcpPresetsFromPayload,
});
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = useState(0);
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
const shellRef = useRef<HTMLElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
const skipLayoutCacheRef = useRef(false);
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
const bottomScrolledChatIdRef = useRef<string | null>(null);
const initial = useMemo(() => {
if (!chatId) return historical;
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const handleTurnEnd = useCallback(() => {
onTurnEnd?.();
}, [onTurnEnd]);
const {
messages,
isStreaming,
runStartedAt,
goalState,
send,
transcribeAudio,
stop,
setMessages,
streamError,
dismissStreamError,
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
useEffect(() => {
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
}, [chatId, historyKey]);
useEffect(() => {
filePreviewWidthRef.current = filePreviewWidth;
}, [filePreviewWidth]);
useEffect(() => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(null);
}, [historyKey]);
useEffect(() => {
return () => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
}
};
}, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
const wasShowingHeroComposerRef = useRef(showHeroComposer);
const modelBadge = useMemo(
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const modelBadgeLabel = modelBadge.needsSetup
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
: modelBadge.label;
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
}
wasShowingHeroComposerRef.current = showHeroComposer;
}, [showHeroComposer]);
const withChatContext = useCallback(
(options?: SendOptions): SendOptions | undefined => {
const next: SendOptions = {
...(options ?? {}),
};
if (workspaceScope) {
next.workspaceScope = workspaceScope;
}
if (selectedKnowledgeBaseId) {
next.knowledgeBaseId = selectedKnowledgeBaseId;
}
if (!Object.keys(next).length) return options;
return {
...next,
};
},
[selectedKnowledgeBaseId, workspaceScope],
);
const refreshModelSettings = useCallback(async () => {
try {
setSettings(await fetchSettings(token));
} catch {
if (!settingsSnapshot) setSettings(null);
}
}, [settingsSnapshot, token]);
useEffect(() => {
if (settingsSnapshot) {
setSettings(settingsSnapshot);
return;
}
void refreshModelSettings();
}, [refreshModelSettings, settingsSnapshot]);
useEffect(() => {
return client.onRuntimeModelUpdate(() => {
void refreshModelSettings();
});
}, [client, refreshModelSettings]);
useEffect(() => {
if (!chatId || loading) return;
const cached = messageCacheRef.current.get(chatId);
const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0;
const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId);
const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion;
// When the user switches away and back, keep the local in-memory thread
// state (including not-yet-persisted messages) instead of replacing it with
// whatever the history endpoint currently knows about. Once a fresh
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
// so rendering converges to the same shape as a manual refresh.
setMessages((prev) => {
const normalizedHistory = projectWebuiThreadMessages(historical);
const keepLiveMessages = (messagesToKeep: UIMessage[]) => {
const projected = projectWebuiThreadMessages(messagesToKeep);
messageCacheRef.current.set(chatId, projected);
return projected;
};
if (hasNewCanonicalHistory && historical.length > 0) {
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
pendingCanonicalHydrateRef.current.delete(chatId);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
messageCacheRef.current.set(chatId, normalizedHistory);
return normalizedHistory;
}
if (cached && cached.length > 0) {
const normalizedCached = projectWebuiThreadMessages(cached);
if (
normalizedHistory.length > normalizedCached.length
&& !isStaleThreadSnapshot(prev, normalizedHistory)
) {
messageCacheRef.current.set(chatId, normalizedHistory);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
return normalizedHistory;
}
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
return normalizedCached;
}
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
return normalizedHistory;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical, historyVersion]);
useEffect(() => {
if (!chatId) return;
return client.onSessionUpdate((updatedChatId, scope) => {
if (updatedChatId !== chatId) return;
if (scope === "metadata") return;
pendingCanonicalHydrateRef.current.add(chatId);
refreshHistory();
});
}, [chatId, client, refreshHistory]);
useEffect(() => {
if (!chatId) {
bottomScrolledChatIdRef.current = null;
return;
}
if (loading || bottomScrolledChatIdRef.current === chatId) return;
bottomScrolledChatIdRef.current = chatId;
setScrollToBottomSignal((value) => value + 1);
}, [chatId, loading]);
useEffect(() => {
if (chatId) return;
setMessages(projectWebuiThreadMessages(historical));
}, [chatId, historical, setMessages]);
useLayoutEffect(() => {
if (chatId) {
const prev = prevChatIdForCacheRef.current;
if (prev && prev !== chatId) {
messageCacheRef.current.set(prev, projectWebuiThreadMessages(messages));
skipLayoutCacheRef.current = true;
}
prevChatIdForCacheRef.current = chatId;
} else {
if (prevChatIdForCacheRef.current) {
messageCacheRef.current.set(
prevChatIdForCacheRef.current,
projectWebuiThreadMessages(messages),
);
skipLayoutCacheRef.current = true;
}
prevChatIdForCacheRef.current = null;
}
}, [chatId, messages]);
// Persist thread to in-memory cache after paint so ``useNanobotStream``'s chat switch
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
useEffect(() => {
if (!chatId) {
return;
}
if (skipLayoutCacheRef.current) {
skipLayoutCacheRef.current = false;
return;
}
if (loading) {
return;
}
messageCacheRef.current.set(chatId, projectWebuiThreadMessages(messages));
}, [chatId, loading, messages]);
useEffect(() => {
if (!chatId) return;
const pending = pendingFirstRef.current;
if (!pending) return;
pendingFirstRef.current = null;
setScrollToLatestUserPromptSignal((value) => value + 1);
send(pending.content, pending.images, pending.options);
setBooting(false);
}, [chatId, send]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const commands = await listSlashCommands(token);
if (!cancelled) setSlashCommands(commands);
} catch {
if (!cancelled) setSlashCommands([]);
}
})();
return () => {
cancelled = true;
};
}, [token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = { content, images, options: withChatContext(options) };
const newId = await onCreateChat?.(workspaceScope, selectedKnowledgeBaseId);
if (!newId) {
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onCreateChat, selectedKnowledgeBaseId, withChatContext, workspaceScope],
);
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
setScrollToLatestUserPromptSignal((value) => value + 1);
send(content, images, withChatContext(options));
},
[send, withChatContext],
);
const handleOpenFilePreview = useCallback((path: string) => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(path);
}, []);
const handleCloseFilePreview = useCallback(() => {
if (!filePreviewPath || filePreviewClosing) return;
setFilePreviewClosing(true);
filePreviewCloseTimerRef.current = window.setTimeout(() => {
filePreviewCloseTimerRef.current = null;
setFilePreviewPath(null);
setFilePreviewClosing(false);
}, FILE_PREVIEW_CLOSE_ANIMATION_MS);
}, [filePreviewClosing, filePreviewPath]);
const handleFilePreviewResizeStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const panel = event.currentTarget.closest<HTMLElement>("[data-file-preview-panel]");
const shellRect = shellRef.current?.getBoundingClientRect();
const rightEdge = shellRect?.right ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellRect?.width ?? window.innerWidth);
const originalBodyCursor = document.body.style.cursor;
const originalBodyUserSelect = document.body.style.userSelect;
const originalPanelTransition = panel?.style.transition ?? "";
let nextWidth = filePreviewWidthRef.current;
let frame: number | null = null;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
if (panel) panel.style.transition = "none";
const applyWidth = (clientX: number) => {
nextWidth = clampFilePreviewWidth(rightEdge - clientX, maxWidth);
filePreviewWidthRef.current = nextWidth;
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
});
};
const handlePointerMove = (moveEvent: PointerEvent) => {
moveEvent.preventDefault();
applyWidth(moveEvent.clientX);
};
const handlePointerUp = () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
frame = null;
}
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
if (panel) panel.style.transition = originalPanelTransition;
setFilePreviewWidth(nextWidth);
document.body.style.cursor = originalBodyCursor;
document.body.style.userSelect = originalBodyUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
applyWidth(event.clientX);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, []);
useEffect(() => {
if (!filePreviewPath) return;
const clampToShell = () => {
const shellWidth = shellRef.current?.getBoundingClientRect().width ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellWidth);
const nextWidth = clampFilePreviewWidth(filePreviewWidthRef.current, maxWidth);
filePreviewWidthRef.current = nextWidth;
setFilePreviewWidth(nextWidth);
};
clampToShell();
window.addEventListener("resize", clampToShell);
return () => {
window.removeEventListener("resize", clampToShell);
};
}, [filePreviewPath]);
const handleForkFromMessage = useCallback(
async (beforeUserIndex: number) => {
if (!chatId || !onForkChat) return;
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
if (!forkedChatId) return;
messageCacheRef.current.delete(forkedChatId);
appliedHistoryVersionRef.current.delete(forkedChatId);
pendingCanonicalHydrateRef.current.add(forkedChatId);
},
[chatId, onForkChat],
);
const composer = (
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{session ? (
<ThreadComposer
onSend={handleThreadSend}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
onStop={stop}
onTranscribeAudio={transcribeAudio}
runStartedAt={runStartedAt}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
knowledgeBases={knowledgeBases}
selectedKnowledgeBaseId={selectedKnowledgeBaseId}
onKnowledgeBaseChange={onKnowledgeBaseChange}
pendingQueueKey={chatId}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
runStartedAt={runStartedAt}
onTranscribeAudio={transcribeAudio}
goalState={goalState}
workspaceScope={workspaceScope}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
knowledgeBases={knowledgeBases}
selectedKnowledgeBaseId={selectedKnowledgeBaseId}
onKnowledgeBaseChange={onKnowledgeBaseChange}
/>
)}
</>
);
const emptyState = loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t("thread.loadingConversation")}
</div>
) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<h1 className="max-w-[44rem] text-balance text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight">
{t(heroGreetingKey)}
</h1>
</div>
);
const sessionInfoAction = historyKey ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
const promptNavigatorAction = historyKey ? (
<PromptNavigator
messages={displayMessages}
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
/>
) : undefined;
return (
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
/>
) : null}
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
userMessageOffset={userMessageOffset}
onLoadOlder={loadOlder}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
/>
</div>
{filePreviewPath && historyKey ? (
<FilePreviewPanel
sessionKey={historyKey}
path={filePreviewPath}
token={token}
desktopWidth={filePreviewWidth}
isClosing={filePreviewClosing}
onResizeStart={handleFilePreviewResizeStart}
onClose={handleCloseFilePreview}
/>
) : null}
</section>
);
}

View File

@ -0,0 +1,587 @@
import {
forwardRef,
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PromptRail } from "@/components/thread/PromptRail";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import {
findPromptElement,
jumpToPrompt,
promptTop,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
}
interface ThreadViewportProps {
messages: UIMessage[];
isStreaming: boolean;
composer: ReactNode;
emptyState?: ReactNode;
scrollToBottomSignal?: number;
scrollToLatestUserPromptSignal?: number;
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
forkBoundaryMessageCount?: number | null;
hasMoreBefore?: boolean;
loadingOlder?: boolean;
userMessageOffset?: number;
onLoadOlder?: () => Promise<void> | void;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
}
const NEAR_BOTTOM_PX = 48;
const NEAR_TOP_PX = 96;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
const KEYBOARD_SCROLL_FRAMES = 18;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
if (messages.length <= visibleCount) return messages;
let start = Math.max(0, messages.length - visibleCount);
while (
start > 0
&& isAgentActivityMember(messages[start])
&& isAgentActivityMember(messages[start - 1])
) {
start -= 1;
}
return messages.slice(start);
}
function isKeyboardEditableElement(element: Element | null): element is HTMLElement {
if (!(element instanceof HTMLElement)) return false;
if (element.isContentEditable) return true;
if (element instanceof HTMLTextAreaElement) return true;
if (!(element instanceof HTMLInputElement)) return false;
return ![
"button",
"checkbox",
"color",
"file",
"hidden",
"image",
"radio",
"range",
"reset",
"submit",
].includes(element.type);
}
function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
const viewport = window.visualViewport;
if (!viewport) return 0;
const active = document.activeElement;
if (!isKeyboardEditableElement(active) || !container?.contains(active)) return 0;
const layoutHeight = window.innerHeight || document.documentElement.clientHeight;
const inset = layoutHeight - viewport.height - viewport.offsetTop;
return inset >= SOFT_KEYBOARD_MIN_INSET_PX ? Math.ceil(inset) : 0;
}
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
isStreaming,
composer,
emptyState,
scrollToBottomSignal = 0,
scrollToLatestUserPromptSignal = 0,
conversationKey = null,
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
forkBoundaryMessageCount = null,
hasMoreBefore = false,
loadingOlder = false,
userMessageOffset = 0,
onLoadOlder,
onOpenFilePreview,
onForkFromMessage,
}, ref) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const scrollFrameIdsRef = useRef<number[]>([]);
const programmaticPromptScrollTopRef = useRef<number | null>(null);
const handledLatestPromptSignalRef = useRef(0);
const activeTurnPromptRef = useRef<string | null>(null);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
const userReadingHistoryRef = useRef(false);
const [atBottom, setAtBottom] = useState(true);
const [composerDockHeight, setComposerDockHeight] = useState(0);
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
const [visibleMessageCount, setVisibleMessageCount] =
useState(INITIAL_HISTORY_WINDOW);
const hasMessages = messages.length > 0;
const visibleMessages = useMemo(
() => windowMessages(messages, visibleMessageCount),
[messages, visibleMessageCount],
);
const hiddenMessageCount = messages.length - visibleMessages.length;
const hiddenUserMessageCount =
userMessageOffset
+ (hiddenMessageCount > 0
? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length
: 0);
const visibleForkBoundaryMessageCount =
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
? forkBoundaryMessageCount - hiddenMessageCount
: null;
const scrollButtonBottom =
keyboardInsetBottom
+ (composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
const scrollViewportStyle =
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
const cancelScheduledBottomScroll = useCallback(() => {
for (const id of scrollFrameIdsRef.current) {
window.cancelAnimationFrame(id);
}
scrollFrameIdsRef.current = [];
}, []);
const markProgrammaticPromptScroll = useCallback((top: number) => {
programmaticPromptScrollTopRef.current = top;
}, []);
const scrollToBottomNow = useCallback((smooth = false) => {
const el = scrollRef.current;
const marker = bottomRef.current;
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
if (el) {
const top = Math.max(0, el.scrollHeight - el.clientHeight);
try {
el.scrollTo?.({ top, behavior });
if (!smooth) el.scrollTop = top;
} catch {
try {
el.scrollTop = top;
} catch {
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
}
}
} else if (marker) {
marker.scrollIntoView({ block: "end", behavior });
}
userReadingHistoryRef.current = false;
setAtBottom(true);
}, []);
const scrollToPromptTopNow = useCallback((promptId: string) => {
const el = scrollRef.current;
if (!el) return false;
const target = findPromptElement(el, promptId);
if (!target) return false;
const top = Math.max(0, promptTop(el, target) - 16);
markProgrammaticPromptScroll(top);
try {
el.scrollTo?.({ top, behavior: "auto" });
el.scrollTop = top;
} catch {
try {
el.scrollTop = top;
} catch {
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
}
}
const near = el.scrollHeight - top - el.clientHeight < NEAR_BOTTOM_PX;
userReadingHistoryRef.current = false;
setAtBottom(near);
return true;
}, [markProgrammaticPromptScroll]);
const scrollToBottom = useCallback(
(smooth = false, frames = 1, options?: { force?: boolean }) => {
const force = options?.force ?? false;
cancelScheduledBottomScroll();
const run = () => {
if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth);
};
const scheduleNext = (remainingFrames: number) => {
if (remainingFrames <= 0) return;
const id = window.requestAnimationFrame(() => {
scrollFrameIdsRef.current = scrollFrameIdsRef.current.filter((frameId) => frameId !== id);
if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth);
scheduleNext(remainingFrames - 1);
});
scrollFrameIdsRef.current.push(id);
};
run();
scheduleNext(frames - 1);
},
[cancelScheduledBottomScroll, scrollToBottomNow],
);
const loadEarlierMessages = useCallback(() => {
const el = scrollRef.current;
if (el) {
restoreScrollAfterPrependRef.current = {
height: el.scrollHeight,
top: el.scrollTop,
};
}
userReadingHistoryRef.current = true;
activeTurnPromptRef.current = null;
setAtBottom(false);
if (hiddenMessageCount > 0) {
setVisibleMessageCount((count) =>
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
);
return;
}
if (hasMoreBefore && onLoadOlder && !loadingOlder) {
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
void onLoadOlder();
}
}, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]);
const maybeLoadEarlierFromScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
if (!userReadingHistoryRef.current) return;
if (el.scrollTop > NEAR_TOP_PX) return;
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
loadEarlierMessages();
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
const jumpToUserPrompt = useCallback((promptId: string) => {
const scrollEl = scrollRef.current;
if (scrollEl && findPromptElement(scrollEl, promptId)) {
jumpToPrompt(scrollEl, promptId);
return;
}
const index = messages.findIndex((message) => message.id === promptId);
if (index < 0) return;
pendingPromptJumpRef.current = promptId;
userReadingHistoryRef.current = true;
activeTurnPromptRef.current = null;
setAtBottom(false);
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]);
useImperativeHandle(ref, () => ({ jumpToUserPrompt }), [jumpToUserPrompt]);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
const height = el.getBoundingClientRect().height || el.offsetHeight;
setComposerDockHeight((current) =>
Math.abs(current - height) < 1 ? current : height,
);
}, []);
useLayoutEffect(() => {
const updateKeyboardInset = () => {
const scrollEl = scrollRef.current;
const next = readSoftKeyboardInsetBottom(scrollEl);
const active = document.activeElement;
const composerFocused =
hasMessages && isKeyboardEditableElement(active) && Boolean(scrollEl?.contains(active));
setKeyboardInsetBottom((current) =>
Math.abs(current - next) < 1 ? current : next,
);
if (composerFocused) {
userReadingHistoryRef.current = false;
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
}
};
updateKeyboardInset();
const viewport = window.visualViewport;
viewport?.addEventListener("resize", updateKeyboardInset);
viewport?.addEventListener("scroll", updateKeyboardInset);
window.addEventListener("resize", updateKeyboardInset);
document.addEventListener("focusin", updateKeyboardInset);
document.addEventListener("focusout", updateKeyboardInset);
return () => {
viewport?.removeEventListener("resize", updateKeyboardInset);
viewport?.removeEventListener("scroll", updateKeyboardInset);
window.removeEventListener("resize", updateKeyboardInset);
document.removeEventListener("focusin", updateKeyboardInset);
document.removeEventListener("focusout", updateKeyboardInset);
};
}, [hasMessages, scrollToBottom]);
useLayoutEffect(() => {
if (keyboardInsetBottom > 0) {
userReadingHistoryRef.current = false;
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
return;
}
if (userReadingHistoryRef.current) return;
scrollToBottom(false, 4);
}, [keyboardInsetBottom, scrollToBottom]);
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl) return;
const onComposerFocus = () => {
const active = document.activeElement;
if (!hasMessages || !isKeyboardEditableElement(active) || !scrollEl.contains(active)) return;
userReadingHistoryRef.current = false;
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
};
document.addEventListener("focusin", onComposerFocus);
return () => document.removeEventListener("focusin", onComposerFocus);
}, [hasMessages, scrollToBottom]);
useEffect(() => {
if (scrollToBottomSignal <= 0) return;
userReadingHistoryRef.current = false;
scrollToBottom(false, 8);
}, [scrollToBottomSignal, scrollToBottom]);
useLayoutEffect(() => {
if (scrollToLatestUserPromptSignal <= handledLatestPromptSignalRef.current) return;
const latest = messages[messages.length - 1];
if (!latest || latest.role !== "user") return;
handledLatestPromptSignalRef.current = scrollToLatestUserPromptSignal;
cancelScheduledBottomScroll();
activeTurnPromptRef.current = latest.id;
if (!scrollToPromptTopNow(latest.id)) activeTurnPromptRef.current = null;
}, [
cancelScheduledBottomScroll,
messages,
scrollToLatestUserPromptSignal,
scrollToPromptTopNow,
]);
useLayoutEffect(() => {
if (lastConversationKeyRef.current === conversationKey) return;
lastConversationKeyRef.current = conversationKey;
pendingConversationScrollRef.current = true;
userReadingHistoryRef.current = false;
activeTurnPromptRef.current = null;
setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
useLayoutEffect(() => {
const promptId = activeTurnPromptRef.current;
if (!promptId || userReadingHistoryRef.current) return;
const promptIndex = messages.findIndex((message) => message.id === promptId);
if (promptIndex < 0) {
activeTurnPromptRef.current = null;
return;
}
const hasAgentOutput = messages
.slice(promptIndex + 1)
.some((message) => message.role !== "user");
if (!hasAgentOutput) return;
scrollToBottom(false, isStreaming ? 3 : 1);
}, [isStreaming, messages, scrollToBottom]);
useLayoutEffect(() => {
if (!hasMessages) return;
if (userReadingHistoryRef.current) return;
scrollToBottom(false, isStreaming ? 4 : 2);
}, [hasMessages, isStreaming, scrollToBottom, visibleMessages]);
useLayoutEffect(() => {
const pending = restoreScrollAfterPrependRef.current;
if (!pending) return;
const el = scrollRef.current;
restoreScrollAfterPrependRef.current = null;
if (!el) return;
const delta = el.scrollHeight - pending.height;
const nextTop = pending.top + delta;
try {
el.scrollTop = nextTop;
} catch {
try {
el.scrollTo?.({ top: nextTop, behavior: "auto" });
} catch {
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
}
}
}, [visibleMessages.length, messages.length]);
useLayoutEffect(() => {
const promptId = pendingPromptJumpRef.current;
const scrollEl = scrollRef.current;
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
pendingPromptJumpRef.current = null;
const frame = window.requestAnimationFrame(() => jumpToPrompt(scrollEl, promptId));
return () => window.cancelAnimationFrame(frame);
}, [visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationKey) {
pendingConversationScrollRef.current = false;
scrollToBottom(false, 4);
return;
}
scrollToBottom(false, 8);
if (!hasMessages) return;
pendingConversationScrollRef.current = false;
}, [conversationKey, hasMessages, messages, scrollToBottom]);
useLayoutEffect(() => {
measureComposerDock();
}, [composer, hasMessages, measureComposerDock]);
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
useEffect(() => {
const target = composerDockRef.current;
if (!target || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => measureComposerDock());
observer.observe(target);
return () => observer.disconnect();
}, [hasMessages, measureComposerDock]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = (allowHistoryLoad = true) => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX;
const programmaticPromptTop = programmaticPromptScrollTopRef.current;
const programmatic =
programmaticPromptTop !== null && Math.abs(el.scrollTop - programmaticPromptTop) < 2;
setAtBottom(near);
if (programmatic) {
programmaticPromptScrollTopRef.current = null;
if (near) userReadingHistoryRef.current = false;
return;
}
programmaticPromptScrollTopRef.current = null;
userReadingHistoryRef.current = !near;
if (!near) activeTurnPromptRef.current = null;
if (allowHistoryLoad && !near) maybeLoadEarlierFromScroll();
};
onScroll(false);
const handleScroll = () => onScroll(true);
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [maybeLoadEarlierFromScroll]);
return (
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
className={cn(
"thread-viewport-scrollbar absolute inset-0 overflow-y-auto scroll-auto scrollbar-thin",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
"[&::-webkit-scrollbar-track]:bg-transparent",
)}
style={scrollViewportStyle}
>
{hasMessages ? (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div
data-testid="thread-message-region"
className="flex min-h-0 flex-1 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
>
<div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages
messages={visibleMessages}
isStreaming={isStreaming}
hiddenUserMessageCount={hiddenUserMessageCount}
cliApps={cliApps}
mcpPresets={mcpPresets}
forkBoundaryMessageCount={visibleForkBoundaryMessageCount}
onOpenFilePreview={onOpenFilePreview}
onForkFromMessage={onForkFromMessage}
/>
</div>
</div>
<div
ref={composerDockRef}
data-testid="thread-composer-dock"
className="sticky bottom-0 z-10 bg-background"
>
<div className="px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
{composer}
</div>
</div>
</div>
) : (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-3 sm:px-4">
<div className="flex w-full flex-1 items-center justify-center py-6 sm:py-12">
<div className="relative flex w-full max-w-[58rem] flex-col items-center gap-5 sm:block">
<div className="flex justify-center sm:absolute sm:inset-x-0 sm:bottom-[calc(100%+1.5rem)]">
{emptyState}
</div>
<div className="w-full">{composer}</div>
</div>
</div>
</div>
)}
<div ref={bottomRef} aria-hidden className="h-px" />
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
{hasMessages ? (
<PromptRail
messages={visibleMessages}
scrollRef={scrollRef}
bottomOffset={scrollButtonBottom}
/>
) : null}
{showScrollToBottomButton && !atBottom && (
<div
className="absolute left-1/2 z-20 -translate-x-1/2"
style={{ bottom: scrollButtonBottom }}
>
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
"h-8 w-8 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
});

View File

@ -0,0 +1,390 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { AlertTriangle, BookOpen, Check, ChevronDown, Folder, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import type {
KnowledgeBaseCard,
WorkspaceAccessMode,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { getHostApi } from "@/lib/runtime";
import { cn } from "@/lib/utils";
import {
isAbsoluteWorkspacePath,
projectNameFromPath,
scopeWithAccessMode,
selectedProjectScope,
shortWorkspacePath,
} from "@/lib/workspace";
export function WorkspaceProjectPicker({
isHero,
disabled,
scope,
defaultScope,
controls,
error,
onChange,
}: {
isHero: boolean;
disabled?: boolean;
scope: WorkspaceScopePayload | null;
defaultScope: WorkspaceScopePayload | null;
controls: WorkspacesPayload["controls"] | null;
error?: string | null;
onChange?: (scope: WorkspaceScopePayload) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [pathDraft, setPathDraft] = useState("");
const [pathError, setPathError] = useState<string | null>(null);
const [pickingFolder, setPickingFolder] = useState(false);
const currentProjectScope = selectedProjectScope(scope, defaultScope);
const projectLabel = currentProjectScope
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
: t("thread.composer.workspace.projectPlaceholder");
const visible = isHero
&& !!defaultScope
&& !!onChange
&& controls?.can_change_project !== false;
const hostApi = getHostApi();
const nativeProjectPicker = !!hostApi;
useEffect(() => {
if (!open) return;
setPathDraft(currentProjectScope?.project_path ?? "");
setPathError(null);
}, [currentProjectScope?.project_path, open]);
useEffect(() => {
if (error && visible) setOpen(true);
}, [error, visible]);
const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => {
const base = scope ?? defaultScope;
const trimmed = projectPath.trim();
if (!base || !onChange) return;
if (!trimmed || !isAbsoluteWorkspacePath(trimmed)) {
setPathError(t("workspace.dialog.absolutePathRequired"));
return;
}
onChange({
...base,
project_path: trimmed,
project_name: projectName || projectNameFromPath(trimmed),
restrict_to_workspace: base.access_mode === "restricted",
});
setPathError(null);
setOpen(false);
},
[defaultScope, onChange, scope, t],
);
const pickNativeFolder = useCallback(async () => {
if (!hostApi || disabled) return;
setPickingFolder(true);
try {
const picked = await hostApi.pickFolder();
if (picked) applyProjectPath(picked);
} catch (err) {
setPathError((err as Error).message);
} finally {
setPickingFolder(false);
}
}, [applyProjectPath, disabled, hostApi]);
if (!visible || !defaultScope || !onChange) return null;
if (nativeProjectPicker) {
return (
<div className="flex min-w-0 items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-3 py-1.5 dark:bg-white/[0.055] sm:px-4">
<button
type="button"
disabled={disabled || pickingFolder}
aria-label={t("thread.composer.workspace.projectAria")}
title={currentProjectScope?.project_path}
onClick={() => void pickNativeFolder()}
className={cn(
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
</button>
{pathError || error ? (
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</span>
) : null}
</div>
);
}
return (
<div className="flex min-w-0 items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-3 py-1.5 dark:bg-white/[0.055] sm:px-4">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")}
className={cn(
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
sideOffset={8}
className="w-[min(25rem,calc(100vw-2rem))] rounded-[22px]"
>
<DropdownMenuItem
onSelect={() => applyProjectPath(defaultScope.project_path, defaultScope.project_name)}
className="flex min-h-[48px] cursor-default gap-3 rounded-[16px] px-3 py-2.5 focus:bg-muted/55"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/80">
<Folder className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold text-foreground">
{t("workspace.dialog.defaultProject")}
</span>
<span className="block truncate text-[11.5px] text-muted-foreground">
{shortWorkspacePath(defaultScope.project_path)}
</span>
</span>
{!currentProjectScope ? <Check className="h-4 w-4 text-foreground/80" /> : null}
</DropdownMenuItem>
<div className="my-1 h-px bg-border/45" />
<div
className="space-y-1.5 px-1.5 py-1.5"
onKeyDown={(event) => {
if (event.key !== "Escape") event.stopPropagation();
}}
>
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
applyProjectPath(pathDraft);
}}
>
<Input
value={pathDraft}
disabled={disabled}
onChange={(event) => {
setPathDraft(event.target.value);
setPathError(null);
}}
placeholder={t("workspace.dialog.manualPlaceholder")}
aria-label={t("workspace.dialog.manual")}
className={cn(
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
)}
/>
<Button
type="submit"
disabled={disabled || !pathDraft.trim()}
className="h-9 shrink-0 rounded-full px-3 text-[12px]"
>
{t("workspace.dialog.usePath")}
</Button>
</form>
{pathError || error ? (
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</p>
) : null}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function WorkspaceAccessMenu({
scope,
disabled,
canUseFullAccess,
isHero,
onChange,
}: {
scope: WorkspaceScopePayload;
disabled?: boolean;
canUseFullAccess: boolean;
isHero: boolean;
onChange?: (scope: WorkspaceScopePayload) => void;
}) {
const { t } = useTranslation();
const mode = scope.access_mode;
const isFull = mode === "full";
const setMode = (value: WorkspaceAccessMode) => {
if (value === "full" && !canUseFullAccess) return;
if (value === mode) return;
onChange?.(scopeWithAccessMode(scope, value));
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled || !onChange}>
<Button
type="button"
variant="ghost"
aria-label={t("thread.composer.workspace.accessAria")}
className={cn(
"max-w-[min(12.5rem,42vw)] rounded-[10px] border border-transparent font-semibold shadow-none",
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
isFull
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
: "bg-transparent text-muted-foreground hover:bg-foreground/[0.045] hover:text-foreground dark:hover:bg-white/[0.06]",
)}
>
{isFull ? (
<AlertTriangle className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
) : (
<Hand className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
)}
<span className="truncate">
{t(isFull ? "thread.composer.workspace.full" : "thread.composer.workspace.default")}
</span>
<ChevronDown className={cn("ml-1.5 shrink-0", isHero ? "h-3 w-3" : "h-3 w-3")} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<AccessMenuItem
icon={<Hand className="h-4 w-4" />}
label={t("thread.composer.workspace.default")}
selected={mode === "restricted"}
onSelect={() => setMode("restricted")}
/>
<AccessMenuItem
icon={<AlertTriangle className="h-4 w-4" />}
label={t("thread.composer.workspace.full")}
selected={mode === "full"}
disabled={!canUseFullAccess}
warning
onSelect={() => setMode("full")}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function KnowledgeBasePicker({
knowledgeBases,
selectedKnowledgeBaseId,
disabled,
onChange,
}: {
knowledgeBases: KnowledgeBaseCard[];
selectedKnowledgeBaseId?: string | null;
disabled?: boolean;
onChange?: (knowledgeBaseId: string | null) => void;
}) {
const { t } = useTranslation();
const selected = knowledgeBases.find((item) => item.knowledge_base_id === selectedKnowledgeBaseId) ?? null;
const label = selected?.title || selected?.knowledge_base_id || t("thread.composer.knowledgeBase.all", { defaultValue: "Knowledge Base" });
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled || !onChange}>
<Button
type="button"
variant="ghost"
aria-label={t("thread.composer.knowledgeBase.aria", { defaultValue: "Select knowledge base" })}
className="h-8 max-w-[min(14rem,42vw)] rounded-[10px] border border-transparent px-2.5 text-[12px] font-semibold text-muted-foreground shadow-none hover:bg-foreground/[0.045] hover:text-foreground dark:hover:bg-white/[0.06]"
>
<BookOpen className="mr-1.5 h-3.5 w-3.5 shrink-0" />
<span className="truncate">{label}</span>
<ChevronDown className="ml-1.5 h-3 w-3 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
<DropdownMenuItem
onSelect={() => onChange?.(null)}
className="flex min-h-[42px] items-center gap-3 rounded-xl px-3 text-[13px] font-medium"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">
{t("thread.composer.knowledgeBase.all", { defaultValue: "No knowledge base" })}
</span>
{!selectedKnowledgeBaseId ? <Check className="h-4 w-4 shrink-0" /> : null}
</DropdownMenuItem>
{knowledgeBases.map((item) => (
<DropdownMenuItem
key={item.knowledge_base_id}
onSelect={() => onChange?.(item.knowledge_base_id)}
className="flex min-h-[42px] items-center gap-3 rounded-xl px-3 text-[13px] font-medium"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate">{item.title || item.knowledge_base_id}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{item.meeting_count} meetings
</span>
</span>
{selectedKnowledgeBaseId === item.knowledge_base_id ? (
<Check className="h-4 w-4 shrink-0" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AccessMenuItem({
icon,
label,
selected,
disabled,
warning,
onSelect,
}: {
icon: ReactNode;
label: string;
selected: boolean;
disabled?: boolean;
warning?: boolean;
onSelect: () => void;
}) {
return (
<DropdownMenuItem
disabled={disabled}
onSelect={onSelect}
className={cn(
"flex h-10 items-center gap-3 rounded-xl px-3 text-[13.5px] font-semibold",
warning && "text-orange-600 focus:text-orange-600 dark:text-orange-300 dark:focus:text-orange-300",
)}
>
<span className="grid h-5 w-5 shrink-0 place-items-center text-current" aria-hidden>
{icon}
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
}

View File

@ -0,0 +1,35 @@
import { AttachmentTile } from "@/components/AttachmentTile";
import { cn } from "@/lib/utils";
import type { ActivityEvidence } from "@/lib/activity-timeline";
interface ActivityEvidencePreviewProps {
evidence: ActivityEvidence[];
className?: string;
}
export function ActivityEvidencePreview({ evidence, className }: ActivityEvidencePreviewProps) {
if (evidence.length === 0) return null;
return (
<div
data-testid="activity-evidence-preview"
className={cn(
"flex max-w-full flex-wrap items-start gap-2 pt-0.5",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200",
className,
)}
>
{evidence.slice(0, 4).map((item) => (
<AttachmentTile
key={item.id}
attachment={item.attachment}
variant="compact"
className={cn(
item.attachment.kind === "image" || item.attachment.kind === "video"
? "max-w-[min(100%,20rem)]"
: "max-w-[14rem]",
)}
/>
))}
</div>
);
}

View File

@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface ActivityGroupProps {
title: string;
icon?: LucideIcon;
children: ReactNode;
className?: string;
}
export function ActivityGroup({ title, icon: Icon, children, className }: ActivityGroupProps) {
return (
<section
className={cn(
"min-w-0 py-1 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-200",
className,
)}
>
<div className="mb-1 flex min-w-0 items-center gap-1.5 pl-0.5 text-[12px] font-medium text-muted-foreground/70">
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
<span className="min-w-0 truncate">{title}</span>
</div>
<div className="min-w-0">{children}</div>
</section>
);
}

View File

@ -0,0 +1,95 @@
import type { CSSProperties, ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { StreamingLabelSheen } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
export type ActivityStepTone = "neutral" | "active" | "success" | "error";
export interface ActivityStepProps {
as?: "div" | "li";
icon?: LucideIcon;
marker?: ReactNode;
label: ReactNode;
detail?: ReactNode;
aside?: ReactNode;
children?: ReactNode;
active?: boolean;
tone?: ActivityStepTone;
title?: string;
className?: string;
contentClassName?: string;
markerClassName?: string;
style?: CSSProperties;
}
export function ActivityStep({
as: Component = "div",
icon: Icon,
marker,
label,
detail,
aside,
children,
active = false,
tone = active ? "active" : "neutral",
title,
className,
contentClassName,
markerClassName,
style,
}: ActivityStepProps) {
return (
<Component
className={cn(
"group/activity-step relative grid min-w-0 grid-cols-[1.125rem_minmax(0,1fr)] gap-2 py-0.5 text-[13px] leading-5",
className,
)}
title={title}
style={style}
>
<span
className={cn(
"relative flex h-5 w-[1.125rem] shrink-0 items-start justify-center pt-[3px]",
"after:absolute after:left-1/2 after:top-[1.25rem] after:h-[calc(100%+0.375rem)] after:w-px after:-translate-x-1/2 after:bg-muted-foreground/14 group-last/activity-step:after:hidden",
)}
aria-hidden
>
{marker ?? (
<span
className={cn(
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
tone === "active" && "border-muted-foreground/28 text-muted-foreground/72",
tone === "success" && "border-emerald-500/28 text-emerald-500/78",
tone === "error" && "border-destructive/30 text-destructive/78",
tone === "neutral" && "border-muted-foreground/18 text-muted-foreground/50",
markerClassName,
)}
>
{Icon ? <Icon className="h-2.5 w-2.5" strokeWidth={2.15} /> : null}
</span>
)}
</span>
<div className={cn("min-w-0", contentClassName)}>
<div className="flex min-w-0 items-baseline gap-1.5">
<StreamingLabelSheen
active={active}
className={cn(
"min-w-0 shrink-0 font-medium",
tone === "error" ? "text-destructive/78" : "text-muted-foreground/85",
)}
>
{label}
</StreamingLabelSheen>
{detail ? (
<span className="min-w-0 break-words text-foreground/82">
{detail}
</span>
) : null}
{aside ? <span className="ml-auto shrink-0">{aside}</span> : null}
</div>
{children ? <div className="mt-1 min-w-0">{children}</div> : null}
</div>
</Component>
);
}

View File

@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export function DiffPair({ added, deleted }: { added: number; deleted: number }) {
return (
<span
className="inline-flex shrink-0 items-baseline gap-1.5 leading-[inherit] tabular-nums"
data-testid="activity-diff-pair"
>
<DiffValue
sign="+"
value={added}
className="text-emerald-600/75 dark:text-emerald-300/75"
/>
<DiffValue
sign="-"
value={deleted}
className="text-rose-600/70 dark:text-rose-300/75"
/>
</span>
);
}
function DiffValue({ sign, value, className }: { sign: string; value: number; className: string }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return (
<span
className={cn("inline-flex items-baseline leading-[inherit]", className)}
aria-label={`${sign}${safeValue}`}
>
<span className="inline-flex items-baseline leading-none" aria-hidden>
{sign}
<AnimatedNumber value={safeValue} />
</span>
<span className="sr-only">{sign}{safeValue}</span>
</span>
);
}
function AnimatedNumber({ value }: { value: number }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
const [display, setDisplay] = useState(0);
const displayRef = useRef(0);
const setAnimatedDisplay = useCallback((next: number) => {
displayRef.current = next;
setDisplay(next);
}, []);
useEffect(() => {
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setAnimatedDisplay(safeValue);
return;
}
const start = displayRef.current;
const delta = safeValue - start;
if (delta === 0) {
setAnimatedDisplay(safeValue);
return;
}
const duration = 260;
const startedAt = performance.now();
let frame = 0;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedDisplay(Math.round(start + delta * eased));
if (progress < 1) {
frame = window.requestAnimationFrame(tick);
return;
}
displayRef.current = safeValue;
};
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]);
return <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex items-baseline leading-none" aria-hidden>
{digits.map((digit, index) => (
<RollingDigit
key={`${digits.length}-${index}`}
digit={Number(digit)}
/>
))}
</span>
);
}
function RollingDigit({ digit }: { digit: number }) {
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
<span className="invisible block h-[1em] leading-none">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
style={{ transform: `translateY(-${safeDigit}em)` }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n}
</span>
))}
</span>
</span>
);
}

View File

@ -0,0 +1,144 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import type { UIFileEdit } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ActivityStep } from "./ActivityStep";
import { DiffPair } from "./DiffPair";
export interface FileEditSummary {
key: string;
path: string;
absolute_path?: string;
added: number;
deleted: number;
approximate: boolean;
binary: boolean;
status: UIFileEdit["status"];
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
}
export function FileEditGroup({
edits,
onOpenFilePreview,
}: {
edits: FileEditSummary[];
onOpenFilePreview?: (path: string) => void;
}) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow
key={edit.key}
edit={edit}
onOpenFilePreview={onOpenFilePreview}
/>
))}
</ul>
);
}
function FileEditRow({
edit,
onOpenFilePreview,
}: {
edit: FileEditSummary;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
const failureDetail = failed
? formatFileEditError(edit.error)
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
: "";
const statusIcon = failed ? (
<AlertCircle className="h-3 w-3" aria-hidden />
) : editing ? (
<CircleDashed className="h-3 w-3 animate-spin" aria-hidden />
) : (
<CheckCircle2 className="h-3 w-3" aria-hidden />
);
return (
<ActivityStep
as="li"
marker={(
<span
className={cn(
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
failed && "border-destructive/30 text-destructive/78",
editing && "border-muted-foreground/24 text-muted-foreground/65",
!failed && !editing && "border-emerald-500/28 text-emerald-500/78",
)}
>
{statusIcon}
</span>
)}
active={editing}
tone={failed ? "error" : editing ? "active" : "success"}
className="text-xs"
contentClassName={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
title={rawFailureDetail || edit.absolute_path || edit.path}
label={edit.pending && !edit.path
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
: (
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
previewPath={edit.absolute_path || edit.path}
onOpen={onOpenFilePreview}
display="path"
active={editing}
className="min-w-0"
textClassName="text-[12px]"
testId="activity-file-reference"
/>
)}
detail={null}
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
>
{failed ? (
<span className="block max-w-[42rem] truncate text-[11px] leading-4 text-destructive/75">
{failureDetail}
</span>
) : null}
</ActivityStep>
);
}
export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
return edit.added > 0 || edit.deleted > 0;
}
function cleanFileEditError(error?: string): string {
const firstLine = (error || "").replace(/\s+/g, " ").trim();
if (!firstLine) return "";
return firstLine
.replace(/^Error applying patch:\s*/i, "")
.replace(/^Error writing file:\s*/i, "")
.replace(/^Error editing file:\s*/i, "")
.replace(/^Error:\s*/i, "");
}
function formatFileEditError(error?: string): string {
const cleaned = cleanFileEditError(error);
if (!cleaned) return "";
if (/\bpermission denied\b/i.test(cleaned) || /\boperation not permitted\b/i.test(cleaned)) {
return "No permission to change this location.";
}
return cleaned
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}

View File

@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from "react";
import { Check, CircleDashed } from "lucide-react";
import { useTranslation } from "react-i18next";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { ActivityStep } from "./ActivityStep";
export function ReasoningRow({
text,
streaming,
onOpenFilePreview,
}: {
text: string;
streaming: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
useEffect(() => {
if (text.length > 0) preloadMarkdownText();
}, [text.length]);
return (
<ActivityStep
marker={<ReasoningMarker streaming={streaming} />}
active={streaming}
tone={streaming ? "active" : "success"}
label={streaming
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
: t("message.reasoning", { defaultValue: "Thinking" })}
>
{text.trim() ? (
<MarkdownText
streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
className={cn(
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
"prose-p:my-1 prose-li:my-0.5",
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
"prose-a:text-blue-500 prose-a:underline hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
"prose-code:text-[0.92em]",
)}
>
{text}
</MarkdownText>
) : null}
</ActivityStep>
);
}
function ReasoningMarker({ streaming }: { streaming: boolean }) {
const wasStreamingRef = useRef(streaming);
const [justCompleted, setJustCompleted] = useState(false);
useEffect(() => {
if (wasStreamingRef.current && !streaming) {
setJustCompleted(true);
const timeout = window.setTimeout(() => setJustCompleted(false), 650);
wasStreamingRef.current = streaming;
return () => window.clearTimeout(timeout);
}
wasStreamingRef.current = streaming;
return undefined;
}, [streaming]);
if (streaming) {
return (
<CircleDashed
data-testid="activity-reasoning-marker"
data-state="thinking"
className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/55"
strokeWidth={1.8}
aria-hidden
/>
);
}
return (
<span
data-testid="activity-reasoning-marker"
data-state="done"
className={cn(
"grid h-3.5 w-3.5 shrink-0 place-items-center rounded-full border border-emerald-500/28 text-emerald-500/78",
"bg-emerald-500/[0.035] transition-[border-color,background-color,box-shadow,transform] duration-300 ease-out",
justCompleted
&& "animate-in fade-in-0 zoom-in-75 shadow-[0_0_0_3px_rgba(16,185,129,0.10)] motion-reduce:animate-none",
)}
aria-hidden
>
<Check
className={cn(
"h-2.5 w-2.5 stroke-[2.4]",
justCompleted && "animate-in fade-in-0 zoom-in-50 duration-300 motion-reduce:animate-none",
)}
/>
</span>
);
}

View File

@ -0,0 +1,64 @@
import type { UIMessage } from "@/lib/types";
export interface PromptAnchor {
id: string;
label: string;
preview: string;
createdAt: number;
index: number;
}
export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
let index = 0;
return messages.flatMap((message) => {
if (message.role !== "user") return [];
const anchor: PromptAnchor = {
id: message.id,
label: promptLabel(message.content, index),
preview: promptPreview(message.content, index),
createdAt: message.createdAt,
index,
};
index += 1;
return [anchor];
});
}
export function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
}
export function promptPreview(content: string, index: number): string {
const text = content.replace(/\n{3,}/g, "\n\n").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 320 ? `${text.slice(0, 317)}...` : text;
}
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
(candidate) => candidate.dataset.userPromptId === promptId,
) ?? null;
}
export function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
if (hasLayoutRect) {
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
}
return target.offsetTop;
}

View File

@ -0,0 +1,131 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/45 backdrop-blur-[3px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"grid w-full max-w-lg origin-center gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
/>
</div>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
};

View File

@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,120 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
showCloseButton?: boolean;
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<DialogPrimitive.Content
ref={ref}
className={cn(
"grid w-full max-w-lg origin-center gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
</div>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
};

View File

@ -0,0 +1,192 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuContentClassName =
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl scrollbar-thin scrollbar-track-transparent dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
const menuItemClassName =
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
menuItemClassName,
"data-[state=open]:bg-foreground/[0.055] dark:data-[state=open]:bg-white/[0.08]",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
menuContentClassName,
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
interface DropdownMenuContentProps
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
portalContainer?: HTMLElement | null;
}
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
DropdownMenuContentProps
>(({ className, sideOffset = 4, portalContainer, ...props }, ref) => (
<DropdownMenuPrimitive.Portal container={portalContainer ?? undefined}>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
menuContentClassName,
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
menuItemClassName,
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
menuItemClassName,
"pl-8 pr-2.5",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
menuItemClassName,
"pl-8 pr-2.5",
className,
)}
{...props}
>
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2.5 pb-1.5 pt-1 text-[12px] font-semibold text-muted-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,29 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

Some files were not shown because too many files have changed in this diff Show More