初始化 graph_memory 分支
commit
77e0732648
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.env
|
||||
__pycache__/
|
||||
data/
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"python-envs.defaultEnvManager": "ms-python.python:conda",
|
||||
"python-envs.defaultPackageManager": "ms-python.python:conda"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"max_turns": 20,
|
||||
"next_turn_id": 1,
|
||||
"active_turn": null,
|
||||
"turns": []
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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()
|
||||
|
|
@ -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-")
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
class AgentCancelled(RuntimeError):
|
||||
pass
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
|
@ -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.
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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" });
|
||||
}
|
||||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
@ -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)`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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
Loading…
Reference in New Issue