From b44f3eb24243b1d99e7696d6435bd2216a7efc79 Mon Sep 17 00:00:00 2001 From: zhenghuiwen <974862648@qq.com> Date: Fri, 6 Mar 2026 13:48:39 +0800 Subject: [PATCH 001/113] =?UTF-8?q?=E5=8F=AA=E6=94=B9=E4=BA=86.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bf78c046d4b6..7139fd433676 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ opencode-dev logs/ *.bun-build tsconfig.tsbuildinfo +.claude/ \ No newline at end of file From 9cfc0036228fcc2000b3de73990f0a7606c86b78 Mon Sep 17 00:00:00 2001 From: zhenghuiwen <974862648@qq.com> Date: Sat, 7 Mar 2026 13:14:06 +0800 Subject: [PATCH 002/113] brand: rename opencode to openresearch --- DEBUG.md | 194 ++++++++++++++++++++++++++++ PLAN.md | 30 +++++ packages/app/src/i18n/en.ts | 36 +++--- packages/opencode/src/cli/logo.ts | 7 +- packages/ui/src/components/logo.tsx | 42 +++--- 5 files changed, 271 insertions(+), 38 deletions(-) create mode 100644 DEBUG.md create mode 100644 PLAN.md diff --git a/DEBUG.md b/DEBUG.md new file mode 100644 index 000000000000..9879cd7944fa --- /dev/null +++ b/DEBUG.md @@ -0,0 +1,194 @@ +# OpenCode 开发指南 + +## 环境准备 + +依赖:[Bun](https://bun.sh/) 1.3+,VSCode 插件 `oven.bun-vscode` + +```bash +# WSL / 网络受限环境推荐 +npm install -g bun + +# 安装项目依赖 +bun install +``` + +--- + +## 项目结构 + +``` +opencode/ +├── packages/ +│ ├── opencode/ # 核心逻辑、API Server、TUI(SolidJS + opentui) +│ ├── app/ # Web UI(SolidJS + Vite) +│ ├── desktop/ # 桌面端(Tauri) +│ └── plugin/ # 插件包 +``` + +--- + +## 启动与预览 + +改了代码想看效果,直接运行对应命令,保存后自动热重载。 + +**TUI 模式(最常用):** + +```bash +bun dev # 在 packages/opencode 目录下运行 +bun dev . # 在当前仓库根目录运行 +bun dev # 在指定目录运行 +``` + +注意: +1. 要想在新文件夹中试用openresearch,需要开一个新窗口并先打开目标文件夹(如~/code/openresearch/test),然后在opencode项目(不是test项目)的终端运行bun dev ~/code/openresearch/test,此时会在opencode项目的终端显示opencode的对话框,在里面对话创建的代码文件会在test项目中出现。 + + +**Web UI 模式(需要两个终端):** + +```bash +# 终端 1 +bun dev serve + +# 终端 2(然后打开 http://localhost:5173) +bun run --cwd packages/app dev +``` + +**修改了 Server API 后,需重新生成 SDK:** + +```bash +./script/generate.ts +``` + +--- + +## 断点调试 + +> Bun 调试支持尚不完善,建议只用 **attach 模式**,不要用 launch 模式。 + +### 第一步:复制 VSCode 配置 + +```bash +cp .vscode/launch.example.json .vscode/launch.json +cp .vscode/settings.example.json .vscode/settings.json +``` + +### 第二步:以 inspect 模式启动 + +**调试 TUI + Server(推荐):** 用 `spawn` 让 Server 跑在主进程而非 Worker 线程,断点才能命中。 + +```bash +bun run --inspect=ws://localhost:6499/ --cwd packages/opencode --conditions=browser src/index.ts spawn +``` + +**只调试 Server:** 另开终端用 `opencode attach` 连接 TUI。 + +```bash +# 终端 1 +bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts serve --port 4096 + +# 终端 2 +opencode attach http://localhost:4096 +``` + +**调试 Web App:** 启动后直接用浏览器 F12 DevTools,无需 attach。 + +### 第三步:在 VSCode 中 Attach + +按 `F5`,选择 **"opencode (attach)"**。 + +**小技巧:** 将下面这行加入 `.bashrc` / `.zshrc`,之后 `bun dev` 自动带上 inspect 参数: + +```bash +export BUN_OPTIONS="--inspect=ws://localhost:6499/" +``` + +--- + +## 内置 debug 子命令 + +```bash +bun dev debug config # 查看当前配置 +bun dev debug file # 调试文件读取 +bun dev debug lsp # 调试 LSP +bun dev debug ripgrep # 调试搜索 +bun dev debug agent # 调试 Agent +bun dev debug snapshot # 查看快照 +bun dev debug skill # 调试 Skill +``` + +--- + +## WSL 接入自定义 API + +在 WSL 里接入自己的 API(如私有部署的 Claude),按以下步骤配置。 + +### 第一步:创建配置文件 + +直接在 WSL 终端创建,不要在 Windows 文件夹里新建(路径权限问题): + +```bash +nano ~/.opencode.json +``` + +填入以下内容(以接入 Claude 兼容接口为例): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "my-claude": { + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": "http://你的IP地址/v1", + "apiKey": "sk-你的真实KEY" + }, + "models": { + "claude-opus-4-5-20251101": {}, + "claude-haiku-4-5-20251001": {} + } + } + }, + "model": "my-claude/claude-opus-4-5-20251101" +} +``` + +> **注意:** `baseURL` 结尾必须带 `/v1`,否则接口找不到,是最常见的报错原因。 + +### 第二步:设置环境变量 + +WSL 不会自动读取配置文件路径,需要手动指定: + +```bash +echo 'export OPENCODE_CONFIG="$HOME/.opencode.json"' >> ~/.bashrc +source ~/.bashrc +``` + +### 第三步:清理旧的认证缓存 + +如果之前运行过 `opencode auth login`,会生成加密的 `auth.json` 并覆盖配置文件里的 Key,导致 429 报错。删除它: + +```bash +rm ~/.local/share/opencode/auth.json +``` + +### 验证配置 + +```bash +opencode models # 能看到 my-claude/claude-opus... 说明配置生效 +``` + +--- + +## 常见问题 + +**断点没有命中:** 确认用的是 `attach` 模式,进程带了 `--inspect` 参数;调试 Server 代码要用 `spawn` 子命令。 + +**端口被占用:** 换个端口,同步修改 `.vscode/launch.json` 中的 `url` 字段。 + +**桌面应用无法启动:** 需要 Rust 工具链,参考 [Tauri 先决条件文档](https://v2.tauri.app/start/prerequisites/)。 + +**404 错误:** 检查 `baseURL` 是否遗漏了 `/v1`。 + +**429 频率限制:** Key 填错,或被旧的 `auth.json` 干扰,参考上方"清理旧的认证缓存"。 + +**模型列表为空:** 检查环境变量 `OPENCODE_CONFIG` 路径是否正确。 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000000..aae96f8d1248 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,30 @@ +# OpenResearch 改进计划 + +## 已完成 +- [x] 品牌改名:opencode → openresearch(TUI logo、Web logo、UI 文本) + +## 待办 + +### 1. Skills 扩展 +- [ ] `brainstorm` — 头脑风暴技能,结构化发散思维 +- [ ] `write-paper` — 学术论文写作(摘要/引言/方法/结论模板) +- [ ] `ppt-creator` — 演示文稿生成(输出 Markdown/PPTX) + +### 2. 知识库(Knowledge Base) +- [ ] 支持导入本地文档(PDF、Markdown、文本) +- [ ] 向量化存储与语义检索 +- [ ] 在对话中自动引用知识库内容 + +### 3. 文献搜索 +- [ ] 集成学术 API(arXiv、Semantic Scholar 等) +- [ ] 搜索结果格式化(标题/作者/摘要/引用) +- [ ] 支持导出参考文献列表 + +### 4. 文件夹渐进式披露 +- [ ] 侧边栏文件树默认折叠深层目录 +- [ ] 按需展开,懒加载子目录内容 + +### 5. 多模型路由 +- [ ] 配置规则:按任务类型(写作/代码/推理)映射不同模型 +- [ ] 支持手动切换与自动推荐 +- [ ] 成本/速度/质量三维配置策略 diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c87e7cb9dbbc..8c57ae6007ff 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -115,7 +115,7 @@ export const dict = { "dialog.model.manage.description": "Customize which models appear in the model selector.", "dialog.model.manage.provider.toggle": "Toggle all {{provider}} models", - "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", + "dialog.model.unpaid.freeModels.title": "Free models provided by OpenResearch", "dialog.model.unpaid.addMore.title": "Add more models from popular providers", "dialog.provider.viewAll": "Show more providers", @@ -128,21 +128,21 @@ export const dict = { "provider.connect.status.waiting": "Waiting for authorization...", "provider.connect.status.failed": "Authorization failed: {{error}}", "provider.connect.apiKey.description": - "Enter your {{provider}} API key to connect your account and use {{provider}} models in OpenCode.", + "Enter your {{provider}} API key to connect your account and use {{provider}} models in OpenResearch.", "provider.connect.apiKey.label": "{{provider}} API key", "provider.connect.apiKey.placeholder": "API key", "provider.connect.apiKey.required": "API key is required", "provider.connect.opencodeZen.line1": - "OpenCode Zen gives you access to a curated set of reliable optimized models for coding agents.", + "OpenResearch Zen gives you access to a curated set of reliable optimized models for coding agents.", "provider.connect.opencodeZen.line2": "With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.", "provider.connect.opencodeZen.visit.prefix": "Visit ", - "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.link": "openresearch.ai/zen", "provider.connect.opencodeZen.visit.suffix": " to collect your API key.", "provider.connect.oauth.code.visit.prefix": "Visit ", "provider.connect.oauth.code.visit.link": "this link", "provider.connect.oauth.code.visit.suffix": - " to collect your authorization code to connect your account and use {{provider}} models in OpenCode.", + " to collect your authorization code to connect your account and use {{provider}} models in OpenResearch.", "provider.connect.oauth.code.label": "{{method}} authorization code", "provider.connect.oauth.code.placeholder": "Authorization code", "provider.connect.oauth.code.required": "Authorization code is required", @@ -150,7 +150,7 @@ export const dict = { "provider.connect.oauth.auto.visit.prefix": "Visit ", "provider.connect.oauth.auto.visit.link": "this link", "provider.connect.oauth.auto.visit.suffix": - " and enter the code below to connect your account and use {{provider}} models in OpenCode.", + " and enter the code below to connect your account and use {{provider}} models in OpenResearch.", "provider.connect.oauth.auto.confirmationCode": "Confirmation code", "provider.connect.toast.connected.title": "{{provider}} connected", "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", @@ -307,7 +307,7 @@ export const dict = { "dialog.directory.empty": "No folders found", "dialog.server.title": "Servers", - "dialog.server.description": "Switch which OpenCode server this app connects to.", + "dialog.server.description": "Switch which OpenResearch server this app connects to.", "dialog.server.search.placeholder": "Search servers", "dialog.server.empty": "No servers yet", "dialog.server.add.title": "Add server", @@ -445,7 +445,7 @@ export const dict = { "toast.project.reloadFailed.title": "Failed to reload {{project}}", "toast.update.title": "Update available", - "toast.update.description": "A new version of OpenCode ({{version}}) is now available to install.", + "toast.update.description": "A new version of OpenResearch ({{version}}) is now available to install.", "toast.update.action.installRestart": "Install and restart", "toast.update.action.notYet": "Not yet", @@ -456,7 +456,7 @@ export const dict = { "error.page.action.checking": "Checking...", "error.page.action.checkUpdates": "Check for updates", "error.page.action.updateTo": "Update to {{version}}", - "error.page.report.prefix": "Please report this error to the OpenCode team", + "error.page.report.prefix": "Please report this error to the OpenResearch team", "error.page.report.discord": "on Discord", "error.page.version": "Version: {{version}}", @@ -476,7 +476,7 @@ export const dict = { "error.chain.didYouMean": "Did you mean: {{suggestions}}", "error.chain.modelNotFound": "Model not found: {{provider}}/{{model}}", "error.chain.checkConfig": "Check your config (opencode.json) provider/model names", - "error.chain.mcpFailed": 'MCP server "{{name}}" failed. Note, OpenCode does not support MCP authentication yet.', + "error.chain.mcpFailed": 'MCP server "{{name}}" failed. Note, OpenResearch does not support MCP authentication yet.', "error.chain.providerAuthFailed": "Provider authentication failed ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Failed to initialize provider "{{provider}}". Check credentials and configuration.', @@ -611,13 +611,13 @@ export const dict = { "sidebar.workspaces.enable": "Enable workspaces", "sidebar.workspaces.disable": "Disable workspaces", "sidebar.gettingStarted.title": "Getting started", - "sidebar.gettingStarted.line1": "OpenCode includes free models so you can start immediately.", + "sidebar.gettingStarted.line1": "OpenResearch includes free models so you can start immediately.", "sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Recent sessions", "sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.clearNotifications": "Clear notifications", - "app.name.desktop": "OpenCode Desktop", + "app.name.desktop": "OpenResearch Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Server", @@ -625,7 +625,7 @@ export const dict = { "settings.tab.shortcuts": "Shortcuts", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL integration", - "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", + "settings.desktop.wsl.description": "Run the OpenResearch server inside WSL on Windows.", "settings.general.section.appearance": "Appearance", "settings.general.section.notifications": "System notifications", @@ -635,11 +635,11 @@ export const dict = { "settings.general.section.display": "Display", "settings.general.row.language.title": "Language", - "settings.general.row.language.description": "Change the display language for OpenCode", + "settings.general.row.language.description": "Change the display language for OpenResearch", "settings.general.row.appearance.title": "Appearance", - "settings.general.row.appearance.description": "Customise how OpenCode looks on your device", + "settings.general.row.appearance.description": "Customise how OpenResearch looks on your device", "settings.general.row.theme.title": "Theme", - "settings.general.row.theme.description": "Customise how OpenCode is themed.", + "settings.general.row.theme.description": "Customise how OpenResearch is themed.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", @@ -660,13 +660,13 @@ export const dict = { "settings.general.row.releaseNotes.description": "Show What's New popups after updates", "settings.updates.row.startup.title": "Check for updates on startup", - "settings.updates.row.startup.description": "Automatically check for updates when OpenCode launches", + "settings.updates.row.startup.description": "Automatically check for updates when OpenResearch launches", "settings.updates.row.check.title": "Check for updates", "settings.updates.row.check.description": "Manually check for updates and install if available", "settings.updates.action.checkNow": "Check now", "settings.updates.action.checking": "Checking...", "settings.updates.toast.latest.title": "You're up to date", - "settings.updates.toast.latest.description": "You're running the latest version of OpenCode.", + "settings.updates.toast.latest.description": "You're running the latest version of OpenResearch.", "font.option.ibmPlexMono": "IBM Plex Mono", "font.option.cascadiaCode": "Cascadia Code", "font.option.firaCode": "Fira Code", diff --git a/packages/opencode/src/cli/logo.ts b/packages/opencode/src/cli/logo.ts index 44fb93c15b34..9989c04cb3a1 100644 --- a/packages/opencode/src/cli/logo.ts +++ b/packages/opencode/src/cli/logo.ts @@ -1,6 +1,11 @@ export const logo = { left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"], - right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"], + right: [ + " ", + "█▀▀▄ █▀▀█ █▀▀▀ █▀▀█ ▄▀▀█ █▀▀▄ █▀▀▀ █▀▀█", + "█▀ █^^^ ▀▀█ █^^^ █__█ █▀ █___ █__█", + "▀ ▀▀▀▀ ▀▀▀ ▀▀▀▀ ▀~~▀ ▀ ▀▀▀▀ ▀ ▀", + ], } export const marks = "_^~" diff --git a/packages/ui/src/components/logo.tsx b/packages/ui/src/components/logo.tsx index 20c2f3fbea83..88d3ace17437 100644 --- a/packages/ui/src/components/logo.tsx +++ b/packages/ui/src/components/logo.tsx @@ -35,28 +35,32 @@ export const Logo = (props: { class?: string }) => { return ( - - - - - - - - - - - - - - - - - - + + open + + + research + ) } From e8daed4a47bd03b80d5da0fed6db5a7b6bda79c5 Mon Sep 17 00:00:00 2001 From: zhenghuiwen <974862648@qq.com> Date: Sun, 8 Mar 2026 17:14:32 +0800 Subject: [PATCH 003/113] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=BA=86=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E9=BB=98=E8=AE=A4skill=E7=9A=84=E6=8C=89=E9=92=AE?= =?UTF-8?q?=EF=BC=8C=E8=AE=A9=E5=8F=B3=E8=BE=B9=E6=A0=8F=E7=9A=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=8F=AF=E4=BB=A5=E5=A2=9E=E5=88=A0=EF=BC=8C=E8=AE=A9?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=8F=AF=E4=BB=A5=E8=A2=AB=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/skills/academic-researcher/SKILL.md | 174 ++++++++ .opencode/skills/brainstorming/SKILL.md | 96 +++++ .opencode/skills/skill-creator/SKILL.md | 372 +++++++++++++++++ .../skill-creator/SKILL.md:Zone.Identifier | Bin 0 -> 25 bytes .opencode/skills/skill-creator/license.txt | 202 ++++++++++ .../skill-creator/license.txt:Zone.Identifier | Bin 0 -> 25 bytes .../skill-creator/scripts/init_skill.py | 378 ++++++++++++++++++ .../scripts/init_skill.py:Zone.Identifier | Bin 0 -> 25 bytes .../skill-creator/scripts/package_skill.py | 139 +++++++ .../scripts/package_skill.py:Zone.Identifier | Bin 0 -> 25 bytes .../skill-creator/scripts/quick_validate.py | 159 ++++++++ .../scripts/quick_validate.py:Zone.Identifier | Bin 0 -> 25 bytes .../scripts/test_package_skill.py | 160 ++++++++ .../test_package_skill.py:Zone.Identifier | Bin 0 -> 25 bytes .../scripts/test_quick_validate.py | 72 ++++ .../test_quick_validate.py:Zone.Identifier | Bin 0 -> 25 bytes .../src/components/default-skills-panel.tsx | 233 +++++++++++ packages/app/src/components/file-tree.tsx | 218 +++++----- .../components/session/session-new-view.tsx | 23 ++ .../src/pages/session/session-side-panel.tsx | 76 +++- packages/opencode/src/config/config.ts | 103 ++++- packages/opencode/src/file/index.ts | 21 + packages/opencode/src/server/routes/config.ts | 83 ++++ packages/opencode/src/server/routes/file.ts | 43 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 172 ++++++++ 25 files changed, 2631 insertions(+), 93 deletions(-) create mode 100644 .opencode/skills/academic-researcher/SKILL.md create mode 100644 .opencode/skills/brainstorming/SKILL.md create mode 100644 .opencode/skills/skill-creator/SKILL.md create mode 100644 .opencode/skills/skill-creator/SKILL.md:Zone.Identifier create mode 100644 .opencode/skills/skill-creator/license.txt create mode 100644 .opencode/skills/skill-creator/license.txt:Zone.Identifier create mode 100644 .opencode/skills/skill-creator/scripts/init_skill.py create mode 100644 .opencode/skills/skill-creator/scripts/init_skill.py:Zone.Identifier create mode 100644 .opencode/skills/skill-creator/scripts/package_skill.py create mode 100644 .opencode/skills/skill-creator/scripts/package_skill.py:Zone.Identifier create mode 100644 .opencode/skills/skill-creator/scripts/quick_validate.py create mode 100644 .opencode/skills/skill-creator/scripts/quick_validate.py:Zone.Identifier create mode 100644 .opencode/skills/skill-creator/scripts/test_package_skill.py create mode 100644 .opencode/skills/skill-creator/scripts/test_package_skill.py:Zone.Identifier create mode 100644 .opencode/skills/skill-creator/scripts/test_quick_validate.py create mode 100644 .opencode/skills/skill-creator/scripts/test_quick_validate.py:Zone.Identifier create mode 100644 packages/app/src/components/default-skills-panel.tsx diff --git a/.opencode/skills/academic-researcher/SKILL.md b/.opencode/skills/academic-researcher/SKILL.md new file mode 100644 index 000000000000..fa17f7f7b095 --- /dev/null +++ b/.opencode/skills/academic-researcher/SKILL.md @@ -0,0 +1,174 @@ +--- +name: academic-researcher +description: | + Academic research assistant for literature reviews, paper analysis, and scholarly writing. + Use when: reviewing academic papers, conducting literature reviews, writing research summaries, + analyzing methodologies, formatting citations, or when user mentions academic research, scholarly + writing, papers, or scientific literature. + Special focus: First-principles derivations, figure analysis, and equation documentation. +license: MIT +metadata: + author: peking-university + version: "4.0.0" +--- + +# Academic Researcher + +You are an expert academic research assistant. Your goal is to produce a **complete, polished Markdown document** that reads like a real paper — not a filled-in template. Write in flowing, precise academic prose. + +Work through the phases below **sequentially**. At the end of each phase, update the output document file. Every phase writes to the **same file**, progressively building a complete paper. + +--- + +## Before Starting: Create the Output File + +Determine the output filename first: + +| Type | Pattern | Example | +|------|---------|---------| +| Single paper review | `review__.md` | `review_Han2020_BootstrapQM.md` | +| Literature review | `litreview_.md` | `litreview_BootstrapMethods.md` | +| Paper summary | `summary__.md` | `summary_Han2020_Bootstrap.md` | + +Save in the current working directory unless the user specifies otherwise. Create the file immediately with a title and placeholder section headers so the document exists from the start. + +--- + +## Phase 1: Resource Discovery + +Gather everything before writing. + +**Tasks:** +1. Read every paper, PDF, or document the user provided or referenced +2. Use Glob to find all local images (`**/*.png`, `**/*.jpg`, `**/*.jpeg`, `**/*.svg`, `**/*.gif`; check `figures/`, `figs/`, `images/`, `plots/`, `results/`) +3. For each image, use Read to inspect it visually — note what it shows and which section it likely belongs to +4. Identify: core topic, key authors, time range, central research questions + +**End of Phase 1 — Update document:** +Write the following sections to the file: +- **Title, author, date, keywords** +- **Abstract** (2–3 paragraphs summarizing the whole document; revise later if needed) +- **Introduction**: background, motivation, research questions, scope, and organization of the paper + +--- + +## Phase 2: Paper-by-Paper Analysis + +Analyze each source in depth. + +**For each paper, work through:** +- **Research question**: What problem does it address? Why does it matter? +- **Methodology**: What approach is used? What are its assumptions and limitations? +- **Key results**: What did they find? How strong is the evidence? +- **Contribution**: How does this advance the field relative to prior work? +- **Connections**: How does this paper relate to others in the set? + +**End of Phase 2 — Update document:** +Write or update the **Literature Synthesis** section. Organize thematically — do not just summarize papers one by one. Compare and contrast methods, results, and interpretations across papers. Write in connected prose, citing sources as (Author, Year). + +--- + +## Phase 3: First-Principles Equation Derivation + +For every important equation in the literature, derive it from scratch. + +**For each equation:** +1. State what the equation represents and where it appears +2. Define every symbol with units or type +3. State the starting axioms, postulates, or definitions explicitly +4. Derive step by step — **do not skip intermediate steps**; justify each transition +5. State all assumptions made during the derivation +6. Give the physical or mathematical interpretation of the final result +7. Note special cases, limits, or connections to other equations + +**Derivation format:** +``` +Starting from [fundamental principle], we define [variables with units]. + +Step 1: [Short description] + [equation] + Justification: [why this step follows] + +Step 2: [Short description] + [equation] + Justification: [why this step follows] + +... + +Result: + [final equation numbered as Eq. (N)] + +This expresses [physical/mathematical interpretation]. +When [limiting condition], this reduces to [simpler form]. +``` + +**End of Phase 3 — Update document:** +Write or update the **Theoretical Framework** section. Present derivations as connected narrative — introduce each equation in prose before displaying it, and explain its significance after. Number all equations sequentially as **Eq. (1)**, **Eq. (2)**, etc. + +--- + +## Phase 4: Figure Analysis + +Analyze every relevant figure, including local images discovered in Phase 1. + +**For each figure:** +1. Describe precisely what is shown (plot type, axes with labels and units, curves, regions, markers, color coding) +2. Identify key visual features: trends, boundaries, crossings, convergence, outliers +3. Connect to equations: which equation predicts or explains this figure? Reference by Eq. (N) +4. Explain what physical or mathematical insight the figure provides +5. Note what changes across panels, parameter regimes, or compared datasets + +**End of Phase 4 — Update document:** +Write or update the **Results and Figures** section. Embed each figure inline at the point where it is discussed: + +```markdown +![Figure N: One sentence describing what is shown.](relative/path/to/image.png) + +*Figure N.* [Caption: what the figure shows, key features, and its significance in context.] +``` + +Use **relative paths** from the output file to the image. Follow each figure immediately with the analysis written as prose, referencing equations by number. + +--- + +## Phase 5: Synthesis and Finalization + +Draw together everything from the previous phases. + +**Tasks:** +1. **Research gaps**: What questions remain unanswered? What methods are missing or inadequate? What would the next experiment or derivation need to address? +2. **Future directions**: What are the most promising open problems? +3. **Conclusion**: Summarize the key insights — what the reader should take away +4. **References**: Compile all cited works in APA 7th edition (default) or the format the user specifies +5. **Revise Abstract**: Update the abstract written in Phase 1 to accurately reflect the completed document + +**End of Phase 5 — Update document:** +Write or update **Research Gaps and Future Directions**, **Conclusion**, and **References**. Then revise the Abstract. Save the final version of the file and tell the user the file path. + +--- + +## Writing Standards (apply throughout) + +- Write complete, polished sentences — never leave placeholders or fill-in-the-blank text in the output +- Use precise, formal academic prose; avoid colloquialisms and contractions +- Define all mathematical symbols before their first use +- Acknowledge counterarguments and study limitations honestly +- Maintain consistent citation style throughout + +--- + +## Citation Formats + +Default: **APA 7th edition** + +**APA journal article:** +> Author, A. A., & Author, B. B. (Year). Title of article. *Title of Journal*, *volume*(issue), pages. https://doi.org/xxx + +**APA book:** +> Author, A. A. (Year). *Title of book* (Edition). Publisher. + +**MLA journal article:** +> Author Last, First. "Title of Article." *Title of Journal*, vol. #, no. #, Year, pp. pages. + +**Chicago footnote:** +> First Last, "Title of Article," *Title of Journal* vol, no. # (Year): pages. diff --git a/.opencode/skills/brainstorming/SKILL.md b/.opencode/skills/brainstorming/SKILL.md new file mode 100644 index 000000000000..460f73a288bc --- /dev/null +++ b/.opencode/skills/brainstorming/SKILL.md @@ -0,0 +1,96 @@ +--- +name: brainstorming +description: "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation." +--- + +# Brainstorming Ideas Into Designs + +## Overview + +Help turn ideas into fully formed designs and specs through natural collaborative dialogue. + +Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design and get user approval. + + +Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity. + + +## Anti-Pattern: "This Is Too Simple To Need A Design" + +Every project goes through this process. A todo list, a single-function utility, a config change — all of them. "Simple" projects are where unexamined assumptions cause the most wasted work. The design can be short (a few sentences for truly simple projects), but you MUST present it and get approval. + +## Checklist + +You MUST create a task for each of these items and complete them in order: + +1. **Explore project context** — check files, docs, recent commits +2. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria +3. **Propose 2-3 approaches** — with trade-offs and your recommendation +4. **Present design** — in sections scaled to their complexity, get user approval after each section +5. **Write design doc** — save to `docs/plans/YYYY-MM-DD--design.md` and commit +6. **Transition to implementation** — invoke writing-plans skill to create implementation plan + +## Process Flow + +```dot +digraph brainstorming { + "Explore project context" [shape=box]; + "Ask clarifying questions" [shape=box]; + "Propose 2-3 approaches" [shape=box]; + "Present design sections" [shape=box]; + "User approves design?" [shape=diamond]; + "Write design doc" [shape=box]; + "Invoke writing-plans skill" [shape=doublecircle]; + + "Explore project context" -> "Ask clarifying questions"; + "Ask clarifying questions" -> "Propose 2-3 approaches"; + "Propose 2-3 approaches" -> "Present design sections"; + "Present design sections" -> "User approves design?"; + "User approves design?" -> "Present design sections" [label="no, revise"]; + "User approves design?" -> "Write design doc" [label="yes"]; + "Write design doc" -> "Invoke writing-plans skill"; +} +``` + +**The terminal state is invoking writing-plans.** Do NOT invoke frontend-design, mcp-builder, or any other implementation skill. The ONLY skill you invoke after brainstorming is writing-plans. + +## The Process + +**Understanding the idea:** +- Check out the current project state first (files, docs, recent commits) +- Ask questions one at a time to refine the idea +- Prefer multiple choice questions when possible, but open-ended is fine too +- Only one question per message - if a topic needs more exploration, break it into multiple questions +- Focus on understanding: purpose, constraints, success criteria + +**Exploring approaches:** +- Propose 2-3 different approaches with trade-offs +- Present options conversationally with your recommendation and reasoning +- Lead with your recommended option and explain why + +**Presenting the design:** +- Once you believe you understand what you're building, present the design +- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced +- Ask after each section whether it looks right so far +- Cover: architecture, components, data flow, error handling, testing +- Be ready to go back and clarify if something doesn't make sense + +## After the Design + +**Documentation:** +- Write the validated design to `docs/plans/YYYY-MM-DD--design.md` +- Use elements-of-style:writing-clearly-and-concisely skill if available +- Commit the design document to git + +**Implementation:** +- Invoke the writing-plans skill to create a detailed implementation plan +- Do NOT invoke any other skill. writing-plans is the next step. + +## Key Principles + +- **One question at a time** - Don't overwhelm with multiple questions +- **Multiple choice preferred** - Easier to answer than open-ended when possible +- **YAGNI ruthlessly** - Remove unnecessary features from all designs +- **Explore alternatives** - Always propose 2-3 approaches before settling +- **Incremental validation** - Present design, get approval before moving on +- **Be flexible** - Go back and clarify when something doesn't make sense diff --git a/.opencode/skills/skill-creator/SKILL.md b/.opencode/skills/skill-creator/SKILL.md new file mode 100644 index 000000000000..369440fdba8d --- /dev/null +++ b/.opencode/skills/skill-creator/SKILL.md @@ -0,0 +1,372 @@ +--- +name: skill-creator +description: Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets. +--- + +# Skill Creator + +This skill provides guidance for creating effective skills. + +## About Skills + +Skills are modular, self-contained packages that extend Codex's capabilities by providing +specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific +domains or tasks—they transform Codex from a general-purpose agent into a specialized agent +equipped with procedural knowledge that no model can fully possess. + +### What Skills Provide + +1. Specialized workflows - Multi-step procedures for specific domains +2. Tool integrations - Instructions for working with specific file formats or APIs +3. Domain expertise - Company-specific knowledge, schemas, business logic +4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks + +## Core Principles + +### Concise is Key + +The context window is a public good. Skills share the context window with everything else Codex needs: system prompt, conversation history, other Skills' metadata, and the actual user request. + +**Default assumption: Codex is already very smart.** Only add context Codex doesn't already have. Challenge each piece of information: "Does Codex really need this explanation?" and "Does this paragraph justify its token cost?" + +Prefer concise examples over verbose explanations. + +### Set Appropriate Degrees of Freedom + +Match the level of specificity to the task's fragility and variability: + +**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach. + +**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior. + +**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. + +Think of Codex as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). + +### Anatomy of a Skill + +Every skill consists of a required SKILL.md file and optional bundled resources: + +``` +skill-name/ +├── SKILL.md (required) +│ ├── YAML frontmatter metadata (required) +│ │ ├── name: (required) +│ │ └── description: (required) +│ └── Markdown instructions (required) +└── Bundled Resources (optional) + ├── scripts/ - Executable code (Python/Bash/etc.) + ├── references/ - Documentation intended to be loaded into context as needed + └── assets/ - Files used in output (templates, icons, fonts, etc.) +``` + +#### SKILL.md (required) + +Every SKILL.md consists of: + +- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Codex reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. +- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). + +#### Bundled Resources (optional) + +##### Scripts (`scripts/`) + +Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten. + +- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed +- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks +- **Benefits**: Token efficient, deterministic, may be executed without loading into context +- **Note**: Scripts may still need to be read by Codex for patching or environment-specific adjustments + +##### References (`references/`) + +Documentation and reference material intended to be loaded as needed into context to inform Codex's process and thinking. + +- **When to include**: For documentation that Codex should reference while working +- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications +- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides +- **Benefits**: Keeps SKILL.md lean, loaded only when Codex determines it's needed +- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md +- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. + +##### Assets (`assets/`) + +Files not intended to be loaded into context, but rather used within the output Codex produces. + +- **When to include**: When the skill needs files that will be used in the final output +- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography +- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified +- **Benefits**: Separates output resources from documentation, enables Codex to use files without loading them into context + +#### What to Not Include in a Skill + +A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including: + +- README.md +- INSTALLATION_GUIDE.md +- QUICK_REFERENCE.md +- CHANGELOG.md +- etc. + +The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion. + +### Progressive Disclosure Design Principle + +Skills use a three-level loading system to manage context efficiently: + +1. **Metadata (name + description)** - Always in context (~100 words) +2. **SKILL.md body** - When skill triggers (<5k words) +3. **Bundled resources** - As needed by Codex (Unlimited because scripts can be executed without reading into context window) + +#### Progressive Disclosure Patterns + +Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them. + +**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files. + +**Pattern 1: High-level guide with references** + +```markdown +# PDF Processing + +## Quick start + +Extract text with pdfplumber: +[code example] + +## Advanced features + +- **Form filling**: See [FORMS.md](FORMS.md) for complete guide +- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods +- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns +``` + +Codex loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. + +**Pattern 2: Domain-specific organization** + +For Skills with multiple domains, organize content by domain to avoid loading irrelevant context: + +``` +bigquery-skill/ +├── SKILL.md (overview and navigation) +└── reference/ + ├── finance.md (revenue, billing metrics) + ├── sales.md (opportunities, pipeline) + ├── product.md (API usage, features) + └── marketing.md (campaigns, attribution) +``` + +When a user asks about sales metrics, Codex only reads sales.md. + +Similarly, for skills supporting multiple frameworks or variants, organize by variant: + +``` +cloud-deploy/ +├── SKILL.md (workflow + provider selection) +└── references/ + ├── aws.md (AWS deployment patterns) + ├── gcp.md (GCP deployment patterns) + └── azure.md (Azure deployment patterns) +``` + +When the user chooses AWS, Codex only reads aws.md. + +**Pattern 3: Conditional details** + +Show basic content, link to advanced content: + +```markdown +# DOCX Processing + +## Creating documents + +Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md). + +## Editing documents + +For simple edits, modify the XML directly. + +**For tracked changes**: See [REDLINING.md](REDLINING.md) +**For OOXML details**: See [OOXML.md](OOXML.md) +``` + +Codex reads REDLINING.md or OOXML.md only when the user needs those features. + +**Important guidelines:** + +- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. +- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Codex can see the full scope when previewing. + +## Skill Creation Process + +Skill creation involves these steps: + +1. Understand the skill with concrete examples +2. Plan reusable skill contents (scripts, references, assets) +3. Initialize the skill (run init_skill.py) +4. Edit the skill (implement resources and write SKILL.md) +5. Package the skill (run package_skill.py) +6. Iterate based on real usage + +Follow these steps in order, skipping only if there is a clear reason why they are not applicable. + +### Skill Naming + +- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., "Plan Mode" -> `plan-mode`). +- When generating names, generate a name under 64 characters (letters, digits, hyphens). +- Prefer short, verb-led phrases that describe the action. +- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`). +- Name the skill folder exactly after the skill name. + +### Step 1: Understanding the Skill with Concrete Examples + +Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill. + +To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback. + +For example, when building an image-editor skill, relevant questions include: + +- "What functionality should the image-editor skill support? Editing, rotating, anything else?" +- "Can you give some examples of how this skill would be used?" +- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" +- "What would a user say that should trigger this skill?" + +To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. + +Conclude this step when there is a clear sense of the functionality the skill should support. + +### Step 2: Planning the Reusable Skill Contents + +To turn concrete examples into an effective skill, analyze each example by: + +1. Considering how to execute on the example from scratch +2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly + +Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows: + +1. Rotating a PDF requires re-writing the same code each time +2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill + +Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows: + +1. Writing a frontend webapp requires the same boilerplate HTML/React each time +2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill + +Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows: + +1. Querying BigQuery requires re-discovering the table schemas and relationships each time +2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill + +To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets. + +### Step 3: Initializing the Skill + +At this point, it is time to actually create the skill. + +Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step. + +When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. + +Usage: + +```bash +scripts/init_skill.py --path [--resources scripts,references,assets] [--examples] +``` + +Examples: + +```bash +scripts/init_skill.py my-skill --path skills/public +scripts/init_skill.py my-skill --path skills/public --resources scripts,references +scripts/init_skill.py my-skill --path skills/public --resources scripts --examples +``` + +The script: + +- Creates the skill directory at the specified path +- Generates a SKILL.md template with proper frontmatter and TODO placeholders +- Optionally creates resource directories based on `--resources` +- Optionally adds example files when `--examples` is set + +After initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files. + +### Step 4: Edit the Skill + +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Codex to use. Include information that would be beneficial and non-obvious to Codex. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Codex instance execute these tasks more effectively. + +#### Learn Proven Design Patterns + +Consult these helpful guides based on your skill's needs: + +- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic +- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns + +These files contain established best practices for effective skill design. + +#### Start with Reusable Skill Contents + +To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`. + +Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion. + +If you used `--examples`, delete any placeholder files that are not needed for the skill. Only create resource directories that are actually required. + +#### Update SKILL.md + +**Writing Guidelines:** Always use imperative/infinitive form. + +##### Frontmatter + +Write the YAML frontmatter with `name` and `description`: + +- `name`: The skill name +- `description`: This is the primary triggering mechanism for your skill, and helps Codex understand when to use the skill. + - Include both what the Skill does and specific triggers/contexts for when to use it. + - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Codex. + - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Codex needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" + +Do not include any other fields in YAML frontmatter. + +##### Body + +Write instructions for using the skill and its bundled resources. + +### Step 5: Packaging a Skill + +Once development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements: + +```bash +scripts/package_skill.py +``` + +Optional output directory specification: + +```bash +scripts/package_skill.py ./dist +``` + +The packaging script will: + +1. **Validate** the skill automatically, checking: + - YAML frontmatter format and required fields + - Skill naming conventions and directory structure + - Description completeness and quality + - File organization and resource references + +2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension. + + Security restriction: symlinks are rejected and packaging fails when any symlink is present. + +If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again. + +### Step 6: Iterate + +After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed. + +**Iteration workflow:** + +1. Use the skill on real tasks +2. Notice struggles or inefficiencies +3. Identify how SKILL.md or bundled resources should be updated +4. Implement changes and test again diff --git a/.opencode/skills/skill-creator/SKILL.md:Zone.Identifier b/.opencode/skills/skill-creator/SKILL.md:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2xdl#JyUFr831@K2x --path [--resources scripts,references,assets] [--examples] + +Examples: + init_skill.py my-new-skill --path skills/public + init_skill.py my-new-skill --path skills/public --resources scripts,references + init_skill.py my-api-helper --path skills/private --resources scripts --examples + init_skill.py custom-skill --path /custom/location +""" + +import argparse +import re +import sys +from pathlib import Path + +MAX_SKILL_NAME_LENGTH = 64 +ALLOWED_RESOURCES = {"scripts", "references", "assets"} + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" -> "Reading" -> "Creating" -> "Editing" +- Structure: ## Overview -> ## Workflow Decision Tree -> ## Step 1 -> ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" -> "Merge PDFs" -> "Split PDFs" -> "Extract Text" +- Structure: ## Overview -> ## Quick Start -> ## Task Category 1 -> ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" -> "Colors" -> "Typography" -> "Features" +- Structure: ## Overview -> ## Guidelines -> ## Specifications -> ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" -> numbered capability list +- Structure: ## Overview -> ## Core Capabilities -> ### 1. Feature -> ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources (optional) + +Create only the resource directories this skill actually needs. Delete this section if no resources are required. + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Codex's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Codex produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Not every skill requires all three types of resources.** +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Codex produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def normalize_skill_name(skill_name): + """Normalize a skill name to lowercase hyphen-case.""" + normalized = skill_name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + return normalized + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return " ".join(word.capitalize() for word in skill_name.split("-")) + + +def parse_resources(raw_resources): + if not raw_resources: + return [] + resources = [item.strip() for item in raw_resources.split(",") if item.strip()] + invalid = sorted({item for item in resources if item not in ALLOWED_RESOURCES}) + if invalid: + allowed = ", ".join(sorted(ALLOWED_RESOURCES)) + print(f"[ERROR] Unknown resource type(s): {', '.join(invalid)}") + print(f" Allowed: {allowed}") + sys.exit(1) + deduped = [] + seen = set() + for resource in resources: + if resource not in seen: + deduped.append(resource) + seen.add(resource) + return deduped + + +def create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples): + for resource in resources: + resource_dir = skill_dir / resource + resource_dir.mkdir(exist_ok=True) + if resource == "scripts": + if include_examples: + example_script = resource_dir / "example.py" + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("[OK] Created scripts/example.py") + else: + print("[OK] Created scripts/") + elif resource == "references": + if include_examples: + example_reference = resource_dir / "api_reference.md" + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("[OK] Created references/api_reference.md") + else: + print("[OK] Created references/") + elif resource == "assets": + if include_examples: + example_asset = resource_dir / "example_asset.txt" + example_asset.write_text(EXAMPLE_ASSET) + print("[OK] Created assets/example_asset.txt") + else: + print("[OK] Created assets/") + + +def init_skill(skill_name, path, resources, include_examples): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + resources: Resource directories to create + include_examples: Whether to create example files in resource directories + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"[ERROR] Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"[OK] Created skill directory: {skill_dir}") + except Exception as e: + print(f"[ERROR] Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format(skill_name=skill_name, skill_title=skill_title) + + skill_md_path = skill_dir / "SKILL.md" + try: + skill_md_path.write_text(skill_content) + print("[OK] Created SKILL.md") + except Exception as e: + print(f"[ERROR] Error creating SKILL.md: {e}") + return None + + # Create resource directories if requested + if resources: + try: + create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples) + except Exception as e: + print(f"[ERROR] Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + if resources: + if include_examples: + print("2. Customize or delete the example files in scripts/, references/, and assets/") + else: + print("2. Add resources to scripts/, references/, and assets/ as needed") + else: + print("2. Create resource directories only if needed (scripts/, references/, assets/)") + print("3. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + parser = argparse.ArgumentParser( + description="Create a new skill directory with a SKILL.md template.", + ) + parser.add_argument("skill_name", help="Skill name (normalized to hyphen-case)") + parser.add_argument("--path", required=True, help="Output directory for the skill") + parser.add_argument( + "--resources", + default="", + help="Comma-separated list: scripts,references,assets", + ) + parser.add_argument( + "--examples", + action="store_true", + help="Create example files inside the selected resource directories", + ) + args = parser.parse_args() + + raw_skill_name = args.skill_name + skill_name = normalize_skill_name(raw_skill_name) + if not skill_name: + print("[ERROR] Skill name must include at least one letter or digit.") + sys.exit(1) + if len(skill_name) > MAX_SKILL_NAME_LENGTH: + print( + f"[ERROR] Skill name '{skill_name}' is too long ({len(skill_name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." + ) + sys.exit(1) + if skill_name != raw_skill_name: + print(f"Note: Normalized skill name from '{raw_skill_name}' to '{skill_name}'.") + + resources = parse_resources(args.resources) + if args.examples and not resources: + print("[ERROR] --examples requires --resources to be set.") + sys.exit(1) + + path = args.path + + print(f"Initializing skill: {skill_name}") + print(f" Location: {path}") + if resources: + print(f" Resources: {', '.join(resources)}") + if args.examples: + print(" Examples: enabled") + else: + print(" Resources: none (create as needed)") + print() + + result = init_skill(skill_name, path, resources, args.examples) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.opencode/skills/skill-creator/scripts/init_skill.py:Zone.Identifier b/.opencode/skills/skill-creator/scripts/init_skill.py:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import sys +import zipfile +from pathlib import Path + +from quick_validate import validate_skill + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"[ERROR] Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"[ERROR] Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"[ERROR] SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"[ERROR] Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"[OK] {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + EXCLUDED_DIRS = {".git", ".svn", ".hg", "__pycache__", "node_modules"} + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory + for file_path in skill_path.rglob("*"): + # Security: never follow or package symlinks. + if file_path.is_symlink(): + print(f"[WARN] Skipping symlink: {file_path}") + continue + + rel_parts = file_path.relative_to(skill_path).parts + if any(part in EXCLUDED_DIRS for part in rel_parts): + continue + + if file_path.is_file(): + resolved_file = file_path.resolve() + if not _is_within(resolved_file, skill_path): + print(f"[ERROR] File escapes skill root: {file_path}") + return None + # If output lives under skill_path, avoid writing archive into itself. + if resolved_file == skill_filename.resolve(): + print(f"[WARN] Skipping output archive: {file_path}") + continue + + # Calculate the relative path within the zip. + arcname = Path(skill_name) / file_path.relative_to(skill_path) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n[OK] Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"[ERROR] Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.opencode/skills/skill-creator/scripts/package_skill.py:Zone.Identifier b/.opencode/skills/skill-creator/scripts/package_skill.py:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x Optional[str]: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + return "\n".join(lines[1:i]) + return None + + +def _parse_simple_frontmatter(frontmatter_text: str) -> Optional[dict[str, str]]: + """ + Minimal fallback parser used when PyYAML is unavailable. + Supports simple `key: value` mappings used by SKILL.md frontmatter. + """ + parsed: dict[str, str] = {} + current_key: Optional[str] = None + for raw_line in frontmatter_text.splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + continue + + is_indented = raw_line[:1].isspace() + if is_indented: + if current_key is None: + return None + current_value = parsed[current_key] + parsed[current_key] = ( + f"{current_value}\n{stripped}" if current_value else stripped + ) + continue + + if ":" not in stripped: + return None + key, value = stripped.split(":", 1) + key = key.strip() + value = value.strip() + if not key: + return None + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + parsed[key] = value + current_key = key + return parsed + + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + return False, "SKILL.md not found" + + try: + content = skill_md.read_text(encoding="utf-8") + except OSError as e: + return False, f"Could not read SKILL.md: {e}" + + frontmatter_text = _extract_frontmatter(content) + if frontmatter_text is None: + return False, "Invalid frontmatter format" + if yaml is not None: + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}" + else: + frontmatter = _parse_simple_frontmatter(frontmatter_text) + if frontmatter is None: + return ( + False, + "Invalid YAML in frontmatter: unsupported syntax without PyYAML installed", + ) + + allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"} + + unexpected_keys = set(frontmatter.keys()) - allowed_properties + if unexpected_keys: + allowed = ", ".join(sorted(allowed_properties)) + unexpected = ", ".join(sorted(unexpected_keys)) + return ( + False, + f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", + ) + + if "name" not in frontmatter: + return False, "Missing 'name' in frontmatter" + if "description" not in frontmatter: + return False, "Missing 'description' in frontmatter" + + name = frontmatter.get("name", "") + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name = name.strip() + if name: + if not re.match(r"^[a-z0-9-]+$", name): + return ( + False, + f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", + ) + if name.startswith("-") or name.endswith("-") or "--" in name: + return ( + False, + f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", + ) + if len(name) > MAX_SKILL_NAME_LENGTH: + return ( + False, + f"Name is too long ({len(name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters.", + ) + + description = frontmatter.get("description", "") + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description = description.strip() + if description: + if "<" in description or ">" in description: + return False, "Description cannot contain angle brackets (< or >)" + if len(description) > 1024: + return ( + False, + f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", + ) + + return True, "Skill is valid!" + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) diff --git a/.opencode/skills/skill-creator/scripts/quick_validate.py:Zone.Identifier b/.opencode/skills/skill-creator/scripts/quick_validate.py:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2xdl#JyUFr831@K2xdl#JyUFr831@K2x { + const globalSDK = useGlobalSDK() + const sdk = useSDK() + + // Skills list/save/delete use NO directory — the server defaults to process.cwd() + // (the openresearch project), so skills always come from .opencode/skills/ there. + // Only "add to project" passes sdk.directory (the currently viewed project). + const [skills, { refetch }] = createResource(async () => { + const result = await globalSDK.client.config.skillsList() + return ((result.data as unknown) as DefaultSkill[]) ?? [] + }) + + const [edit, setEdit] = createSignal({ mode: "none" }) + const [adding, setAdding] = createSignal(false) + const [deleting, setDeleting] = createSignal(null) + const [saving, setSaving] = createSignal(false) + + const [form, setForm] = createStore({ name: "", description: "", content: "" }) + + function openNew() { + setForm({ name: "", description: "", content: "" }) + setEdit({ mode: "new" }) + } + + function openEdit(skill: DefaultSkill) { + setForm({ name: skill.name, description: skill.description, content: skill.content }) + setEdit({ mode: "edit", skill }) + } + + function cancelEdit() { + setEdit({ mode: "none" }) + } + + async function handleSave() { + if (!form.name.trim()) { + showToast({ variant: "error", icon: "circle-x", title: "名称不能为空" }) + return + } + setSaving(true) + try { + await globalSDK.client.config.skillsSave({ + name: form.name.trim(), + description: form.description.trim(), + content: form.content, + }) + await refetch() + setEdit({ mode: "none" }) + showToast({ variant: "success", icon: "circle-check", title: "Skill 已保存" }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ variant: "error", icon: "circle-x", title: "保存失败", description: message }) + } finally { + setSaving(false) + } + } + + async function handleDelete(name: string) { + setDeleting(name) + try { + await globalSDK.client.config.skillsDelete({ name }) + await refetch() + showToast({ icon: "check", title: `已删除 ${name}` }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ variant: "error", icon: "circle-x", title: "删除失败", description: message }) + } finally { + setDeleting(null) + } + } + + async function handleAddToProject() { + setAdding(true) + try { + const result = await globalSDK.client.config.skillsDefaults({ directory: sdk.directory }) + const added = ((result.data as unknown) as { added: string[] }).added + if (added.length > 0) { + showToast({ + variant: "success", + icon: "circle-check", + title: "Skills 已复制到项目", + description: `已复制:${added.join(", ")}`, + }) + } else { + showToast({ icon: "check", title: "已是最新", description: "默认 Skills 已全部存在于项目中" }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ variant: "error", icon: "circle-x", title: "添加失败", description: message }) + } finally { + setAdding(false) + } + } + + return ( +
+ {/* Header row */} +
+
+ + 默认 Skills + + ({skills()!.length}) + +
+
+ + +
+
+ + {/* Skill list */} + + +
加载中...
+
+ +
+ 暂无默认 Skills,点击「新建」创建第一个 +
+
+ +
+ + {(skill) => ( +
+
+ {skill.name} + + {skill.description} + + + 暂无描述 + +
+
+ openEdit(skill)} + /> + handleDelete(skill.name)} + /> +
+
+ )} +
+
+
+
+ + {/* Edit / New form */} + +
+ + {edit().mode === "new" ? "新建 Skill" : `编辑:${(edit() as { mode: "edit"; skill: DefaultSkill }).skill.name}`} + + + setForm("name", v)} + /> + + setForm("description", v)} + /> + setForm("content", v)} + spellcheck={false} + class="font-mono text-xs min-h-24 max-h-48 overflow-y-auto" + /> +
+ + +
+
+
+
+ ) +} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 930832fb6555..822a2161d672 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -1,6 +1,7 @@ import { useFile } from "@/context/file" import { encodeFilePath } from "@/context/file/path" import { Collapsible } from "@opencode-ai/ui/collapsible" +import { ContextMenu } from "@opencode-ai/ui/context-menu" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { @@ -202,6 +203,8 @@ export default function FileTree(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void + onFileCreate?: (dir: string, type: "file" | "directory") => void + onFileDelete?: (node: FileNode) => void _filter?: Filter _marks?: Set @@ -393,110 +396,145 @@ export default function FileTree(props: { const kind = () => visibleKind(node, kinds(), marks()) const active = () => !!kind() && !node.ignored + const contextMenu = (trigger: () => JSXElement) => ( + + + {trigger()} + + + + {node.type === "directory" && ( + <> + props.onFileCreate?.(node.path, "file")}> + 新建文件 + + props.onFileCreate?.(node.path, "directory")}> + 新建文件夹 + + + + )} + props.onFileDelete?.(node)} + class="text-red-500 focus:text-red-500" + > + 删除 + + + + + ) + return ( - (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} - > - - -
- -
-
-
- -
- ...
} - > - ( + (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} + > + + +
+ +
+
+
+ +
- - - + ...
} + > + + +
+
+ ))}
- props.onFileClick?.(node)} - > -
- - - - - - - - - + {contextMenu(() => ( + props.onFileClick?.(node)} + > +
+ + + + - - - - + + + + + + + + + + ))} ) diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 52251dbb2079..088726c3d559 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" import { Mark } from "@opencode-ai/ui/logo" import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { DefaultSkillsPanel } from "@/components/default-skills-panel" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" @@ -87,6 +88,28 @@ export function NewSessionView(props: NewSessionViewProps) {
+
+ +
{label(current())}
+
+ + {(project) => ( +
+ +
+ {language.t("session.new.lastModified")}  + + {DateTime.fromMillis(project().time.updated ?? project().time.created) + .setLocale(language.intl()) + .toRelative()} + +
+
+ )} +
+
+ +
) } diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 2c499d9f4195..1cbd05098eff 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -3,9 +3,11 @@ import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { Tabs } from "@opencode-ai/ui/tabs" import { IconButton } from "@opencode-ai/ui/icon-button" -import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { Icon } from "@opencode-ai/ui/icon" +import { TooltipKeybind, Tooltip } from "@opencode-ai/ui/tooltip" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" +import { showToast } from "@opencode-ai/ui/toast" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" @@ -20,12 +22,14 @@ import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { StickyAddButton } from "@/pages/session/review-tab" import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +import type { FileNode } from "@opencode-ai/sdk/v2" export function SessionSidePanel(props: { reviewPanel: () => JSX.Element @@ -40,6 +44,40 @@ export function SessionSidePanel(props: { const language = useLanguage() const command = useCommand() const dialog = useDialog() + const sdk = useSDK() + + async function handleFileCreate(dir: string, type: "file" | "directory") { + const name = window.prompt(type === "file" ? "新建文件名称:" : "新建文件夹名称:") + if (!name?.trim()) return + const newPath = dir ? `${dir}/${name.trim()}` : name.trim() + try { + await sdk.client.file.create({ path: newPath, type }) + file.tree.refresh(dir) + if (!file.tree.state(dir)?.expanded) file.tree.expand(dir) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ variant: "error", icon: "circle-x", title: "创建失败", description: message }) + } + } + + async function handleFileDelete(node: FileNode) { + const label = node.type === "directory" ? "文件夹" : "文件" + const confirmed = window.confirm(`确认删除${label} "${node.name}"?此操作不可撤销。`) + if (!confirmed) return + const parentDir = node.path.includes("/") ? node.path.slice(0, node.path.lastIndexOf("/")) : "" + try { + await sdk.client.file.delete({ path: node.path }) + file.tree.refresh(parentDir) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ variant: "error", icon: "circle-x", title: "删除失败", description: message }) + } + } + + function handleRefresh() { + file.tree.refresh("") + } + const { params, sessionKey, tabs, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") @@ -429,7 +467,39 @@ export function SessionSidePanel(props: {
- + +
+ + + + + + + + + +
{empty(language.t("session.files.empty"))} @@ -439,6 +509,8 @@ export function SessionSidePanel(props: { modified={diffFiles()} kinds={kinds()} onFileClick={(node) => openTab(file.tab(node.path))} + onFileCreate={handleFileCreate} + onFileDelete={handleFileDelete} /> diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2b8aa9e03010..57645f312f56 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -25,7 +25,7 @@ import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" -import { constants, existsSync } from "fs" +import { constants, existsSync, statSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" @@ -36,6 +36,7 @@ import { iife } from "@/util/iife" import { Account } from "@/account" import { ConfigPaths } from "./paths" import { Filesystem } from "@/util/filesystem" +import matter from "gray-matter" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) @@ -1325,6 +1326,106 @@ export namespace Config { await Instance.dispose() } + export const DefaultSkill = z.object({ + name: z.string(), + description: z.string(), + content: z.string(), + }) + export type DefaultSkill = z.infer + + // Search upward from process.cwd() for .opencode/skills, calculated once at startup. + // This ensures the source skills dir is always the server's own project regardless of + // which project the user is currently viewing. + function findServerSkillsDirSync(): string | undefined { + let dir = process.cwd() + while (true) { + const candidate = path.join(dir, ".opencode", "skills") + try { + if (statSync(candidate).isDirectory()) return candidate + } catch {} + const parent = path.dirname(dir) + if (parent === dir) break + dir = parent + } + return undefined + } + const _serverSkillsDir = findServerSkillsDirSync() + + export function getDefaultSkillsDir(): string | undefined { + return _serverSkillsDir + } + + export async function listDefaultSkills(): Promise { + const skillsDir = getDefaultSkillsDir() + if (!skillsDir) return [] + if (!(await Filesystem.isDir(skillsDir))) return [] + + const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []) + const skills: DefaultSkill[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + const skillFile = path.join(skillsDir, entry.name, "SKILL.md") + const text = await Filesystem.readText(skillFile).catch(() => null) + if (text === null) { + skills.push({ name: entry.name, description: "", content: "" }) + continue + } + try { + const parsed = matter(text) + skills.push({ + name: String(parsed.data.name ?? entry.name), + description: String(parsed.data.description ?? ""), + content: parsed.content.trim(), + }) + } catch { + skills.push({ name: entry.name, description: "", content: text }) + } + } + return skills + } + + export async function saveDefaultSkill(name: string, description: string, content: string): Promise { + const skillsDir = getDefaultSkillsDir() + if (!skillsDir) throw new Error("No .opencode directory found") + const skillDir = path.join(skillsDir, name) + await fs.mkdir(skillDir, { recursive: true }) + const skillFile = path.join(skillDir, "SKILL.md") + const text = `---\nname: ${name}\ndescription: ${description}\n---\n${content}` + await Filesystem.write(skillFile, text) + } + + export async function deleteDefaultSkill(name: string): Promise { + const skillsDir = getDefaultSkillsDir() + if (!skillsDir) throw new Error("No .opencode directory found") + const skillDir = path.join(skillsDir, name) + await fs.rm(skillDir, { recursive: true, force: true }) + } + + export async function addDefaultSkills(): Promise { + // Source: skills from the server's own project (found once at startup) + const sourceSkillsDir = getDefaultSkillsDir() + if (!sourceSkillsDir) return [] + + // Target: the currently viewed project (.opencode/skills/ under Instance.directory) + const targetSkillsDir = path.join(Instance.directory, ".opencode", "skills") + await fs.mkdir(targetSkillsDir, { recursive: true }) + + // Copy each skill directory from source to target + const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true }).catch(() => []) + const copied: string[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + const src = path.join(sourceSkillsDir, entry.name) + const dest = path.join(targetSkillsDir, entry.name) + if (await Filesystem.isDir(dest)) continue // Already exists, skip + await fs.cp(src, dest, { recursive: true }) + copied.push(entry.name) + } + + if (copied.length > 0) await Instance.dispose() + return copied + } + function globalConfigFile() { const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => path.join(Global.Path.config, file), diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index e03fc8a9f301..40f71b1f227c 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -651,4 +651,25 @@ export namespace File { log.info("search", { query, kind, results: output.length }) return output } + + export async function create(filePath: string, type: "file" | "directory"): Promise { + const resolved = path.join(Instance.directory, filePath) + if (!Instance.containsPath(resolved)) { + throw new Error("Access denied: path escapes project directory") + } + if (type === "directory") { + await fs.promises.mkdir(resolved, { recursive: true }) + } else { + await fs.promises.mkdir(path.dirname(resolved), { recursive: true }) + await fs.promises.writeFile(resolved, "", { flag: "wx" }) + } + } + + export async function remove(filePath: string): Promise { + const resolved = path.join(Instance.directory, filePath) + if (!Instance.containsPath(resolved)) { + throw new Error("Access denied: path escapes project directory") + } + await fs.promises.rm(resolved, { recursive: true, force: true }) + } } diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/routes/config.ts index 85d28f6aa6b8..c525f416c0c1 100644 --- a/packages/opencode/src/server/routes/config.ts +++ b/packages/opencode/src/server/routes/config.ts @@ -58,6 +58,89 @@ export const ConfigRoutes = lazy(() => return c.json(config) }, ) + .get( + "/skills", + describeRoute({ + summary: "List default skills", + description: "List all default skills from the .opencode/skills/ directory.", + operationId: "config.skills.list", + responses: { + 200: { + description: "List of default skills", + content: { + "application/json": { + schema: resolver(z.array(Config.DefaultSkill)), + }, + }, + }, + }, + }), + async (c) => { + const skills = await Config.listDefaultSkills() + return c.json(skills) + }, + ) + .post( + "/skills", + describeRoute({ + summary: "Create or update a default skill", + description: "Create or update a skill in .opencode/skills/.", + operationId: "config.skills.save", + responses: { + 200: { + description: "Skill saved", + content: { "application/json": { schema: resolver(z.object({ ok: z.boolean() })) } }, + }, + }, + }), + validator("json", Config.DefaultSkill), + async (c) => { + const { name, description, content } = c.req.valid("json") + await Config.saveDefaultSkill(name, description, content) + return c.json({ ok: true }) + }, + ) + .delete( + "/skills/:name", + describeRoute({ + summary: "Delete a default skill", + description: "Delete a skill from .opencode/skills/.", + operationId: "config.skills.delete", + responses: { + 200: { + description: "Skill deleted", + content: { "application/json": { schema: resolver(z.object({ ok: z.boolean() })) } }, + }, + }, + }), + async (c) => { + const name = c.req.param("name") + await Config.deleteDefaultSkill(name) + return c.json({ ok: true }) + }, + ) + .post( + "/skills/defaults", + describeRoute({ + summary: "Add default skills to project config", + description: "Add the default skills from .opencode/skills/ to the project's opencode.jsonc skills.paths.", + operationId: "config.skills.addDefaults", + responses: { + 200: { + description: "Successfully added default skills", + content: { + "application/json": { + schema: resolver(z.object({ added: z.array(z.string()) })), + }, + }, + }, + }, + }), + async (c) => { + const added = await Config.addDefaultSkills() + return c.json({ added }) + }, + ) .get( "/providers", describeRoute({ diff --git a/packages/opencode/src/server/routes/file.ts b/packages/opencode/src/server/routes/file.ts index 60789ef4b722..b77b302d7ff5 100644 --- a/packages/opencode/src/server/routes/file.ts +++ b/packages/opencode/src/server/routes/file.ts @@ -6,6 +6,7 @@ import { Ripgrep } from "../../file/ripgrep" import { LSP } from "../../lsp" import { Instance } from "../../project/instance" import { lazy } from "../../util/lazy" +import { errors } from "../error" export const FileRoutes = lazy(() => new Hono() @@ -172,6 +173,48 @@ export const FileRoutes = lazy(() => return c.json(content) }, ) + .post( + "/file", + describeRoute({ + summary: "Create file or directory", + description: "Create a new file or directory at the specified path within the project.", + operationId: "file.create", + responses: { + 200: { + description: "Created", + content: { "application/json": { schema: resolver(z.object({ ok: z.boolean() })) } }, + }, + ...errors(400), + }, + }), + validator("json", z.object({ path: z.string(), type: z.enum(["file", "directory"]) })), + async (c) => { + const { path, type } = c.req.valid("json") + await File.create(path, type) + return c.json({ ok: true }) + }, + ) + .delete( + "/file", + describeRoute({ + summary: "Delete file or directory", + description: "Delete a file or directory at the specified path within the project.", + operationId: "file.delete", + responses: { + 200: { + description: "Deleted", + content: { "application/json": { schema: resolver(z.object({ ok: z.boolean() })) } }, + }, + ...errors(400), + }, + }), + validator("query", z.object({ path: z.string() })), + async (c) => { + const { path } = c.req.valid("query") + await File.remove(path) + return c.json({ ok: true }) + }, + ) .get( "/file/status", describeRoute({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2bb2edcd1752..648a02a0920b 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -825,6 +825,115 @@ export class Config2 extends HeyApiClient { ...params, }) } + + /** + * List default skills + */ + public skillsList( + parameters?: { directory?: string; workspace?: string }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [{ args: [{ in: "query", key: "directory" }, { in: "query", key: "workspace" }] }], + ) + return (options?.client ?? this.client).get< + Array<{ name: string; description: string; content: string }>, + unknown, + ThrowOnError + >({ url: "/config/skills", ...options, ...params }) + } + + /** + * Create or update a default skill + */ + public skillsSave( + parameters: { + directory?: string + workspace?: string + name: string + description: string + content: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "description" }, + { in: "body", key: "content" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post<{ ok: boolean }, unknown, ThrowOnError>({ + url: "/config/skills", + ...options, + ...params, + headers: { "Content-Type": "application/json", ...options?.headers, ...params.headers }, + }) + } + + /** + * Delete a default skill + */ + public skillsDelete( + parameters: { name: string; directory?: string; workspace?: string }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete<{ ok: boolean }, unknown, ThrowOnError>({ + url: "/config/skills/{name}", + ...options, + ...params, + }) + } + + /** + * Add default skills to project config + * + * Add the default skills from .opencode/skills/ to the project's opencode.jsonc skills.paths. + */ + public skillsDefaults( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post<{ added: string[] }, unknown, ThrowOnError>({ + url: "/config/skills/defaults", + ...options, + ...params, + }) + } } export class Tool extends HeyApiClient { @@ -2837,6 +2946,69 @@ export class File extends HeyApiClient { ...params, }) } + + /** + * Create file or directory + */ + public create( + parameters: { + directory?: string + workspace?: string + path: string + type: "file" | "directory" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "path" }, + { in: "body", key: "type" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post<{ ok: boolean }, unknown, ThrowOnError>({ + url: "/file", + ...options, + ...params, + headers: { "Content-Type": "application/json", ...options?.headers, ...params.headers }, + }) + } + + /** + * Delete file or directory + */ + public delete( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete<{ ok: boolean }, unknown, ThrowOnError>({ + url: "/file", + ...options, + ...params, + }) + } } export class Auth2 extends HeyApiClient { From de9fc5fe3c6fe8d587ecd473b2d70a9eee352e96 Mon Sep 17 00:00:00 2001 From: zhenghuiwen <974862648@qq.com> Date: Sun, 8 Mar 2026 23:08:00 +0800 Subject: [PATCH 004/113] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=91=98=E8=A6=81=E5=AF=BC=E8=88=AA=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8D=A2=E8=A1=8C=E5=88=87=E6=8D=A2=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/skills/deep-research/SKILL.md | 192 ++++++++++++++ bun.lock | 68 +++++ packages/app/package.json | 12 + packages/app/src/components/code-editor.tsx | 135 ++++++++++ .../src/components/default-skills-panel.tsx | 76 ++++-- .../src/components/dialog-default-skills.tsx | 224 +++++++++++++++++ .../components/session/session-new-view.tsx | 16 +- packages/app/src/index.css | 9 + packages/app/src/pages/session/file-tabs.tsx | 238 ++++++++++++++---- .../src/pages/session/session-side-panel.tsx | 34 ++- .../opencode/src/agent/prompt/explore.txt | 10 + packages/opencode/src/file/index.ts | 8 + packages/opencode/src/file/summary-watcher.ts | 64 +++++ packages/opencode/src/file/summary.ts | 151 +++++++++++ packages/opencode/src/project/bootstrap.ts | 2 + packages/opencode/src/server/routes/file.ts | 50 ++++ .../src/session/prompt/anthropic-20250930.txt | 10 + .../opencode/src/session/prompt/anthropic.txt | 10 + packages/opencode/src/session/prompt/qwen.txt | 10 + .../opencode/src/session/prompt/trinity.txt | 10 + packages/opencode/src/tool/read.ts | 39 ++- packages/opencode/src/tool/read.txt | 1 + packages/opencode/src/tool/registry.ts | 2 + packages/opencode/src/tool/summarize-dirs.ts | 45 ++++ packages/opencode/src/tool/summarize-dirs.txt | 15 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 70 ++++++ 26 files changed, 1417 insertions(+), 84 deletions(-) create mode 100644 .opencode/skills/deep-research/SKILL.md create mode 100644 packages/app/src/components/code-editor.tsx create mode 100644 packages/app/src/components/dialog-default-skills.tsx create mode 100644 packages/opencode/src/file/summary-watcher.ts create mode 100644 packages/opencode/src/file/summary.ts create mode 100644 packages/opencode/src/tool/summarize-dirs.ts create mode 100644 packages/opencode/src/tool/summarize-dirs.txt diff --git a/.opencode/skills/deep-research/SKILL.md b/.opencode/skills/deep-research/SKILL.md new file mode 100644 index 000000000000..ab1670bfc2db --- /dev/null +++ b/.opencode/skills/deep-research/SKILL.md @@ -0,0 +1,192 @@ +--- +name: deep-research +description: | + Comprehensive research assistant that synthesizes information from multiple sources with citations. + Use when: conducting in-depth research, gathering sources, writing research summaries, analyzing topics + from multiple perspectives, or when user mentions research, investigation, or needs synthesized analysis + with citations. +license: MIT +metadata: + author: awesome-llm-apps + version: "1.0.0" +--- + +# Deep Research + +You are an expert researcher who provides thorough, well-cited analysis by synthesizing information from multiple perspectives. + +## When to Apply + +Use this skill when: +- Conducting in-depth research on a topic +- Synthesizing information from multiple sources +- Creating research summaries with proper citations +- Analyzing different viewpoints and perspectives +- Identifying key findings and trends +- Evaluating the quality and credibility of sources + +## Research Process + +Follow this systematic approach: + +### 1. **Clarify the Research Question** +- What exactly needs to be researched? +- What level of detail is required? +- Are there specific angles to prioritize? +- What is the purpose of the research? + +### 2. **Identify Key Aspects** +- Break the topic into subtopics or dimensions +- List main questions to answer +- Note important context or background needed + +### 3. **Gather Information** +- Consider multiple perspectives +- Look for primary and secondary sources +- Check publication dates and currency +- Evaluate source credibility + +### 4. **Synthesize Findings** +- Identify patterns and themes +- Note areas of consensus and disagreement +- Highlight key insights +- Connect related information + +### 5. **Document Sources** +- Use numbered citations [1], [2], etc. +- List full sources at the end +- Note if information is uncertain or contested +- Indicate confidence levels where appropriate + +## Output Format + +Structure your research as: + +```markdown +## Executive Summary +[2-3 sentence overview of key findings] + +## Key Findings +- **[Finding 1]**: [Brief explanation] [1] +- **[Finding 2]**: [Brief explanation] [2] +- **[Finding 3]**: [Brief explanation] [3] + +## Detailed Analysis + +### [Subtopic 1] +[In-depth analysis with citations] + +### [Subtopic 2] +[In-depth analysis with citations] + +## Areas of Consensus +[What sources agree on] + +## Areas of Debate +[Where sources disagree or uncertainty exists] + +## Sources +[1] [Full citation with credibility note] +[2] [Full citation with credibility note] + +## Gaps and Further Research +[What's still unknown or needs investigation] +``` + +## Source Evaluation Criteria + +When citing sources, note: + +- **Peer-reviewed journals** - Highest credibility +- **Official reports/statistics** - Authoritative data +- **News from reputable outlets** - Timely, fact-checked +- **Expert commentary** - Qualified opinions +- **General websites** - verify independently + +## Example + +**User Request:** "Research the benefits and risks of intermittent fasting" + +**Response:** + +## Executive Summary + +Intermittent fasting (IF) shows promising benefits for weight loss and metabolic health based on current research, though long-term effects remain under study. Evidence supports its safety for most healthy adults, with certain populations requiring medical supervision [1][2]. + +## Key Findings + +- **Weight Loss**: IF produces similar weight loss to calorie restriction (5-8% body weight over 12 weeks), with potentially better adherence [1] +- **Metabolic Health**: May improve insulin sensitivity by 20-31% and reduce inflammation markers [2] +- **Longevity**: Animal studies show promise; human long-term data is limited [3] +- **Safety**: Not recommended for pregnant women, diabetics without supervision, or those with eating disorder history [4] + +## Detailed Analysis + +### Weight Management +Studies comparing IF to traditional calorie restriction show similar weight loss outcomes. The main advantage appears to be adherence - many people find time-restricted eating easier to maintain than calorie counting [1]. Typical results show 5-8% body weight loss over 12-16 weeks. + +Common IF protocols: +- **16:8** - 16 hours fasting, 8-hour eating window +- **5:2** - Normal eating 5 days, restricted calories 2 days +- **Alternate day** - Alternating between fasting and eating days + +### Metabolic Effects +Research indicates improvements in several biomarkers [2]: +- Fasting insulin levels decreased 20-31% +- Blood pressure reductions (3-8 mmHg systolic) +- Inflammatory marker (CRP) reductions +- Improved lipid profiles in some studies + +However, individual responses vary significantly. Some people show dramatic improvements while others see minimal changes. + +### Potential Mechanisms +Several explanations for observed benefits: +1. **Caloric restriction** - IF naturally reduces total calories +2. **Circadian alignment** - Eating during daytime hours aligns with metabolism +3. **Autophagy** - Cellular cleanup processes activated during fasting +4. **Hormetic stress** - Mild stress that improves adaptation + +### Safety Considerations +While generally safe for healthy adults, certain groups should avoid IF or proceed with medical supervision [4]: +- Pregnant or breastfeeding women +- People with diabetes (risk of hypoglycemia) +- History of eating disorders +- Children and adolescents +- Those taking medications requiring food + +Common side effects during adaptation (1-2 weeks): +- Hunger and irritability +- Headaches +- Fatigue +- Difficulty concentrating + +## Areas of Consensus + +- IF is as effective as traditional diets for weight loss +- Short-term safety is established for healthy adults +- Individual results vary considerably +- Not a magic solution - overall diet quality matters + +## Areas of Debate + +- **Optimal fasting window**: Research shows benefits across different protocols +- **Long-term sustainability**: Most studies are 8-24 weeks; need multi-year data +- **Superiority to other diets**: Unclear if benefits exceed other healthy eating patterns +- **Muscle preservation**: Some concern about muscle loss, but studies show mixed results + +## Sources + +[1] Varady KA, et al. "Clinical application of intermittent fasting for weight loss." *Nature Reviews Endocrinology*, 2022. (Systematic review, high credibility) + +[2] de Cabo R, Mattson MP. "Effects of Intermittent Fasting on Health, Aging, and Disease." *New England Journal of Medicine*, 2019. (Peer-reviewed, authoritative review) + +[3] Longo VD, Panda S. "Fasting, Circadian Rhythms, and Time-Restricted Feeding in Healthy Lifespan." *Cell Metabolism*, 2016. (Mechanistic research, preliminary human data) + +[4] Academy of Nutrition and Dietetics. "Position on Intermittent Fasting." 2022. (Professional organization guidelines) + +## Gaps and Further Research + +- **Long-term studies** (5+ years) needed for sustained effects +- **Different populations** - effects across ages, sexes, ethnicities +- **Optimization** - best fasting windows, meal timing, macronutrient composition +- **Clinical applications** - specific diseases or conditions that benefit most diff --git a/bun.lock b/bun.lock index 6140c3497a55..8376daa07003 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,17 @@ "name": "@opencode-ai/app", "version": "1.2.24", "dependencies": { + "@codemirror/autocomplete": "6.20.1", + "@codemirror/commands": "6.10.2", + "@codemirror/lang-css": "6.3.1", + "@codemirror/lang-javascript": "6.2.5", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-markdown": "6.5.0", + "@codemirror/lang-python": "6.2.1", + "@codemirror/language": "6.12.2", + "@codemirror/state": "6.5.4", + "@codemirror/theme-one-dark": "6.1.3", + "@codemirror/view": "6.39.16", "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -45,6 +56,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", + "codemirror": "6.0.2", "diff": "catalog:", "effect": "4.0.0-beta.31", "fuzzysort": "catalog:", @@ -956,6 +968,34 @@ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ=="], + + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="], + + "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], + + "@codemirror/language": ["@codemirror/language@6.12.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], + + "@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="], + + "@codemirror/state": ["@codemirror/state@6.5.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw=="], + + "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], + + "@codemirror/view": ["@codemirror/view@6.39.16", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q=="], + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -1302,12 +1342,32 @@ "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], + + "@lezer/css": ["@lezer/css@1.3.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + + "@lezer/lr": ["@lezer/lr@1.4.8", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA=="], + + "@lezer/markdown": ["@lezer/markdown@1.6.3", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw=="], + + "@lezer/python": ["@lezer/python@1.1.18", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg=="], + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], @@ -2536,6 +2596,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -2594,6 +2656,8 @@ "crc32-stream": ["crc32-stream@6.0.0", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], @@ -4332,6 +4396,8 @@ "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "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=="], @@ -4630,6 +4696,8 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], diff --git a/packages/app/package.json b/packages/app/package.json index 1e69a64f78c9..cf89b682a3c8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -38,6 +38,17 @@ "vite-plugin-solid": "catalog:" }, "dependencies": { + "@codemirror/autocomplete": "6.20.1", + "@codemirror/commands": "6.10.2", + "@codemirror/lang-css": "6.3.1", + "@codemirror/lang-javascript": "6.2.5", + "@codemirror/lang-json": "6.0.2", + "@codemirror/lang-markdown": "6.5.0", + "@codemirror/lang-python": "6.2.1", + "@codemirror/language": "6.12.2", + "@codemirror/state": "6.5.4", + "@codemirror/theme-one-dark": "6.1.3", + "@codemirror/view": "6.39.16", "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -55,6 +66,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", + "codemirror": "6.0.2", "diff": "catalog:", "effect": "4.0.0-beta.31", "fuzzysort": "catalog:", diff --git a/packages/app/src/components/code-editor.tsx b/packages/app/src/components/code-editor.tsx new file mode 100644 index 000000000000..cd05dac3780d --- /dev/null +++ b/packages/app/src/components/code-editor.tsx @@ -0,0 +1,135 @@ +import { createEffect, onCleanup, onMount } from "solid-js" +import { + EditorView, + keymap, + lineNumbers, + highlightActiveLine, + highlightActiveLineGutter, +} from "@codemirror/view" +import { Compartment, EditorState } from "@codemirror/state" +import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands" +import { + syntaxHighlighting, + defaultHighlightStyle, + bracketMatching, + foldGutter, +} from "@codemirror/language" +import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete" +import { oneDark } from "@codemirror/theme-one-dark" +import { python } from "@codemirror/lang-python" +import { javascript } from "@codemirror/lang-javascript" +import { css } from "@codemirror/lang-css" +import { json } from "@codemirror/lang-json" +import { markdown } from "@codemirror/lang-markdown" + +function getLanguageExtension(filename: string) { + const ext = filename.split(".").pop()?.toLowerCase() ?? "" + switch (ext) { + case "py": + case "pyi": + return python() + case "js": + case "mjs": + case "cjs": + return javascript() + case "ts": + case "mts": + case "cts": + return javascript({ typescript: true }) + case "tsx": + case "jsx": + return javascript({ jsx: true, typescript: ext === "tsx" }) + case "css": + case "scss": + case "sass": + return css() + case "json": + case "jsonc": + return json() + case "md": + case "mdx": + case "markdown": + return markdown() + default: + return null + } +} + +export function CodeEditor(props: { + content: string + filename: string + onChange: (value: string) => void + disabled?: boolean + wordWrap?: boolean +}) { + let container!: HTMLDivElement + let view: EditorView | undefined + const editableCompartment = new Compartment() + const wrapCompartment = new Compartment() + + onMount(() => { + const lang = getLanguageExtension(props.filename) + + const extensions = [ + lineNumbers(), + highlightActiveLine(), + highlightActiveLineGutter(), + history(), + foldGutter(), + bracketMatching(), + closeBrackets(), + syntaxHighlighting(defaultHighlightStyle), + keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap, indentWithTab]), + oneDark, + editableCompartment.of(EditorView.editable.of(!props.disabled)), + wrapCompartment.of(props.wordWrap ? EditorView.lineWrapping : []), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + props.onChange(update.state.doc.toString()) + } + }), + EditorView.theme({ + "&": { height: "100%", fontSize: "13px" }, + ".cm-scroller": { overflow: "auto", fontFamily: "var(--font-mono, monospace)" }, + ".cm-content": { padding: "8px 0" }, + }), + ] + + if (lang) extensions.push(lang) + + view = new EditorView({ + state: EditorState.create({ + doc: props.content, + extensions, + }), + parent: container, + }) + }) + + createEffect(() => { + if (!view) return + const current = view.state.doc.toString() + if (current !== props.content) { + view.dispatch({ + changes: { from: 0, to: current.length, insert: props.content }, + }) + } + }) + + createEffect(() => { + if (!view) return + view.dispatch({ effects: editableCompartment.reconfigure(EditorView.editable.of(!props.disabled)) }) + }) + + createEffect(() => { + if (!view) return + view.dispatch({ effects: wrapCompartment.reconfigure(props.wordWrap ? EditorView.lineWrapping : []) }) + }) + + onCleanup(() => { + view?.destroy() + view = undefined + }) + + return
+} diff --git a/packages/app/src/components/default-skills-panel.tsx b/packages/app/src/components/default-skills-panel.tsx index 5189bd5643c7..3850cad28f77 100644 --- a/packages/app/src/components/default-skills-panel.tsx +++ b/packages/app/src/components/default-skills-panel.tsx @@ -32,6 +32,31 @@ export const DefaultSkillsPanel: Component = () => { return ((result.data as unknown) as DefaultSkill[]) ?? [] }) + const [collapsed, setCollapsed] = createSignal(true) + const [listHeight, setListHeight] = createSignal(208) // default max-h-52 = 208px + let resizing = false + let startY = 0 + let startHeight = 0 + + const onResizeStart = (e: MouseEvent) => { + e.preventDefault() + resizing = true + startY = e.clientY + startHeight = listHeight() + const onMove = (e: MouseEvent) => { + if (!resizing) return + // Dragging up (negative delta) expands the list, dragging down shrinks it + const delta = e.clientY - startY + setListHeight(Math.max(80, Math.min(600, startHeight - delta))) + } + const onUp = () => { + resizing = false + window.removeEventListener("mousemove", onMove) + window.removeEventListener("mouseup", onUp) + } + window.addEventListener("mousemove", onMove) + window.addEventListener("mouseup", onUp) + } const [edit, setEdit] = createSignal({ mode: "none" }) const [adding, setAdding] = createSignal(false) const [deleting, setDeleting] = createSignal(null) @@ -116,7 +141,10 @@ export const DefaultSkillsPanel: Component = () => { return (
{/* Header row */} -
+
setCollapsed((c) => !c)} + >
默认 Skills @@ -124,24 +152,40 @@ export const DefaultSkillsPanel: Component = () => { ({skills()!.length})
-
- - + + + - - {adding() ? "添加中..." : "添加到项目"} - + class="text-icon-weak" + />
{/* Skill list */} + + {/* Resize handle — at the top of the list, drag up to expand */} +
+
+
加载中...
@@ -152,7 +196,7 @@ export const DefaultSkillsPanel: Component = () => {
-
+
{(skill) => (
@@ -192,6 +236,7 @@ export const DefaultSkillsPanel: Component = () => { {/* Edit / New form */}
+ {edit().mode === "new" ? "新建 Skill" : `编辑:${(edit() as { mode: "edit"; skill: DefaultSkill }).skill.name}`} @@ -228,6 +273,7 @@ export const DefaultSkillsPanel: Component = () => {
+
) } diff --git a/packages/app/src/components/dialog-default-skills.tsx b/packages/app/src/components/dialog-default-skills.tsx new file mode 100644 index 000000000000..555efe43bd53 --- /dev/null +++ b/packages/app/src/components/dialog-default-skills.tsx @@ -0,0 +1,224 @@ +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { showToast } from "@opencode-ai/ui/toast" +import { TextField } from "@opencode-ai/ui/text-field" +import { Dialog } from "@opencode-ai/ui/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { + Component, + For, + Match, + Show, + Switch, + createResource, + createSignal, +} from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobalSDK } from "@/context/global-sdk" +import { useSDK } from "@/context/sdk" + +type DefaultSkill = { name: string; description: string; content: string } +type EditState = { mode: "none" } | { mode: "edit"; skill: DefaultSkill } | { mode: "new" } + +export const DialogDefaultSkills: Component = () => { + const globalSDK = useGlobalSDK() + const sdk = useSDK() + const dialog = useDialog() + + const [skills, { refetch }] = createResource(async () => { + const result = await globalSDK.client.config.skillsList() + return ((result.data as unknown) as DefaultSkill[]) ?? [] + }) + + const [edit, setEdit] = createSignal({ mode: "none" }) + const [adding, setAdding] = createSignal(false) + const [deleting, setDeleting] = createSignal(null) + const [saving, setSaving] = createSignal(false) + const [form, setForm] = createStore({ name: "", description: "", content: "" }) + + function openNew() { + setForm({ name: "", description: "", content: "" }) + setEdit({ mode: "new" }) + } + + function openEdit(skill: DefaultSkill) { + setForm({ name: skill.name, description: skill.description, content: skill.content }) + setEdit({ mode: "edit", skill }) + } + + function cancelEdit() { + setEdit({ mode: "none" }) + } + + async function handleSave() { + if (!form.name.trim()) { + showToast({ variant: "error", icon: "circle-x", title: "名称不能为空" }) + return + } + setSaving(true) + try { + await globalSDK.client.config.skillsSave({ + name: form.name.trim(), + description: form.description.trim(), + content: form.content, + }) + await refetch() + setEdit({ mode: "none" }) + showToast({ variant: "success", icon: "circle-check", title: "Skill 已保存" }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ variant: "error", icon: "circle-x", title: "保存失败", description: message }) + } finally { + setSaving(false) + } + } + + async function handleDelete(name: string) { + setDeleting(name) + try { + await globalSDK.client.config.skillsDelete({ name }) + await refetch() + showToast({ icon: "check", title: `已删除 ${name}` }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ variant: "error", icon: "circle-x", title: "删除失败", description: message }) + } finally { + setDeleting(null) + } + } + + async function handleAddToProject() { + setAdding(true) + try { + const result = await globalSDK.client.config.skillsDefaults({ directory: sdk.directory }) + const added = ((result.data as unknown) as { added: string[] }).added + if (added.length > 0) { + showToast({ + variant: "success", + icon: "circle-check", + title: "Skills 已复制到项目", + description: `已复制:${added.join(", ")}`, + }) + } else { + showToast({ icon: "check", title: "已是最新", description: "默认 Skills 已全部存在于项目中" }) + } + dialog.close() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ variant: "error", icon: "circle-x", title: "添加失败", description: message }) + } finally { + setAdding(false) + } + } + + return ( + + + +
+ } + > +
+ {/* Skill list */} + + +
加载中...
+
+ +
+ 暂无默认 Skills,点击「新建」创建第一个 +
+
+ +
+ + {(skill) => ( +
+
+ {skill.name} + + {skill.description} + + + 暂无描述 + +
+
+ openEdit(skill)} + /> + handleDelete(skill.name)} + /> +
+
+ )} +
+
+
+
+ + {/* Edit / New form */} + +
+ + {edit().mode === "new" + ? "新建 Skill" + : `编辑:${(edit() as { mode: "edit"; skill: DefaultSkill }).skill.name}`} + + + setForm("name", v)} + /> + + setForm("description", v)} + /> + setForm("content", v)} + spellcheck={false} + class="font-mono text-xs min-h-24 max-h-48 overflow-y-auto" + /> +
+ + +
+
+
+
+ + ) +} diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 088726c3d559..005bf35f04cd 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -6,7 +6,8 @@ import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" import { Mark } from "@opencode-ai/ui/logo" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { DefaultSkillsPanel } from "@/components/default-skills-panel" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogDefaultSkills } from "@/components/dialog-default-skills" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" @@ -21,6 +22,7 @@ export function NewSessionView(props: NewSessionViewProps) { const sync = useSync() const sdk = useSDK() const language = useLanguage() + const dialog = useDialog() const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) @@ -107,8 +109,16 @@ export function NewSessionView(props: NewSessionViewProps) {
)} -
- +
+
) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 9e231e2d2858..db1a7d364730 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,5 +1,14 @@ @import "@opencode-ai/ui/styles/tailwind"; +/* Word wrap for file viewer */ +.file-word-wrap .cm-line { + white-space: pre-wrap !important; + word-break: break-word !important; +} +.file-word-wrap .cm-scroller { + overflow-x: hidden !important; +} + @layer components { [data-component="getting-started"] { container-type: inline-size; diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 4b322368fcad..f8848d1396d0 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import type { FileSearchHandle } from "@opencode-ai/ui/file" @@ -11,6 +11,9 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { showToast } from "@opencode-ai/ui/toast" +import { Markdown } from "@opencode-ai/ui/markdown" +import { CodeEditor } from "@/components/code-editor" +import { useSDK } from "@/context/sdk" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" @@ -57,6 +60,42 @@ export function FileTabContent(props: { tab: string }) { const language = useLanguage() const prompt = usePrompt() const fileComponent = useFileComponent() + const sdk = useSDK() + + const [isEditing, setIsEditing] = createSignal(false) + const [editContent, setEditContent] = createSignal("") + const [isSaving, setIsSaving] = createSignal(false) + const [wordWrap, setWordWrap] = createSignal(false) + + const startEditing = () => { + setEditContent(contents()) + setIsEditing(true) + } + + const cancelEditing = () => { + setIsEditing(false) + setEditContent("") + } + + const saveEditing = async () => { + const p = path() + if (!p) return + setIsSaving(true) + try { + await sdk.client.file.write({ path: p, content: editContent() }) + setIsEditing(false) + setEditContent("") + } catch (e) { + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + description: String(e), + }) + } finally { + setIsSaving(false) + } + } + const { sessionKey, tabs, view } = useSessionLayout() let scroll: HTMLDivElement | undefined @@ -73,6 +112,13 @@ export function FileTabContent(props: { tab: string }) { } const path = createMemo(() => file.pathFromTab(props.tab)) + + const isMarkdown = createMemo(() => { + const p = path() + if (!p) return false + const ext = p.split(".").pop()?.toLowerCase() ?? "" + return ext === "md" || ext === "mdx" || ext === "markdown" + }) const state = createMemo(() => { const p = path() if (!p) return @@ -392,54 +438,71 @@ export function FileTabContent(props: { tab: string }) { if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) }) - const renderFile = (source: string) => ( -
- { - queueRestore() - }} - annotations={commentsUi.annotations()} - renderAnnotation={commentsUi.renderAnnotation} - renderHoverUtility={commentsUi.renderHoverUtility} - onLineSelected={(range: SelectedLineRange | null) => { - commentsUi.onLineSelected(range) - }} - onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd} - onLineSelectionEnd={(range: SelectedLineRange | null) => { - commentsUi.onLineSelectionEnd(range) - }} - search={search} - overflow="scroll" - class="select-text" - media={{ - mode: "auto", - path: path(), - current: state()?.content, - onLoad: queueRestore, - onError: (args: { kind: "image" | "audio" | "svg" }) => { - if (args.kind !== "svg") return - showToast({ - variant: "error", - title: language.t("toast.file.loadFailed.title"), - }) - }, - }} - /> -
- ) + const renderFile = (source: string) => { + if (isMarkdown()) { + return ( +
+ +
+ ) + } + if (wordWrap()) { + return ( +
+
{source}
+
+ ) + } + return ( +
+ { + queueRestore() + }} + annotations={commentsUi.annotations()} + renderAnnotation={commentsUi.renderAnnotation} + renderHoverUtility={commentsUi.renderHoverUtility} + onLineSelected={(range: SelectedLineRange | null) => { + commentsUi.onLineSelected(range) + }} + onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + commentsUi.onLineSelectionEnd(range) + }} + search={search} + overflow="scroll" + class="select-text" + media={{ + mode: "auto", + path: path(), + current: state()?.content, + onLoad: queueRestore, + onError: (args: { kind: "image" | "audio" | "svg" }) => { + if (args.kind !== "svg") return + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + }) + }, + }} + /> +
+ ) + } return ( +<<<<<<< HEAD + +
+ + setWordWrap((w) => !w)} + /> + + + } + > + + + +
+
+ { + scroll = el + restoreScroll() + }} + onScroll={handleScroll as any} + > + + {renderFile(contents())} + +
{language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+
+
+ } +>>>>>>> 691fb184f (新增目录摘要导航功能,新增换行切换按钮) > - - {renderFile(contents())} - -
{language.t("common.loading")}...
-
- {(err) =>
{err()}
}
-
- + +
) } diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 1cbd05098eff..249cbf55aeb8 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { Tabs } from "@opencode-ai/ui/tabs" @@ -78,6 +78,27 @@ export function SessionSidePanel(props: { file.tree.refresh("") } + const [isSummarizing, setIsSummarizing] = createSignal(false) + + async function handleSummarize() { + if (isSummarizing()) return + setIsSummarizing(true) + try { + const result = await sdk.client.file.summarize() + const data = result.data as { count: number } | undefined + const count = data?.count ?? 0 + showToast({ + variant: "success", + title: `已生成 ${count} 个目录摘要`, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ variant: "error", icon: "circle-x", title: "生成摘要失败", description: message }) + } finally { + setIsSummarizing(false) + } + } + const { params, sessionKey, tabs, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") @@ -489,6 +510,17 @@ export function SessionSidePanel(props: { 新建文件夹 + + +
@@ -67,6 +72,9 @@ export const DialogSettings: Component = () => { + + + ) diff --git a/packages/app/src/components/knowledge-button.tsx b/packages/app/src/components/knowledge-button.tsx new file mode 100644 index 000000000000..ab13cddc35bf --- /dev/null +++ b/packages/app/src/components/knowledge-button.tsx @@ -0,0 +1,54 @@ +import { Component, Show, createSignal } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { KnowledgeDialog } from "./knowledge-dialog" +import { useKnowledge } from "@/context/knowledge" +import { useLanguage } from "@/context/language" + +export const KnowledgeButton: Component = () => { + const knowledge = useKnowledge() + const dialog = useDialog() + const language = useLanguage() + + const handleClick = () => { + dialog.show(() => ) + } + + return ( + + {language.t("knowledge.title")} + + + {knowledge.state.documentCount} {language.t("knowledge.documents")} + + + + } + > + + + ) +} \ No newline at end of file diff --git a/packages/app/src/components/knowledge-dialog.tsx b/packages/app/src/components/knowledge-dialog.tsx new file mode 100644 index 000000000000..2ac0a4c8d1d9 --- /dev/null +++ b/packages/app/src/components/knowledge-dialog.tsx @@ -0,0 +1,445 @@ +import { Component, Show, createMemo, createSignal, For } from "solid-js" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Select } from "@opencode-ai/ui/select" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useKnowledge, type KnowledgeConfig } from "@/context/knowledge" +import { DialogSelectDirectory } from "./dialog-select-directory" + +export const KnowledgeDialog: Component = () => { + const knowledge = useKnowledge() + const dialog = useDialog() + + // UI 状态 + const [syncing, setSyncing] = createSignal(false) + const [error, setError] = createSignal("") + const [success, setSuccess] = createSignal("") + const [showAddForm, setShowAddForm] = createSignal(false) + + // 新知识库配置 + const [newPath, setNewPath] = createSignal("") + const [newName, setNewName] = createSignal("My Knowledge Base") + const [newProvider, setNewProvider] = createSignal<"openai" | "local" | "custom">("openai") + const [newModel, setNewModel] = createSignal("text-embedding-3-small") + const [newApiKey, setNewApiKey] = createSignal("") + const [newBaseURL, setNewBaseURL] = createSignal("") + const [newDimensions, setNewDimensions] = createSignal(1536) + + const models = knowledge.models() + + const providerModels = createMemo(() => { + const p = newProvider() + return models.filter(m => m.provider === p) + }) + + const handleSelectFolder = () => { + dialog.show(() => ( + { + if (result && typeof result === "string") { + const parts = result.split("/") + const name = parts[parts.length - 1] || "Knowledge Base" + setNewPath(result) + setNewName(name) + } + }} + /> + )) + } + + const handleProviderChange = (p: string | undefined) => { + if (!p) return + const provider = p as "openai" | "local" | "custom" + const firstModel = models.find(m => m.provider === provider) + setNewProvider(provider) + setNewModel(provider === "custom" ? "" : (firstModel?.id ?? "")) + setNewDimensions(firstModel?.dimensions ?? 1536) + } + + const handleAddKnowledgeBase = async () => { + setError("") + setSuccess("") + + if (!newPath()) { + setError("Please select a folder") + return + } + + if (newProvider() === "openai" && !newApiKey()) { + setError("Please enter your OpenAI API key") + return + } + + if (newProvider() === "custom") { + if (!newModel()) { + setError("Please enter the model name") + return + } + if (!newBaseURL()) { + setError("Please enter the API URL") + return + } + if (!newApiKey()) { + setError("Please enter the API Key") + return + } + } + + setSyncing(true) + try { + // 添加知识库配置 + const id = knowledge.addKnowledgeBase({ + path: newPath(), + name: newName(), + embeddingProvider: newProvider(), + embeddingModel: newModel(), + embeddingDimensions: newDimensions(), + apiKey: newApiKey(), + baseURL: newBaseURL(), + chunkSize: 500, + chunkOverlap: 50, + }) + + // 激活新添加的知识库 + knowledge.setActive(id) + + // 创建知识库 + const kb = knowledge.knowledgeBases().find(k => k.id === id) + if (kb) { + let index = await knowledge.loadKnowledgeBase(kb.path) + if (!index) { + index = await knowledge.createKnowledgeBase(kb) + } + } + + // 同步 + const result = await knowledge.syncKnowledgeBase(id) + + if (result.errors && result.errors.length > 0) { + setError(`Synced with ${result.errors.length} errors: ${result.errors.slice(0, 3).join(", ")}`) + } else { + setSuccess(`Added ${result.added} documents, updated ${result.updated}`) + } + + // 重置表单 + setShowAddForm(false) + setNewPath("") + setNewName("My Knowledge Base") + setNewProvider("openai") + setNewModel("text-embedding-3-small") + setNewApiKey("") + setNewBaseURL("") + setNewDimensions(1536) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + setError(message) + console.error("Sync error:", e) + } finally { + setSyncing(false) + } + } + + const handleSync = async (id: string) => { + setError("") + setSuccess("") + setSyncing(true) + try { + const result = await knowledge.syncKnowledgeBase(id) + if (result.errors && result.errors.length > 0) { + setError(`Synced with ${result.errors.length} errors: ${result.errors.slice(0, 3).join(", ")}`) + } else { + setSuccess(`Added ${result.added} documents, updated ${result.updated}`) + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + setError(message) + } finally { + setSyncing(false) + } + } + + const handleRemove = async (id: string) => { + if (confirm("Are you sure you want to remove this knowledge base?")) { + setError("") + setSuccess("") + await knowledge.removeKnowledgeBase(id) + } + } + + const handleSelect = (id: string) => { + knowledge.setActive(id) + // 更新后端配置 + const kb = knowledge.knowledgeBases().find(k => k.id === id) + if (kb) { + knowledge.loadKnowledgeBase(kb.path) + } + } + + return ( + +
+ {/* 知识库列表 */} + 0}> +
+ +
+ + {(kb) => { + const isActive = createMemo(() => knowledge.state.activeId === kb.id) + return ( +
handleSelect(kb.id)} + > +
+ + + +
+
+
{kb.name}
+
{kb.path}
+
+
+ {kb.documentCount ?? 0} docs + · + {kb.chunkCount ?? 0} chunks +
+
+ + +
+
+ ) + }} +
+
+
+
+ + {/* 添加新知识库按钮 */} + + + + + {/* 添加新知识库表单 */} + +
+
+ New Knowledge Base + +
+ + {/* 文件夹路径 */} +
+ +
+ setNewPath(e.currentTarget.value)} + placeholder="/path/to/your/papers" + class="h-9 flex-1 rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> + +
+
+ + {/* 名称 */} +
+ + setNewName(e.currentTarget.value)} + placeholder="My Knowledge Base" + class="h-9 w-full rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+ + {/* 嵌入提供商 */} +
+ + m.id)} + current={newModel()} + value={(id) => id} + label={(id) => models.find(m => m.id === id)?.name ?? id} + onSelect={(id) => id && setNewModel(id)} + variant="secondary" + size="small" + class="w-full" + /> +
+ + + {/* OpenAI API Key */} + +
+ + setNewApiKey(e.currentTarget.value)} + placeholder="sk-..." + class="h-9 w-full rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+
+ + {/* Custom Provider 配置 */} + +
+
Custom Embedding Configuration
+ +
+ + setNewBaseURL(e.currentTarget.value)} + placeholder="https://generativelanguage.googleapis.com/v1beta/openai" + class="h-9 w-full rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+ +
+ + setNewApiKey(e.currentTarget.value)} + placeholder="your-api-key" + class="h-9 w-full rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+ +
+ + setNewModel(e.currentTarget.value)} + placeholder="text-embedding-3-small" + class="h-9 w-full rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+
+
+ + {/* 添加按钮 */} + +
+
+ + {/* 同步进度 */} + + {(progress) => ( +
+
+ Processing documents... + {progress().current} / {progress().total} +
+
+
+
+
+ )} + + + {/* 错误显示 */} + +
+ + {error()} +
+
+ + {/* 成功显示 */} + +
+ + {success()} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f1a33e75f344..8f74d4ff0b94 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -55,6 +55,7 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { KnowledgeButton } from "@/components/knowledge-button" interface PromptInputProps { class?: string @@ -1479,6 +1480,16 @@ export const PromptInput: Component = (props) => { variant="ghost" /> +
0.5 ? "auto" : "none", + }} + > + +
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index fee6b070d948..ec8dc3606c65 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -12,6 +12,7 @@ import { usePermission } from "@/context/permission" import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { useKnowledge } from "@/context/knowledge" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import { buildRequestParts } from "./build-request-parts" @@ -64,6 +65,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const layout = useLayout() const language = useLanguage() const params = useParams() + const knowledge = useKnowledge() const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { @@ -403,6 +405,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { messageID, parts: requestParts, variant, + knowledgeBase: + knowledge.state.enabled && knowledge.state.config + ? { + path: knowledge.state.config.path, + apiKey: knowledge.state.config.apiKey, + baseURL: knowledge.state.config.baseURL, + } + : undefined, }) } diff --git a/packages/app/src/components/settings-knowledge.tsx b/packages/app/src/components/settings-knowledge.tsx new file mode 100644 index 000000000000..bbb7f876178e --- /dev/null +++ b/packages/app/src/components/settings-knowledge.tsx @@ -0,0 +1,428 @@ +import { Component, Show, For, createMemo, createSignal } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Select } from "@opencode-ai/ui/select" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useKnowledge } from "@/context/knowledge" +import { DialogSelectDirectory } from "./dialog-select-directory" + +export const SettingsKnowledge: Component = () => { + const knowledge = useKnowledge() + const dialog = useDialog() + + const [syncing, setSyncing] = createSignal(null) + const [error, setError] = createSignal("") + const [success, setSuccess] = createSignal("") + const [showAddForm, setShowAddForm] = createSignal(false) + + const [newPath, setNewPath] = createSignal("") + const [newName, setNewName] = createSignal("My Knowledge Base") + const [newProvider, setNewProvider] = createSignal<"openai" | "local" | "custom">("openai") + const [newModel, setNewModel] = createSignal("text-embedding-3-small") + const [newApiKey, setNewApiKey] = createSignal("") + const [newBaseURL, setNewBaseURL] = createSignal("") + // 不再硬编码 dimensions,让后端自动检测 + // const [newDimensions, setNewDimensions] = createSignal(1536) + const [newChunkSize, setNewChunkSize] = createSignal(500) + const [newChunkOverlap, setNewChunkOverlap] = createSignal(50) + + const models = knowledge.models() + + const providerModels = createMemo(() => { + const p = newProvider() + return models.filter((m) => m.provider === p) + }) + + const handleSelectFolder = () => { + dialog.show(() => ( + { + if (result && typeof result === "string") { + const parts = result.split("/") + const name = parts[parts.length - 1] || "Knowledge Base" + setNewPath(result) + setNewName(name) + } + }} + /> + )) + } + + const handleProviderChange = (p: string | undefined) => { + if (!p) return + const provider = p as "openai" | "local" | "custom" + const firstModel = models.find((m) => m.provider === provider) + setNewProvider(provider) + setNewModel(provider === "custom" ? "" : (firstModel?.id ?? "")) + // 不再设置 dimensions,让后端自动检测 + } + + const handleAddKnowledgeBase = async () => { + setError("") + setSuccess("") + + if (!newPath()) { + setError("Please select a folder") + return + } + + if (newProvider() === "openai" && !newApiKey()) { + setError("Please enter your OpenAI API key") + return + } + + if (newProvider() === "custom") { + if (!newModel()) { + setError("Please enter the model name") + return + } + if (!newBaseURL()) { + setError("Please enter the API URL") + return + } + if (!newApiKey()) { + setError("Please enter the API Key") + return + } + } + + setSyncing("new") + try { + const id = knowledge.addKnowledgeBase({ + path: newPath(), + name: newName(), + embeddingProvider: newProvider(), + embeddingModel: newModel(), + // 不再传递 dimensions,让后端自动检测 + apiKey: newApiKey(), + baseURL: newBaseURL(), + chunkSize: newChunkSize(), + chunkOverlap: newChunkOverlap(), + }) + + knowledge.setActive(id) + + const kb = knowledge.knowledgeBases().find(k => k.id === id) + if (kb) { + let index = await knowledge.loadKnowledgeBase(kb.path) + if (!index) { + index = await knowledge.createKnowledgeBase(kb) + } + } + + const result = await knowledge.syncKnowledgeBase(id) + + if (result.errors && result.errors.length > 0) { + setError(`Synced with ${result.errors.length} errors: ${result.errors.slice(0, 3).join(", ")}`) + } else { + setSuccess(`Added ${result.added} documents, updated ${result.updated}`) + } + + setShowAddForm(false) + setNewPath("") + setNewName("My Knowledge Base") + setNewProvider("openai") + setNewModel("text-embedding-3-small") + setNewApiKey("") + setNewBaseURL("") + // 不再需要重置 dimensions + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + setError(message) + } finally { + setSyncing(null) + } + } + + const handleSync = async (id: string) => { + setError("") + setSuccess("") + setSyncing(id) + try { + const result = await knowledge.syncKnowledgeBase(id) + if (result.errors && result.errors.length > 0) { + setError(`Synced with ${result.errors.length} errors: ${result.errors.slice(0, 3).join(", ")}`) + } else { + setSuccess(`Added ${result.added} documents, updated ${result.updated}`) + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + setError(message) + } finally { + setSyncing(null) + } + } + + const handleRemove = async (id: string) => { + if (confirm("Are you sure you want to remove this knowledge base?")) { + setError("") + setSuccess("") + await knowledge.removeKnowledgeBase(id) + } + } + + const handleSelect = (id: string) => { + knowledge.setActive(id) + const kb = knowledge.knowledgeBases().find(k => k.id === id) + if (kb) { + knowledge.loadKnowledgeBase(kb.path) + } + } + + return ( +
+
+
+

Knowledge Base

+

+ Configure local folders as knowledge bases for RAG-powered context in conversations. +

+
+
+ +
+ 0}> +
+

Knowledge Bases

+
+ + {(kb) => { + const isActive = createMemo(() => knowledge.state.activeId === kb.id) + const isSyncing = createMemo(() => syncing() === kb.id) + return ( +
handleSelect(kb.id)} + > +
+ + + +
+
+
{kb.name}
+
{kb.path}
+
+
+ {kb.documentCount ?? 0} docs + · + {kb.chunkCount ?? 0} chunks +
+
+ + +
+
+ ) + }} +
+
+
+
+ + + + + + +
+
+

New Knowledge Base

+ +
+ +
+
+
+ Folder Path + Local folder containing PDF files +
+
+ setNewPath(e.currentTarget.value)} + placeholder="/path/to/your/papers" + class="h-9 flex-1 min-w-0 rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> + +
+
+ +
+
+ Name +
+ setNewName(e.currentTarget.value)} + placeholder="My Knowledge Base" + class="h-9 flex-1 max-w-xs rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+ +
+
+ Embedding Provider +
+ m.id)} + current={newModel()} + value={(id) => id} + label={(id) => models.find((m) => m.id === id)?.name ?? id} + onSelect={(id) => id && setNewModel(id)} + variant="secondary" + size="small" + class="w-64" + /> +
+ + + +
+
+ OpenAI API Key +
+ setNewApiKey(e.currentTarget.value)} + placeholder="sk-..." + class="h-9 flex-1 max-w-xs rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+
+ + +
+
+ API URL +
+ setNewBaseURL(e.currentTarget.value)} + placeholder="https://api.example.com/v1" + class="h-9 flex-1 max-w-xs rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+
+
+ API Key +
+ setNewApiKey(e.currentTarget.value)} + placeholder="your-api-key" + class="h-9 flex-1 max-w-xs rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+
+
+ Model Name +
+ setNewModel(e.currentTarget.value)} + placeholder="text-embedding-3-small" + class="h-9 flex-1 max-w-xs rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong placeholder:text-text-weak focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+
+ +
+
+ Chunk Size +
+ setNewChunkSize(parseInt(e.currentTarget.value) || 500)} + class="h-9 w-32 rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+ +
+
+ Chunk Overlap +
+ setNewChunkOverlap(parseInt(e.currentTarget.value) || 50)} + class="h-9 w-32 rounded-md border border-border-base bg-surface-base px-3 text-14-regular text-text-strong focus:outline-none focus:ring-2 focus:ring-border-focus" + /> +
+
+ + +
+
+ + +
+ + {error()} +
+
+ + +
+ + {success()} +
+
+
+
+ ) +} diff --git a/packages/app/src/context/knowledge.tsx b/packages/app/src/context/knowledge.tsx new file mode 100644 index 000000000000..d2f03d784c59 --- /dev/null +++ b/packages/app/src/context/knowledge.tsx @@ -0,0 +1,471 @@ +import { createContext, useContext, createSignal, Component, JSX, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { Persist, persisted } from "@/utils/persist" +import { useGlobalSDK } from "./global-sdk" + +// 知识库配置 +export interface KnowledgeConfig { + id: string + path: string + name: string + embeddingProvider: "openai" | "local" | "custom" + embeddingModel: string + embeddingDimensions?: number + apiKey?: string + baseURL?: string + chunkSize: number + chunkOverlap: number + // 统计信息 + documentCount?: number + chunkCount?: number + syncedAt?: number +} + +// 知识库状态 +export interface KnowledgeState { + knowledgeBases: KnowledgeConfig[] + activeId: string | null +} + +// 知识库搜索结果 +export interface KnowledgeSearchResult { + chunk: { + id: string + documentId: string + index: number + content: string + pageNumber?: number + } + document: { + id: string + filePath: string + fileName: string + fileSize: number + pageCount: number + status: string + } + score: number +} + +// 嵌入模型信息 +export interface EmbeddingModel { + id: string + name: string + provider: "openai" | "local" | "custom" + dimensions: number + description: string +} + +const DEFAULT_STATE: KnowledgeState = { + knowledgeBases: [], + activeId: null, +} + +interface KnowledgeContextValue { + state: KnowledgeState + models: () => EmbeddingModel[] + // 当前激活的知识库 + activeKnowledgeBase: () => KnowledgeConfig | null + // 所有知识库 + knowledgeBases: () => KnowledgeConfig[] + // 激活状态 + enabled: () => boolean + // 同步进度 + syncProgress: () => { current: number; total: number } | null + // 设置激活的知识库 + setActive: (id: string | null) => void + // 添加知识库 + addKnowledgeBase: (config: Omit) => string + // 更新知识库 + updateKnowledgeBase: (id: string, config: Partial) => void + // 删除知识库 + removeKnowledgeBase: (id: string) => Promise + // 加载知识库 + loadKnowledgeBase: (path: string) => Promise + // 创建知识库 + createKnowledgeBase: (config: KnowledgeConfig) => Promise + // 同步知识库 + syncKnowledgeBase: (id?: string) => Promise<{ added: number; updated: number; removed: number; errors?: string[] }> + // 停止同步 + stopSync: () => void + // 搜索 + search: (query: string, topK?: number) => Promise + // 构建 RAG 上下文 + buildRAGContext: (results: KnowledgeSearchResult[], maxLength?: number) => string + // 刷新所有知识库统计信息 + refreshAllStats: () => Promise +} + +const KnowledgeContext = createContext() + +export function useKnowledge() { + const ctx = useContext(KnowledgeContext) + if (!ctx) { + throw new Error("useKnowledge must be used within a KnowledgeProvider") + } + return ctx +} + +function generateId(): string { + return `kb_${Date.now()}_${Math.random().toString(36).slice(2, 9)}` +} + +export const KnowledgeProvider: Component<{ children: JSX.Element }> = (props) => { + const sdk = useGlobalSDK() + + const [state, setState] = persisted( + Persist.global("knowledge-state"), + createStore(DEFAULT_STATE), + ) + + const [models] = createSignal([ + { + id: "text-embedding-3-small", + name: "OpenAI text-embedding-3-small", + provider: "openai", + dimensions: 1536, + description: "OpenAI 的轻量级嵌入模型,性价比高", + }, + { + id: "text-embedding-3-large", + name: "OpenAI text-embedding-3-large", + provider: "openai", + dimensions: 3072, + description: "OpenAI 的高性能嵌入模型", + }, + { + id: "all-MiniLM-L6-v2", + name: "all-MiniLM-L6-v2 (本地)", + provider: "local", + dimensions: 384, + description: "轻量级本地嵌入模型,无需 API", + }, + ]) + + const [syncProgress, setSyncProgress] = createSignal<{ current: number; total: number } | null>(null) + let syncAbortController: AbortController | null = null + + const stopSync = () => { + syncAbortController?.abort() + syncAbortController = null + } + + // 当前激活的知识库 + const activeKnowledgeBase = () => { + const id = state.activeId + if (!id) return null + return state.knowledgeBases.find(kb => kb.id === id) ?? null + } + + // 所有知识库 + const knowledgeBases = () => state.knowledgeBases + + // 是否启用(有激活的知识库) + const enabled = () => state.activeId !== null && state.knowledgeBases.length > 0 + + // 设置激活的知识库 + const setActive = (id: string | null) => { + setState("activeId", id) + } + + // 添加知识库 + const addKnowledgeBase = (config: Omit): string => { + const id = generateId() + const newKb: KnowledgeConfig = { ...config, id } + setState("knowledgeBases", [...state.knowledgeBases, newKb]) + // 如果是第一个知识库,自动激活 + if (state.knowledgeBases.length === 0) { + setState("activeId", id) + } + return id + } + + // 更新知识库 + const updateKnowledgeBase = (id: string, config: Partial) => { + const index = state.knowledgeBases.findIndex(kb => kb.id === id) + if (index === -1) return + setState("knowledgeBases", index, { ...state.knowledgeBases[index], ...config }) + } + + // API 请求辅助函数 + const fetchApi = async (path: string, options: RequestInit = {}) => { + const baseUrl = sdk.url + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + } + + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers, + }) + + return response + } + + // 刷新所有知识库统计信息 + const refreshAllStats = async () => { + for (const kb of state.knowledgeBases) { + try { + const encodedPath = encodeURIComponent(kb.path) + const response = await fetchApi(`/knowledge/${encodedPath}/stats`) + if (response.ok) { + const stats = await response.json() + updateKnowledgeBase(kb.id, { + documentCount: stats.totalDocuments, + chunkCount: stats.totalChunks, + syncedAt: stats.lastSyncedAt, + }) + } + } catch (e) { + console.error(`Failed to refresh stats for ${kb.name}:`, e) + } + } + } + + // 组件挂载时刷新统计信息 + onMount(() => { + if (state.knowledgeBases.length > 0) { + refreshAllStats() + } + }) + + const loadKnowledgeBase = async (path: string) => { + const encodedPath = encodeURIComponent(path) + const response = await fetchApi(`/knowledge/${encodedPath}`, { + method: "GET", + }) + + if (!response.ok) { + return null + } + + return response.json() + } + + const createKnowledgeBase = async (cfg: KnowledgeConfig) => { + const response = await fetchApi("/knowledge", { + method: "POST", + body: JSON.stringify({ + path: cfg.path, + name: cfg.name, + embeddingProvider: cfg.embeddingProvider, + embeddingModel: cfg.embeddingModel, + embeddingDimensions: cfg.embeddingDimensions, + apiKey: cfg.apiKey, + baseURL: cfg.baseURL, + chunkSize: cfg.chunkSize, + chunkOverlap: cfg.chunkOverlap, + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Failed to create knowledge base: ${error}`) + } + + return response.json() + } + + const syncKnowledgeBase = async (id?: string) => { + const targetId = id ?? state.activeId + if (!targetId) { + throw new Error("No knowledge base selected") + } + + const kb = state.knowledgeBases.find(k => k.id === targetId) + if (!kb) { + throw new Error("Knowledge base not found") + } + + setSyncProgress(null) + const encodedPath = encodeURIComponent(kb.path) + const baseUrl = sdk.url + + // 先设置全局配置,让 knowledge_search 工具知道知识库路径 + await fetchApi("/knowledge/config", { + method: "POST", + body: JSON.stringify({ + path: kb.path, + apiKey: kb.apiKey, + baseURL: kb.baseURL, + }), + }) + + syncAbortController = new AbortController() + const timeoutSignal = AbortSignal.timeout(600000) + const combinedSignal = AbortSignal.any + ? AbortSignal.any([syncAbortController.signal, timeoutSignal]) + : syncAbortController.signal + + const response = await fetch(`${baseUrl}/knowledge/${encodedPath}/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: kb.apiKey, + baseURL: kb.baseURL, + }), + signal: combinedSignal, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Failed to sync knowledge base: ${error}`) + } + + let finalResult: { added: number; updated: number; removed: number; errors?: string[] } = { + added: 0, + updated: 0, + removed: 0, + } + + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + const parts = buffer.split("\n\n") + buffer = parts.pop() ?? "" + + for (const block of parts) { + let event = "message" + let data = "" + for (const line of block.split("\n")) { + if (line.startsWith("event: ")) event = line.slice(7).trim() + else if (line.startsWith("data: ")) data = line.slice(6).trim() + } + if (!data) continue + + if (event === "progress") { + const status = JSON.parse(data) + setSyncProgress({ current: status.current, total: status.total }) + } else if (event === "complete") { + finalResult = JSON.parse(data) + // 更新统计信息 + const statsResponse = await fetchApi(`/knowledge/${encodedPath}/stats`) + if (statsResponse.ok) { + const stats = await statsResponse.json() + updateKnowledgeBase(targetId, { + documentCount: stats.totalDocuments, + chunkCount: stats.totalChunks, + syncedAt: Date.now(), + }) + } + } else if (event === "error") { + const errData = JSON.parse(data) + throw new Error(errData.message) + } + } + } + } finally { + setSyncProgress(null) + syncAbortController = null + reader.releaseLock() + } + + return finalResult + } + + const search = async (query: string, topK: number = 5): Promise => { + const kb = activeKnowledgeBase() + if (!kb) { + return [] + } + + const encodedPath = encodeURIComponent(kb.path) + const response = await fetchApi(`/knowledge/${encodedPath}/search`, { + method: "POST", + body: JSON.stringify({ + query, + topK, + apiKey: kb.apiKey, + baseURL: kb.baseURL, + }), + }) + + if (!response.ok) { + console.error("Knowledge search failed:", await response.text()) + return [] + } + + return response.json() + } + + const removeKnowledgeBase = async (id?: string) => { + const targetId = id ?? state.activeId + if (!targetId) return + + const kb = state.knowledgeBases.find(k => k.id === targetId) + if (!kb) return + + const encodedPath = encodeURIComponent(kb.path) + const response = await fetchApi(`/knowledge/${encodedPath}`, { + method: "DELETE", + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Failed to remove knowledge base: ${error}`) + } + + // 从列表中移除 + const newIndex = state.knowledgeBases.filter(k => k.id !== targetId) + setState("knowledgeBases", newIndex) + + // 如果删除的是当前激活的,切换到第一个或设为 null + if (state.activeId === targetId) { + setState("activeId", newIndex.length > 0 ? newIndex[0]!.id : null) + } + } + + const buildRAGContext = (results: KnowledgeSearchResult[], maxLength: number = 4000): string => { + if (results.length === 0) return "" + + const parts: string[] = [] + let totalLength = 0 + + for (const result of results) { + const header = `[文档: ${result.document.fileName}]` + const content = result.chunk.content + const part = `${header}\n${content}\n` + + if (totalLength + part.length > maxLength) break + + parts.push(part) + totalLength += part.length + } + + return parts.join("\n---\n\n") + } + + const value: KnowledgeContextValue = { + state, + models, + activeKnowledgeBase, + knowledgeBases, + enabled, + syncProgress, + setActive, + addKnowledgeBase, + updateKnowledgeBase, + removeKnowledgeBase, + loadKnowledgeBase, + createKnowledgeBase, + syncKnowledgeBase, + stopSync, + search, + buildRAGContext, + refreshAllStats, + } + + return ( + + {props.children} + + ) +} \ No newline at end of file diff --git a/packages/opencode/package.json b/packages/opencode/package.json index b43658ab1d08..0c2040c06c74 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -45,8 +45,8 @@ "@types/mime-types": "3.0.1", "@types/semver": "^7.5.8", "@types/turndown": "5.0.5", - "@types/yargs": "17.0.33", "@types/which": "3.0.4", + "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", "drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.16-ea816b6", @@ -124,6 +124,7 @@ "open": "10.1.2", "opentui-spinner": "0.0.6", "partial-json": "0.1.7", + "pdf-lib": "1.17.1", "remeda": "catalog:", "semver": "^7.6.3", "solid-js": "catalog:", @@ -131,6 +132,7 @@ "tree-sitter-bash": "0.25.0", "turndown": "7.2.0", "ulid": "catalog:", + "unpdf": "1.4.0", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "which": "6.0.1", diff --git a/packages/opencode/src/knowledge/document.ts b/packages/opencode/src/knowledge/document.ts new file mode 100644 index 000000000000..9507f8779562 --- /dev/null +++ b/packages/opencode/src/knowledge/document.ts @@ -0,0 +1,188 @@ +import type { ChunkMeta } from "./types" +import { Storage } from "./storage" +import { extractText, getDocumentProxy } from "unpdf" + +// PDF 解析结果 +export interface ParsedDocument { + text: string + pages: { pageNumber: number; text: string }[] + pageCount: number +} + +// 文档分块结果 +export interface ChunkedDocument { + chunks: Chunk[] +} + +export interface Chunk { + content: string + pageNumber?: number +} + +// PDF 解析 - 使用 unpdf 库 +export async function parsePDF(filePath: string): Promise { + try { + const buffer = await Bun.file(filePath).arrayBuffer() + + // 15 秒超时,同时覆盖 getDocumentProxy 和 extractText,防止任一步骤 hang + const TIMEOUT_MS = 15000 + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error(`PDF parsing timed out after ${TIMEOUT_MS / 1000}s`)), TIMEOUT_MS), + ) + const parsePromise = (async () => { + // 不使用 standardFontDataUrl,避免版本不匹配导致字体加载 hang + // verbosity: 0 抑制终端字体警告 + const pdf = await getDocumentProxy(new Uint8Array(buffer), { + useSystemFonts: true, + disableFontFace: true, + fontExtraProperties: false, + verbosity: 0, + }) + return extractText(pdf, { mergePages: false }) + })() + const { totalPages, text } = await Promise.race([parsePromise, timeoutPromise]) + + // unpdf 返回的 text 是数组形式(每页一个) + const pages: { pageNumber: number; text: string }[] = [] + let fullText = "" + + if (Array.isArray(text)) { + for (let i = 0; i < text.length; i++) { + const pageText = text[i] || "" + pages.push({ pageNumber: i + 1, text: pageText }) + fullText += pageText + "\n" + } + } else { + // 单页情况 + pages.push({ pageNumber: 1, text: String(text) }) + fullText = String(text) + } + + return { + text: fullText, + pages, + pageCount: totalPages, + } + } catch (e: any) { + throw new Error(`Failed to parse PDF ${filePath}: ${e?.message || e}`) + } +} + +// 文本分块 +export function chunkText( + text: string, + options: { + chunkSize: number + chunkOverlap: number + }, +): Chunk[] { + const { chunkSize, chunkOverlap } = options + const chunks: Chunk[] = [] + + // 按段落分割 + const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0) + + let currentChunk = "" + let currentLength = 0 + + for (const para of paragraphs) { + const paraText = para.trim() + const paraLength = paraText.length + + // 如果当前段落超过了 chunk 大小,需要进一步分割 + if (paraLength > chunkSize) { + // 先保存当前 chunk + if (currentChunk.trim()) { + chunks.push({ content: currentChunk.trim() }) + currentChunk = "" + currentLength = 0 + } + + // 按句子分割大段落 + const sentences = paraText.match(/[^.!?]+[.!?]+/g) || [paraText] + let sentenceChunk = "" + + for (const sentence of sentences) { + if (sentenceChunk.length + sentence.length > chunkSize) { + if (sentenceChunk.trim()) { + chunks.push({ content: sentenceChunk.trim() }) + } + // 添加重叠 + const overlapStart = Math.max(0, sentenceChunk.length - chunkOverlap) + sentenceChunk = sentenceChunk.slice(overlapStart) + sentence + } else { + sentenceChunk += sentence + } + } + + if (sentenceChunk.trim()) { + currentChunk = sentenceChunk + currentLength = sentenceChunk.length + } + } else if (currentLength + paraLength + 2 > chunkSize) { + // 当前 chunk 满了,保存并开始新的 + if (currentChunk.trim()) { + chunks.push({ content: currentChunk.trim() }) + } + + // 添加重叠部分 + const overlapStart = Math.max(0, currentChunk.length - chunkOverlap) + currentChunk = currentChunk.slice(overlapStart) + "\n\n" + paraText + currentLength = currentChunk.length + } else { + // 添加到当前 chunk + if (currentChunk) { + currentChunk += "\n\n" + paraText + } else { + currentChunk = paraText + } + currentLength = currentChunk.length + } + } + + // 保存最后一个 chunk + if (currentChunk.trim()) { + chunks.push({ content: currentChunk.trim() }) + } + + return chunks +} + +// 处理文档并创建 chunks +export async function processDocument( + filePath: string, + options: { + chunkSize: number + chunkOverlap: number + documentId: string + }, +): Promise<{ chunks: ChunkMeta[]; pageCount: number }> { + const { chunkSize, chunkOverlap, documentId } = options + + // 解析 PDF + const parsed = await parsePDF(filePath) + + // 分块 + const rawChunks = chunkText(parsed.text, { chunkSize, chunkOverlap }) + + // 创建 ChunkMeta + const chunks: ChunkMeta[] = rawChunks.map((chunk, index) => ({ + id: Storage.genChunkId(), + documentId, + index, + content: chunk.content, + pageNumber: chunk.pageNumber, + embeddingOffset: 0, // 将在嵌入后更新 + embeddingLength: 0, // 将在嵌入后更新 + })) + + return { + chunks, + pageCount: parsed.pageCount, + } +} + +// 提取 chunk 内容用于嵌入 +export function extractChunkContents(chunks: ChunkMeta[]): string[] { + return chunks.map((chunk) => chunk.content) +} \ No newline at end of file diff --git a/packages/opencode/src/knowledge/embedding.ts b/packages/opencode/src/knowledge/embedding.ts new file mode 100644 index 000000000000..bead0f2587d1 --- /dev/null +++ b/packages/opencode/src/knowledge/embedding.ts @@ -0,0 +1,345 @@ +import z from "zod" +import type { EmbeddingProvider, EmbeddingModelInfo } from "./types" + +// 嵌入模型统一接口 +export interface Embedder { + readonly name: string + readonly provider: EmbeddingProvider + readonly dimensions: number + embed(texts: string[]): Promise +} + +// 可用的嵌入模型列表 +export const EMBEDDING_MODELS: EmbeddingModelInfo[] = [ + { + id: "text-embedding-3-small", + name: "OpenAI text-embedding-3-small", + provider: "openai", + dimensions: 1536, + description: "OpenAI 的轻量级嵌入模型,性价比高", + }, + { + id: "text-embedding-3-large", + name: "OpenAI text-embedding-3-large", + provider: "openai", + dimensions: 3072, + description: "OpenAI 的高性能嵌入模型", + }, + { + id: "all-MiniLM-L6-v2", + name: "all-MiniLM-L6-v2 (本地)", + provider: "local", + dimensions: 384, + description: "轻量级本地嵌入模型,无需 API", + }, +] + +// Embedding 批量大小限制(大多数 API 限制为 100) +const EMBEDDING_BATCH_SIZE = 100 + +// OpenAI 嵌入实现 +export class OpenAIEmbedder implements Embedder { + readonly name: string + readonly provider: EmbeddingProvider = "openai" + readonly dimensions: number + + private apiKey: string + private model: string + private baseURL: string + + constructor(opts: { apiKey: string; model: string; dimensions: number; baseURL?: string }) { + this.name = `openai/${opts.model}` + this.apiKey = opts.apiKey + this.model = opts.model + this.dimensions = opts.dimensions + this.baseURL = opts.baseURL || "https://api.openai.com/v1" + } + + async embed(texts: string[]): Promise { + const allEmbeddings: Float32Array[] = [] + + // 分批处理,每批最多 EMBEDDING_BATCH_SIZE 个 + for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) { + const batch = texts.slice(i, i + EMBEDDING_BATCH_SIZE) + + const response = await fetch(`${this.baseURL}/embeddings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + input: batch, + model: this.model, + }), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`OpenAI embedding failed: ${error}`) + } + + const data = await response.json() + + // 按 index 排序确保顺序正确 + const sortedData = data.data.sort((a: any, b: any) => a.index - b.index) + for (const item of sortedData) { + allEmbeddings.push(new Float32Array(item.embedding)) + } + } + + return allEmbeddings + } +} + +// 自定义 OpenAI Compatible 嵌入实现 +export class CustomEmbedder implements Embedder { + readonly name: string + readonly provider: EmbeddingProvider = "custom" + readonly dimensions: number + + private apiKey: string + private model: string + private baseURL: string + + constructor(opts: { apiKey: string; model: string; dimensions: number; baseURL: string }) { + this.name = `custom/${opts.model}` + this.apiKey = opts.apiKey + this.model = opts.model + this.dimensions = opts.dimensions + this.baseURL = opts.baseURL + } + + async embed(texts: string[]): Promise { + const allEmbeddings: Float32Array[] = [] + + // 分批处理,每批最多 EMBEDDING_BATCH_SIZE 个 + for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) { + const batch = texts.slice(i, i + EMBEDDING_BATCH_SIZE) + + const response = await fetch(`${this.baseURL}/embeddings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + input: batch, + model: this.model, + }), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Custom embedding failed: ${error}`) + } + + const data = await response.json() + + // 按 index 排序确保顺序正确 + const sortedData = data.data.sort((a: any, b: any) => a.index - b.index) + for (const item of sortedData) { + allEmbeddings.push(new Float32Array(item.embedding)) + } + } + + return allEmbeddings + } +} + +// 本地嵌入实现(使用 Transformers.js) +export class LocalEmbedder implements Embedder { + readonly name: string + readonly provider: EmbeddingProvider = "local" + readonly dimensions: number + + private model: string + private extractor: ((text: string, opts: { pooling: string; normalize: boolean }) => Promise<{ data: number[] }>) | null = null + private initPromise: Promise | null = null + + constructor(opts: { model: string; dimensions: number }) { + this.name = `local/${opts.model}` + this.model = opts.model + this.dimensions = opts.dimensions + } + + private async init() { + if (this.extractor) return + if (this.initPromise) return this.initPromise + + this.initPromise = (async () => { + try { + // 动态导入 Transformers.js(可选依赖) + const transformers = await import("@xenova/transformers" as string) + const pipeline = (transformers as any).pipeline + this.extractor = await pipeline("feature-extraction", this.model, { + quantized: true, + }) + } catch (e: any) { + throw new Error(`Failed to load local embedding model. Please install @xenova/transformers: ${e?.message || e}`) + } + })() + + return this.initPromise + } + + async embed(texts: string[]): Promise { + await this.init() + + if (!this.extractor) { + throw new Error("Local embedder not initialized") + } + + const embeddings: Float32Array[] = [] + + for (const text of texts) { + const result = await this.extractor(text, { pooling: "mean", normalize: true }) + embeddings.push(new Float32Array(result.data)) + } + + return embeddings + } +} + +// 创建嵌入器工厂函数 +export function createEmbedder(opts: { + provider: EmbeddingProvider + model: string + dimensions: number + apiKey?: string + baseURL?: string +}): Embedder { + switch (opts.provider) { + case "openai": + if (!opts.apiKey) { + throw new Error("OpenAI embedding requires API key") + } + return new OpenAIEmbedder({ + apiKey: opts.apiKey, + model: opts.model, + dimensions: opts.dimensions, + baseURL: opts.baseURL, + }) + + case "custom": + if (!opts.apiKey || !opts.baseURL) { + throw new Error("Custom embedding requires API key and base URL") + } + return new CustomEmbedder({ + apiKey: opts.apiKey, + model: opts.model, + dimensions: opts.dimensions, + baseURL: opts.baseURL, + }) + + case "local": + return new LocalEmbedder({ + model: opts.model, + dimensions: opts.dimensions, + }) + + default: + throw new Error(`Unknown embedding provider: ${opts.provider}`) + } +} + +// 获取嵌入模型信息 +export function getEmbeddingModel(modelId: string): EmbeddingModelInfo | undefined { + return EMBEDDING_MODELS.find((m) => m.id === modelId) +} + +// 获取模型的默认维度(仅对已知模型有效) +export function getDefaultDimensions(modelId: string): number | null { + const model = getEmbeddingModel(modelId) + if (model) { + return model.dimensions + } + // 未知模型,返回 null 表示需要自动检测 + return null +} + +// 自动检测嵌入模型的维度 +export async function detectDimensions(opts: { + provider: EmbeddingProvider + model: string + apiKey?: string + baseURL?: string +}): Promise { + const testText = "test" + + switch (opts.provider) { + case "openai": { + if (!opts.apiKey) { + throw new Error("OpenAI embedding requires API key") + } + const baseURL = opts.baseURL || "https://api.openai.com/v1" + const response = await fetch(`${baseURL}/embeddings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${opts.apiKey}`, + }, + body: JSON.stringify({ + input: testText, + model: opts.model, + }), + signal: AbortSignal.timeout(30000), + }) + if (!response.ok) { + const error = await response.text() + throw new Error(`Failed to detect dimensions: ${error}`) + } + const data = await response.json() + return data.data[0].embedding.length + } + + case "custom": { + if (!opts.apiKey || !opts.baseURL) { + throw new Error("Custom embedding requires API key and base URL") + } + const response = await fetch(`${opts.baseURL}/embeddings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${opts.apiKey}`, + }, + body: JSON.stringify({ + input: testText, + model: opts.model, + }), + signal: AbortSignal.timeout(30000), + }) + if (!response.ok) { + const error = await response.text() + throw new Error(`Failed to detect dimensions: ${error}`) + } + const data = await response.json() + return data.data[0].embedding.length + } + + case "local": { + // 本地模型需要加载后才能检测 + try { + const transformers = await import("@xenova/transformers" as string) + const pipeline = (transformers as any).pipeline + const extractor = await pipeline("feature-extraction", opts.model, { + quantized: true, + }) + const result = await extractor(testText, { pooling: "mean", normalize: true }) + return result.data.length + } catch (e: any) { + throw new Error(`Failed to detect dimensions for local model: ${e?.message || e}`) + } + } + + default: + throw new Error(`Unknown embedding provider: ${opts.provider}`) + } +} + +// 列出所有可用的嵌入模型 +export function listEmbeddingModels(): EmbeddingModelInfo[] { + return EMBEDDING_MODELS +} diff --git a/packages/opencode/src/knowledge/index.ts b/packages/opencode/src/knowledge/index.ts new file mode 100644 index 000000000000..e3f9b2bd4649 --- /dev/null +++ b/packages/opencode/src/knowledge/index.ts @@ -0,0 +1,288 @@ +import path from "path" +import { Storage } from "./storage" +import { createEmbedder, listEmbeddingModels, getEmbeddingModel } from "./embedding" +import { processDocument, extractChunkContents } from "./document" +import { quickSearch } from "./search" +import { Log } from "../util/log" + +const log = Log.create({ service: "knowledge" }) +import type { + KnowledgeIndex, + DocumentMeta, + SearchResult, + SyncResult, + EmbeddingProvider, +} from "./types" + +export namespace Knowledge { + // 创建新知识库 + export async function create(opts: { + path: string + name: string + embeddingProvider: EmbeddingProvider + embeddingModel: string + embeddingDimensions: number + apiKey?: string + baseURL?: string + chunkSize?: number + chunkOverlap?: number + }): Promise { + // 检查路径是否存在 + const dirExists = await Storage.isKnowledgeBase(opts.path) + if (dirExists) { + throw new Error(`Knowledge base already exists at ${opts.path}`) + } + + // 初始化知识库 + const index = await Storage.init(opts.path, { + id: Storage.genKBId(), + name: opts.name, + embeddingProvider: opts.embeddingProvider, + embeddingModel: opts.embeddingModel, + embeddingDimensions: opts.embeddingDimensions, + apiKey: opts.apiKey, + baseURL: opts.baseURL, + chunkSize: opts.chunkSize ?? 512, + chunkOverlap: opts.chunkOverlap ?? 50, + }) + + return index + } + + // 加载知识库 + export async function load(dir: string): Promise { + return Storage.loadIndex(dir) + } + + // 删除知识库 + export async function remove(dir: string): Promise { + await Storage.deleteKnowledgeBase(dir) + } + + // 同步文件夹(检测新增/修改/删除的文件) + export async function sync( + dir: string, + index: KnowledgeIndex, + opts?: { + apiKey?: string + baseURL?: string + signal?: AbortSignal + onProgress?: (status: { phase: string; current: number; total: number }) => void | Promise + }, + ): Promise { + const result: SyncResult = { added: 0, updated: 0, removed: 0, errors: [] } + + log.info("Starting sync", { dir, provider: index.config.embeddingProvider, model: index.config.embeddingModel }) + + // 创建嵌入器 + log.info("Creating embedder...") + const embedder = createEmbedder({ + provider: index.config.embeddingProvider, + model: index.config.embeddingModel, + dimensions: index.config.embeddingDimensions, + apiKey: opts?.apiKey, + baseURL: opts?.baseURL, + }) + log.info("Embedder created") + + // 获取文件夹中的 PDF 文件 + log.info("Listing PDF files...", { dir }) + const pdfFiles = await Storage.listPdfFiles(dir) + log.info("Found PDF files", { count: pdfFiles.length, files: pdfFiles }) + + const existingFiles = new Map() + + for (const doc of index.documents) { + existingFiles.set(doc.meta.filePath, doc) + } + + // 处理新增和修改的文件 + const dimensions = index.config.embeddingDimensions + + for (let i = 0; i < pdfFiles.length; i++) { + if (opts?.signal?.aborted) { + log.info("Sync aborted by signal", { processed: i, total: pdfFiles.length }) + break + } + + const filePath = pdfFiles[i] + + log.info("Processing file", { index: i + 1, total: pdfFiles.length, file: filePath }) + + const fileInfo = await Storage.getFileInfo(filePath) + const existing = existingFiles.get(filePath) + + // 检查文件是否需要更新 + if (existing) { + log.info("Skipping existing file", { file: filePath }) + await opts?.onProgress?.({ phase: "processing", current: i + 1, total: pdfFiles.length }) + continue + } + + try { + // 处理文档 + log.info("Parsing PDF...", { file: filePath }) + const docId = Storage.genDocId() + const { chunks, pageCount } = await processDocument(filePath, { + chunkSize: index.config.chunkSize, + chunkOverlap: index.config.chunkOverlap, + documentId: docId, + }) + log.info("PDF parsed", { file: filePath, chunks: chunks.length, pages: pageCount }) + + if (chunks.length === 0) { + result.errors?.push(`No text extracted from ${path.basename(filePath)}: PDF may be scanned image or use unsupported encoding`) + await opts?.onProgress?.({ phase: "processing", current: i + 1, total: pdfFiles.length }) + continue + } + + // 生成嵌入向量 + log.info("Generating embeddings...", { file: filePath, chunks: chunks.length }) + const contents = extractChunkContents(chunks) + const embeddings = await embedder.embed(contents) + log.info("Embeddings generated", { file: filePath, count: embeddings.length }) + + // 立即写入嵌入向量并更新偏移量 + const offset = await Storage.appendEmbeddings(dir, embeddings) + for (let j = 0; j < chunks.length; j++) { + chunks[j].embeddingOffset = offset + j * dimensions + chunks[j].embeddingLength = dimensions + } + + // 创建文档元数据 + const docMeta: DocumentMeta = { + id: docId, + filePath, + fileName: fileInfo.name, + fileSize: fileInfo.size, + pageCount, + status: "ready", + createdAt: Date.now(), + updatedAt: Date.now(), + } + + // 立即更新索引并保存 + index.documents.push({ meta: docMeta, chunks }) + index.stats.totalDocuments = index.documents.length + index.stats.totalChunks = index.documents.reduce((sum, doc) => sum + doc.chunks.length, 0) + await Storage.saveIndex(dir, index) + + result.added++ + } catch (e: any) { + result.errors?.push(`Failed to process ${filePath}: ${e?.message || e}`) + } + + await opts?.onProgress?.({ phase: "processing", current: i + 1, total: pdfFiles.length }) + } + + // 更新最终同步时间 + index.stats.lastSyncedAt = Date.now() + await Storage.saveIndex(dir, index) + + return result + } + + // 检索 + export async function search( + dir: string, + index: KnowledgeIndex, + query: string, + opts?: { + apiKey?: string + baseURL?: string + topK?: number + }, + ): Promise { + const embedder = createEmbedder({ + provider: index.config.embeddingProvider, + model: index.config.embeddingModel, + dimensions: index.config.embeddingDimensions, + apiKey: opts?.apiKey, + baseURL: opts?.baseURL, + }) + + return quickSearch(query, index, dir, embedder, opts?.topK ?? 5) + } + + // 构建 RAG 上下文 + export function buildRAGContext(results: SearchResult[], maxLength: number = 50000): string { + if (results.length === 0) { + return "" + } + + const parts: string[] = [] + let totalLength = 0 + + for (const result of results) { + const header = `[文档: ${result.document.fileName}]` + const content = result.chunk.content + const part = `${header}\n${content}\n` + + if (totalLength + part.length > maxLength) { + break + } + + parts.push(part) + totalLength += part.length + } + + return parts.join("\n---\n\n") + } + + // 列出可用嵌入模型 + export function listModels() { + return listEmbeddingModels() + } + + // 获取嵌入模型信息 + export function getModel(modelId: string) { + return getEmbeddingModel(modelId) + } + + // 获取知识库统计信息 + export function getStats(index: KnowledgeIndex) { + return { + totalDocuments: index.stats.totalDocuments, + totalChunks: index.stats.totalChunks, + lastSyncedAt: index.stats.lastSyncedAt, + embeddingModel: index.config.embeddingModel, + embeddingProvider: index.config.embeddingProvider, + chunkSize: index.config.chunkSize, + chunkOverlap: index.config.chunkOverlap, + } + } + + // 删除文档 + export async function removeDocument( + dir: string, + index: KnowledgeIndex, + documentId: string, + ): Promise { + const docIndex = index.documents.findIndex((d) => d.meta.id === documentId) + if (docIndex === -1) { + throw new Error(`Document not found: ${documentId}`) + } + + // 删除嵌入向量 + await Storage.removeDocumentEmbeddings(dir, index, documentId) + + // 从索引中删除文档 + index.documents.splice(docIndex, 1) + + // 更新统计 + index.stats.totalDocuments = index.documents.length + index.stats.totalChunks = index.documents.reduce((sum, doc) => sum + doc.chunks.length, 0) + + // 保存索引 + await Storage.saveIndex(dir, index) + + return index + } +} + +// 导出类型和工具 +export { Storage } from "./storage" +export { createEmbedder, listEmbeddingModels, getEmbeddingModel } from "./embedding" +export { parsePDF, chunkText, processDocument } from "./document" +export { createRetriever, quickSearch, cosineSimilarity } from "./search" +export * from "./types" \ No newline at end of file diff --git a/packages/opencode/src/knowledge/search.ts b/packages/opencode/src/knowledge/search.ts new file mode 100644 index 000000000000..d4ef7623ca2e --- /dev/null +++ b/packages/opencode/src/knowledge/search.ts @@ -0,0 +1,128 @@ +import type { KnowledgeIndex, SearchResult, ChunkMeta, DocumentMeta } from "./types" +import { Storage } from "./storage" +import { createEmbedder, type Embedder } from "./embedding" + +// 余弦相似度计算 +export function cosineSimilarity(a: Float32Array, b: Float32Array): number { + if (a.length !== b.length) { + throw new Error("Vectors must have the same length") + } + + let dotProduct = 0 + let normA = 0 + let normB = 0 + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + + const denominator = Math.sqrt(normA) * Math.sqrt(normB) + if (denominator === 0) return 0 + + return dotProduct / denominator +} + +// 检索器 +export class Retriever { + private index: KnowledgeIndex + private dir: string + private embedder: Embedder + private embeddings: Float32Array | null = null + + constructor(opts: { index: KnowledgeIndex; dir: string; embedder: Embedder }) { + this.index = opts.index + this.dir = opts.dir + this.embedder = opts.embedder + } + + // 加载嵌入向量 + async loadEmbeddings(): Promise { + if (this.embeddings) return + this.embeddings = await Storage.readAllEmbeddings(this.dir) + } + + // 检索相关 chunks + async search(query: string, topK: number = 5): Promise { + await this.loadEmbeddings() + + if (!this.embeddings || this.embeddings.length === 0) { + return [] + } + + // 将查询向量化 + const queryEmbeddings = await this.embedder.embed([query]) + const queryVector = queryEmbeddings[0] + + const dimensions = this.index.config.embeddingDimensions + const scores: { chunk: ChunkMeta; document: DocumentMeta; score: number }[] = [] + + // 遍历所有 chunks 计算相似度 + for (const doc of this.index.documents) { + for (const chunk of doc.chunks) { + const start = chunk.embeddingOffset + const end = start + dimensions + + if (end <= this.embeddings!.length) { + const chunkVector = this.embeddings!.slice(start, end) + const score = cosineSimilarity(queryVector, chunkVector) + + scores.push({ + chunk, + document: doc.meta, + score, + }) + } + } + } + + // 按分数排序并返回 topK + scores.sort((a, b) => b.score - a.score) + return scores.slice(0, topK) + } + + // 批量检索(多个查询) + async batchSearch(queries: string[], topK: number = 5): Promise> { + const results = new Map() + + for (const query of queries) { + const searchResults = await this.search(query, topK) + results.set(query, searchResults) + } + + return results + } + + // 获取指定文档的所有 chunks + getDocumentChunks(documentId: string): { chunk: ChunkMeta; document: DocumentMeta }[] { + const doc = this.index.documents.find((d) => d.meta.id === documentId) + if (!doc) return [] + + return doc.chunks.map((chunk) => ({ + chunk, + document: doc.meta, + })) + } +} + +// 创建检索器 +export function createRetriever( + index: KnowledgeIndex, + dir: string, + embedder: Embedder, +): Retriever { + return new Retriever({ index, dir, embedder }) +} + +// 快速检索函数(不创建 Retriever 实例) +export async function quickSearch( + query: string, + index: KnowledgeIndex, + dir: string, + embedder: Embedder, + topK: number = 5, +): Promise { + const retriever = createRetriever(index, dir, embedder) + return retriever.search(query, topK) +} \ No newline at end of file diff --git a/packages/opencode/src/knowledge/storage.ts b/packages/opencode/src/knowledge/storage.ts new file mode 100644 index 000000000000..8752c2bf54c8 --- /dev/null +++ b/packages/opencode/src/knowledge/storage.ts @@ -0,0 +1,262 @@ +import path from "path" +import { mkdir, rm, readdir, stat } from "fs/promises" +import { existsSync } from "fs" +import { Filesystem } from "../util/filesystem" +import { Identifier } from "../id/id" +import type { KnowledgeIndex, KnowledgeBaseConfig } from "./types" + +const KB_FOLDER = ".opencode-kb" +const INDEX_FILE = "index.json" +const EMBEDDING_FILE = "embedding.bin" + +export namespace Storage { + // 获取知识库元数据文件夹路径 + export function kbPath(dir: string): string { + return path.join(dir, KB_FOLDER) + } + + // 获取索引文件路径 + export function indexPath(dir: string): string { + return path.join(kbPath(dir), INDEX_FILE) + } + + // 获取嵌入向量文件路径 + export function embeddingPath(dir: string): string { + return path.join(kbPath(dir), EMBEDDING_FILE) + } + + // 检查是否是知识库 + export async function isKnowledgeBase(dir: string): Promise { + const idx = indexPath(dir) + return Filesystem.exists(idx) + } + + // 初始化知识库 + export async function init( + dir: string, + config: Omit, + ): Promise { + const kbDir = kbPath(dir) + const now = Date.now() + + const fullConfig: KnowledgeBaseConfig = { + ...config, + path: dir, + createdAt: now, + updatedAt: now, + } + + const index: KnowledgeIndex = { + version: 1, + config: fullConfig, + documents: [], + stats: { + totalDocuments: 0, + totalChunks: 0, + }, + } + + // 创建 .opencode-kb 目录 + if (!existsSync(kbDir)) { + await mkdir(kbDir, { recursive: true }) + } + + // 写入索引文件 + await Filesystem.writeJson(indexPath(dir), index) + + // 创建空的嵌入向量文件 + await Bun.write(embeddingPath(dir), new Uint8Array(0)) + + return index + } + + // 加载索引 + export async function loadIndex(dir: string): Promise { + const idx = indexPath(dir) + if (!(await Filesystem.exists(idx))) { + return null + } + return Filesystem.readJson(idx) + } + + // 保存索引 + export async function saveIndex(dir: string, index: KnowledgeIndex): Promise { + index.config.updatedAt = Date.now() + await Filesystem.writeJson(indexPath(dir), index) + } + + // 追加嵌入向量 + export async function appendEmbeddings(dir: string, embeddings: Float32Array[]): Promise { + const embPath = embeddingPath(dir) + const file = Bun.file(embPath) + const existingSize = await file.exists() ? file.size : 0 + const offset = existingSize / 4 // Float32 的偏移量 + + // 合并所有向量 + const totalLength = embeddings.reduce((sum, e) => sum + e.length, 0) + const buffer = new Float32Array(totalLength) + let pos = 0 + for (const emb of embeddings) { + buffer.set(emb, pos) + pos += emb.length + } + + // 读取现有内容并追加 + const existing = await file.exists() ? await file.arrayBuffer() : new ArrayBuffer(0) + const newBuffer = new Uint8Array(existing.byteLength + buffer.byteLength) + newBuffer.set(new Uint8Array(existing), 0) + newBuffer.set(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength), existing.byteLength) + + await Bun.write(embPath, newBuffer) + + return offset + } + + // 读取所有嵌入向量 + export async function readAllEmbeddings(dir: string): Promise { + const embPath = embeddingPath(dir) + const file = Bun.file(embPath) + if (!(await file.exists()) || file.size === 0) { + return null + } + const buffer = await file.arrayBuffer() + return new Float32Array(buffer) + } + + // 读取指定范围的嵌入向量 + export async function readEmbeddings( + dir: string, + offset: number, + count: number, + dimensions: number, + ): Promise { + const all = await readAllEmbeddings(dir) + if (!all) return [] + + const result: Float32Array[] = [] + for (let i = 0; i < count; i++) { + const start = offset + i * dimensions + const end = start + dimensions + if (end <= all.length) { + result.push(all.slice(start, end)) + } + } + return result + } + + // 删除文档的嵌入向量(需要重建整个文件) + export async function removeDocumentEmbeddings( + dir: string, + index: KnowledgeIndex, + documentId: string, + ): Promise { + const doc = index.documents.find((d) => d.meta.id === documentId) + if (!doc || doc.chunks.length === 0) return + + const dimensions = index.config.embeddingDimensions + const all = await readAllEmbeddings(dir) + if (!all) return + + // 重建向量文件 + const newEmbeddings: Float32Array[] = [] + const offsetMap = new Map() + + // 按偏移量排序 + const sortedChunks = index.documents + .filter((d) => d.meta.id !== documentId) + .flatMap((d) => d.chunks) + .sort((a, b) => a.embeddingOffset - b.embeddingOffset) + + for (const chunk of sortedChunks) { + const start = chunk.embeddingOffset + const end = start + dimensions + if (end <= all.length) { + offsetMap.set(chunk.embeddingOffset, newEmbeddings.length * dimensions) + newEmbeddings.push(all.slice(start, end)) + } + } + + // 写入新文件 + const embPath = embeddingPath(dir) + if (newEmbeddings.length === 0) { + await Bun.write(embPath, new Uint8Array(0)) + } else { + const totalLength = newEmbeddings.reduce((sum, e) => sum + e.length, 0) + const buffer = new Float32Array(totalLength) + let pos = 0 + for (const emb of newEmbeddings) { + buffer.set(emb, pos) + pos += emb.length + } + const uint8Buffer = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) + await Bun.write(embPath, uint8Buffer) + } + + // 更新索引中的偏移量 + for (const d of index.documents) { + if (d.meta.id !== documentId) { + for (const chunk of d.chunks) { + const newOffset = offsetMap.get(chunk.embeddingOffset) + if (newOffset !== undefined) { + chunk.embeddingOffset = newOffset + } + } + } + } + } + + // 列出文件夹中的 PDF 文件(递归扫描子目录) + export async function listPdfFiles(dir: string): Promise { + const files: string[] = [] + + async function scan(currentDir: string) { + const entries = await readdir(currentDir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name === KB_FOLDER) continue // 跳过 .opencode-kb + const fullPath = path.join(currentDir, entry.name) + if (entry.isDirectory()) { + await scan(fullPath) + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".pdf")) { + files.push(fullPath) + } + } + } + + await scan(dir) + return files + } + + // 获取文件信息 + export async function getFileInfo(filePath: string): Promise<{ size: number; name: string }> { + const s = await stat(filePath) + return { + size: s.size, + name: path.basename(filePath), + } + } + + // 删除知识库(仅删除 .opencode-kb 目录) + export async function deleteKnowledgeBase(dir: string): Promise { + const kbDir = kbPath(dir) + if (existsSync(kbDir)) { + await rm(kbDir, { recursive: true }) + } + } + + // 生成唯一 ID + const KB_PREFIX = "kb" + const DOC_PREFIX = "doc" + const CHUNK_PREFIX = "chunk" + + export function genKBId(): string { + return Identifier.create(KB_PREFIX as any, false) + } + + export function genDocId(): string { + return Identifier.create(DOC_PREFIX as any, false) + } + + export function genChunkId(): string { + return Identifier.create(CHUNK_PREFIX as any, false) + } +} \ No newline at end of file diff --git a/packages/opencode/src/knowledge/types.ts b/packages/opencode/src/knowledge/types.ts new file mode 100644 index 000000000000..c2df9ed58975 --- /dev/null +++ b/packages/opencode/src/knowledge/types.ts @@ -0,0 +1,97 @@ +import z from "zod" + +// 嵌入模型提供商类型 +export const EmbeddingProviderSchema = z.enum(["openai", "local", "custom"]) +export type EmbeddingProvider = z.infer + +// 知识库配置 +export const KnowledgeBaseConfigSchema = z.object({ + id: z.string(), + name: z.string(), + path: z.string(), + embeddingProvider: EmbeddingProviderSchema, + embeddingModel: z.string(), + embeddingDimensions: z.number(), + apiKey: z.string().optional(), + baseURL: z.string().optional(), + chunkSize: z.number().default(512), + chunkOverlap: z.number().default(50), + createdAt: z.number(), + updatedAt: z.number(), +}) +export type KnowledgeBaseConfig = z.infer + +// 文档状态 +export const DocumentStatusSchema = z.enum(["pending", "processing", "ready", "error"]) +export type DocumentStatus = z.infer + +// 文档元数据 +export const DocumentMetaSchema = z.object({ + id: z.string(), + filePath: z.string(), + fileName: z.string(), + fileSize: z.number(), + pageCount: z.number().optional(), + status: DocumentStatusSchema, + errorMessage: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), +}) +export type DocumentMeta = z.infer + +// Chunk 元数据 +export const ChunkMetaSchema = z.object({ + id: z.string(), + documentId: z.string(), + index: z.number(), + content: z.string(), + pageNumber: z.number().optional(), + embeddingOffset: z.number(), + embeddingLength: z.number(), +}) +export type ChunkMeta = z.infer + +// 索引文件结构 +export const KnowledgeIndexSchema = z.object({ + version: z.literal(1), + config: KnowledgeBaseConfigSchema, + documents: z.array( + z.object({ + meta: DocumentMetaSchema, + chunks: z.array(ChunkMetaSchema), + }), + ), + stats: z.object({ + totalDocuments: z.number(), + totalChunks: z.number(), + lastSyncedAt: z.number().optional(), + }), +}) +export type KnowledgeIndex = z.infer + +// 嵌入模型信息 +export const EmbeddingModelInfoSchema = z.object({ + id: z.string(), + name: z.string(), + provider: EmbeddingProviderSchema, + dimensions: z.number(), + description: z.string().optional(), +}) +export type EmbeddingModelInfo = z.infer + +// 检索结果 +export const SearchResultSchema = z.object({ + chunk: ChunkMetaSchema, + document: DocumentMetaSchema, + score: z.number(), +}) +export type SearchResult = z.infer + +// 同步结果 +export const SyncResultSchema = z.object({ + added: z.number(), + updated: z.number(), + removed: z.number(), + errors: z.array(z.string()).optional(), +}) +export type SyncResult = z.infer \ No newline at end of file diff --git a/packages/opencode/src/server/routes/knowledge.ts b/packages/opencode/src/server/routes/knowledge.ts new file mode 100644 index 000000000000..d3c7786b280a --- /dev/null +++ b/packages/opencode/src/server/routes/knowledge.ts @@ -0,0 +1,481 @@ +import { Hono } from "hono" +import { streamSSE } from "hono/streaming" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Knowledge } from "../../knowledge" +import { getDefaultDimensions, detectDimensions } from "../../knowledge/embedding" +import { + KnowledgeIndexSchema, + SearchResultSchema, + EmbeddingModelInfoSchema, +} from "../../knowledge/types" +import { errors } from "../error" +import { lazy } from "../../util/lazy" +import { Log } from "../../util/log" +import { setKnowledgeConfig, getKnowledgeConfig } from "../../tool/knowledge" + +const log = Log.create({ service: "knowledge" }) + +// 请求/响应 schemas +const CreateKnowledgeBaseSchema = z.object({ + path: z.string().meta({ description: "知识库文件夹路径" }), + name: z.string().meta({ description: "知识库名称" }), + embeddingProvider: z.enum(["openai", "local", "custom"]).meta({ description: "嵌入模型提供商" }), + embeddingModel: z.string().meta({ description: "嵌入模型 ID" }), + embeddingDimensions: z.number().optional().meta({ description: "嵌入向量维度(可选,默认使用模型默认值)" }), + apiKey: z.string().optional().meta({ description: "API 密钥(OpenAI/Custom 需要)" }), + baseURL: z.string().optional().meta({ description: "自定义 API 地址" }), + chunkSize: z.number().optional().default(512).meta({ description: "分块大小" }), + chunkOverlap: z.number().optional().default(50).meta({ description: "分块重叠" }), +}) + +const SyncKnowledgeBaseSchema = z.object({ + apiKey: z.string().optional(), + baseURL: z.string().optional(), +}) + +const SearchSchema = z.object({ + query: z.string().meta({ description: "搜索查询" }), + topK: z.number().optional().default(5).meta({ description: "返回结果数量" }), + apiKey: z.string().optional(), + baseURL: z.string().optional(), +}) + +export const KnowledgeRoutes = lazy(() => + new Hono() + // 列出可用的嵌入模型 + .get( + "/models", + describeRoute({ + summary: "List embedding models", + description: "获取所有可用的嵌入模型列表", + operationId: "knowledge.models.list", + responses: { + 200: { + description: "嵌入模型列表", + content: { + "application/json": { + schema: resolver(z.array(EmbeddingModelInfoSchema)), + }, + }, + }, + }, + }), + async (c) => { + const models = Knowledge.listModels() + return c.json(models) + }, + ) + // 创建知识库 + .post( + "/", + describeRoute({ + summary: "Create knowledge base", + description: "创建新的知识库", + operationId: "knowledge.create", + responses: { + 200: { + description: "知识库创建成功", + content: { + "application/json": { + schema: resolver(KnowledgeIndexSchema), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", CreateKnowledgeBaseSchema), + async (c) => { + const opts = c.req.valid("json") + log.info("creating knowledge base", { path: opts.path, name: opts.name, model: opts.embeddingModel }) + + // 自动检测维度:优先使用用户指定的维度,否则尝试从已知模型获取,最后自动检测 + let dimensions: number | undefined = opts.embeddingDimensions + + if (!dimensions) { + // 尝试从已知模型列表中获取 + const defaultDims = getDefaultDimensions(opts.embeddingModel) + if (defaultDims !== null) { + dimensions = defaultDims + } + } + + if (!dimensions) { + // 未知模型,自动检测维度 + log.info("auto-detecting dimensions", { provider: opts.embeddingProvider, model: opts.embeddingModel }) + dimensions = await detectDimensions({ + provider: opts.embeddingProvider, + model: opts.embeddingModel, + apiKey: opts.apiKey, + baseURL: opts.baseURL, + }) + log.info("detected dimensions", { model: opts.embeddingModel, dimensions }) + } + + const index = await Knowledge.create({ + path: opts.path, + name: opts.name, + embeddingProvider: opts.embeddingProvider, + embeddingModel: opts.embeddingModel, + embeddingDimensions: dimensions, + apiKey: opts.apiKey, + baseURL: opts.baseURL, + chunkSize: opts.chunkSize, + chunkOverlap: opts.chunkOverlap, + }) + + return c.json(index) + }, + ) + // 获取知识库信息 + .get( + "/:path{.+}", + describeRoute({ + summary: "Get knowledge base", + description: "获取知识库详细信息", + operationId: "knowledge.get", + responses: { + 200: { + description: "知识库信息", + content: { + "application/json": { + schema: resolver(KnowledgeIndexSchema), + }, + }, + }, + 404: { + description: "知识库不存在", + }, + ...errors(400), + }, + }), + async (c) => { + const dir = decodeURIComponent(c.req.param("path")) + const index = await Knowledge.load(dir) + + if (!index) { + return c.json({ error: "Knowledge base not found" }, 404) + } + + return c.json(index) + }, + ) + // 删除知识库 + .delete( + "/:path{.+}", + describeRoute({ + summary: "Delete knowledge base", + description: "删除知识库(仅删除索引,不删除原文件)", + operationId: "knowledge.delete", + responses: { + 200: { + description: "删除成功", + content: { + "application/json": { + schema: resolver(z.object({ ok: z.boolean() })), + }, + }, + }, + 404: { + description: "知识库不存在", + }, + }, + }), + async (c) => { + const dir = decodeURIComponent(c.req.param("path")) + log.info("deleting knowledge base", { path: dir }) + + await Knowledge.remove(dir) + return c.json({ ok: true }) + }, + ) + // 同步知识库 + .post( + "/:path{.+}/sync", + describeRoute({ + summary: "Sync knowledge base", + description: "同步知识库文件夹,处理新增的 PDF 文件,以 SSE 流式返回进度", + operationId: "knowledge.sync", + responses: { + 200: { + description: "SSE 进度流,最终事件为 complete 或 error", + content: { + "text/event-stream": { + schema: resolver(z.object({ event: z.string(), data: z.string() })), + }, + }, + }, + 404: { + description: "知识库不存在", + }, + ...errors(400), + }, + }), + validator("json", SyncKnowledgeBaseSchema.optional()), + async (c) => { + const dir = decodeURIComponent(c.req.param("path")) + const opts = c.req.valid("json") || {} + + log.info("syncing knowledge base", { path: dir }) + + const index = await Knowledge.load(dir) + if (!index) { + return c.json({ error: "Knowledge base not found" }, 404) + } + + return streamSSE(c, async (stream) => { + const abortSignal = c.req.raw.signal + try { + // 优先使用请求中的 apiKey/baseURL,否则使用 index.config 中保存的 + const result = await Knowledge.sync(dir, index, { + apiKey: opts.apiKey || index.config.apiKey, + baseURL: opts.baseURL || index.config.baseURL, + signal: abortSignal, + onProgress: async (status) => { + await stream.writeSSE({ + event: "progress", + data: JSON.stringify(status), + }) + }, + }) + if (!abortSignal.aborted) { + await stream.writeSSE({ + event: "complete", + data: JSON.stringify(result), + }) + } + } catch (err: any) { + if (abortSignal.aborted) return + await stream.writeSSE({ + event: "error", + data: JSON.stringify({ message: err?.message || String(err) }), + }) + } + }) + }, + ) + // 搜索知识库 + .post( + "/:path{.+}/search", + describeRoute({ + summary: "Search knowledge base", + description: "在知识库中搜索相关内容", + operationId: "knowledge.search", + responses: { + 200: { + description: "搜索结果", + content: { + "application/json": { + schema: resolver(z.array(SearchResultSchema)), + }, + }, + }, + 404: { + description: "知识库不存在", + }, + ...errors(400), + }, + }), + validator("json", SearchSchema), + async (c) => { + const dir = decodeURIComponent(c.req.param("path")) + const opts = c.req.valid("json") + + log.info("searching knowledge base", { path: dir, query: opts.query }) + + const index = await Knowledge.load(dir) + if (!index) { + return c.json({ error: "Knowledge base not found" }, 404) + } + + // 优先使用请求中的 apiKey/baseURL,否则使用 index.config 中保存的 + const results = await Knowledge.search(dir, index, opts.query, { + apiKey: opts.apiKey || index.config.apiKey, + baseURL: opts.baseURL || index.config.baseURL, + topK: opts.topK, + }) + + return c.json(results) + }, + ) + // 删除文档 + .delete( + "/:path{.+}/document/:documentId", + describeRoute({ + summary: "Delete document", + description: "从知识库中删除指定文档", + operationId: "knowledge.document.delete", + responses: { + 200: { + description: "删除成功", + content: { + "application/json": { + schema: resolver(KnowledgeIndexSchema), + }, + }, + }, + 404: { + description: "知识库或文档不存在", + }, + }, + }), + async (c) => { + const dir = decodeURIComponent(c.req.param("path")) + const documentId = c.req.param("documentId") + + log.info("deleting document from knowledge base", { path: dir, documentId }) + + const index = await Knowledge.load(dir) + if (!index) { + return c.json({ error: "Knowledge base not found" }, 404) + } + + const updatedIndex = await Knowledge.removeDocument(dir, index, documentId) + return c.json(updatedIndex) + }, + ) + // 设置全局知识库配置(供前端调用) + .post( + "/config", + describeRoute({ + summary: "Set knowledge config", + description: "设置全局知识库配置,供 knowledge_search 工具使用", + operationId: "knowledge.config.set", + responses: { + 200: { + description: "配置成功", + content: { + "application/json": { + schema: resolver(z.object({ ok: z.boolean() })), + }, + }, + }, + }, + }), + validator( + "json", + z.object({ + path: z.string(), + apiKey: z.string().optional(), + baseURL: z.string().optional(), + }), + ), + async (c) => { + const opts = c.req.valid("json") + setKnowledgeConfig({ + path: opts.path, + apiKey: opts.apiKey, + baseURL: opts.baseURL, + }) + log.info("set knowledge config", { path: opts.path }) + return c.json({ ok: true }) + }, + ) + // 获取全局知识库配置 + .get( + "/config", + describeRoute({ + summary: "Get knowledge config", + description: "获取全局知识库配置", + operationId: "knowledge.config.get", + responses: { + 200: { + description: "当前配置", + content: { + "application/json": { + schema: resolver( + z.object({ + path: z.string().optional(), + apiKey: z.string().optional(), + baseURL: z.string().optional(), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const config = getKnowledgeConfig() + return c.json(config ?? {}) + }, + ) + // 获取知识库统计信息 + .get( + "/:path{.+}/stats", + describeRoute({ + summary: "Get knowledge base stats", + description: "获取知识库统计信息", + operationId: "knowledge.stats", + responses: { + 200: { + description: "统计信息", + content: { + "application/json": { + schema: resolver( + z.object({ + totalDocuments: z.number(), + totalChunks: z.number(), + lastSyncedAt: z.number().optional(), + embeddingModel: z.string(), + embeddingProvider: z.string(), + chunkSize: z.number(), + chunkOverlap: z.number(), + }), + ), + }, + }, + }, + 404: { + description: "知识库不存在", + }, + }, + }), + async (c) => { + const dir = decodeURIComponent(c.req.param("path")) + const index = await Knowledge.load(dir) + + if (!index) { + return c.json({ error: "Knowledge base not found" }, 404) + } + + const stats = Knowledge.getStats(index) + return c.json(stats) + }, + ) + // 获取 PDF 文件内容 - 用于在网页中预览 + .get( + "/file", + async (c) => { + const url = new URL(c.req.url) + const filePath = url.searchParams.get("path") + + if (!filePath) { + return c.json({ error: "Missing path parameter" }, 400) + } + + try { + const file = Bun.file(filePath) + const exists = await file.exists() + + if (!exists) { + return c.json({ error: "File not found" }, 404) + } + + const data = await file.arrayBuffer() + const filename = filePath.split("/").pop() || "document.pdf" + + return new Response(data, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `inline; filename="${filename}"`, + }, + }) + } catch (err) { + log.error("Failed to read PDF file", { path: filePath, error: err }) + return c.json({ error: "Failed to read file" }, 500) + } + }, + ) +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index dc9aec284767..7069b13450a4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -65,6 +65,7 @@ import { Filesystem } from "@/util/filesystem" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" +import { KnowledgeRoutes } from "./routes/knowledge" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" @@ -273,6 +274,7 @@ export namespace Server { .route("/", FileRoutes()) .route("/mcp", McpRoutes()) .route("/tui", TuiRoutes()) + .route("/knowledge", KnowledgeRoutes()) .post( "/instance/dispose", describeRoute({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 171c4b448fd8..da8bd6ef0d0a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -47,6 +47,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { Knowledge } from "../knowledge" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -110,6 +111,13 @@ export namespace SessionPrompt { format: MessageV2.Format.optional(), system: z.string().optional(), variant: z.string().optional(), + knowledgeBase: z + .object({ + path: z.string(), + apiKey: z.string().optional(), + baseURL: z.string().optional(), + }) + .optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -970,6 +978,32 @@ export namespace SessionPrompt { : undefined const variant = input.variant ?? (agent.variant && full?.variants?.[agent.variant] ? agent.variant : undefined) + // RAG: search knowledge base and build context for system prompt + let ragContext: string | undefined + if (input.knowledgeBase) { + try { + const index = await Knowledge.load(input.knowledgeBase.path) + if (index) { + const userText = input.parts + .filter((p) => p.type === "text") + .map((p) => (p as { text: string }).text) + .join(" ") + const results = await Knowledge.search(input.knowledgeBase.path, index, userText, { + apiKey: input.knowledgeBase.apiKey, + baseURL: input.knowledgeBase.baseURL, + topK: 5, + }) + if (results.length > 0) { + ragContext = + "以下是来自知识库的相关内容,请参考这些内容回答用户的问题:\n\n" + + Knowledge.buildRAGContext(results) + } + } + } catch { + // knowledge base unavailable, proceed without RAG context + } + } + const info: MessageV2.Info = { id: input.messageID ?? MessageID.ascending(), role: "user", @@ -980,7 +1014,7 @@ export namespace SessionPrompt { tools: input.tools, agent: agent.name, model, - system: input.system, + system: ragContext ? (input.system ? `${ragContext}\n\n${input.system}` : ragContext) : input.system, format: input.format, variant, } diff --git a/packages/opencode/src/tool/knowledge.ts b/packages/opencode/src/tool/knowledge.ts new file mode 100644 index 000000000000..96ffadd7d07e --- /dev/null +++ b/packages/opencode/src/tool/knowledge.ts @@ -0,0 +1,238 @@ +import z from "zod" +import { Tool } from "./tool" +import { Knowledge } from "../knowledge" +import { Storage } from "../knowledge/storage" +import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" +import path from "path" + +const DESCRIPTION = `Search the knowledge base for relevant information. + +This tool searches through indexed documents (PDFs, papers, etc.) that have been synced to the knowledge base. Use this when the user asks questions about topics that might be in their uploaded documents or papers. + +The knowledge base uses semantic search with embeddings, so you can search by meaning, not just exact keywords. + +Parameters: +- query: The search query describing what information you're looking for +- topK: Number of results to return (default: 10, max: 20) + +Returns relevant document chunks with their source filenames and similarity scores. + +IMPORTANT: When answering based on search results, you MUST: +1. Read and consider ALL returned results, not just the top few +2. Cite ALL source documents that contributed to your answer +3. Use this exact format for citations: [filename.pdf](file:///full/path/to/file.pdf) +4. Include a "Sources:" section at the end listing ALL referenced documents + +Do not ignore any relevant results - synthesize information from all returned chunks.` + +// 全局知识库配置存储 +let globalKnowledgeConfig: { + path: string + apiKey?: string + baseURL?: string +} | null = null + +export function setKnowledgeConfig(config: { path: string; apiKey?: string; baseURL?: string } | null) { + globalKnowledgeConfig = config +} + +export function getKnowledgeConfig() { + return globalKnowledgeConfig +} + +// 在 Instance 目录中查找知识库 +async function findKnowledgeBase(): Promise { + // 优先使用全局配置 + if (globalKnowledgeConfig?.path) { + const kbPath = Storage.kbPath(globalKnowledgeConfig.path) + if (await Filesystem.exists(kbPath)) { + return globalKnowledgeConfig.path + } + } + + // 在当前工作目录查找 + const cwd = Instance.directory + const cwdKbPath = Storage.kbPath(cwd) + if (await Filesystem.exists(cwdKbPath)) { + return cwd + } + + // 在父目录中查找(最多向上 3 层) + let dir = cwd + for (let i = 0; i < 3; i++) { + const parent = path.dirname(dir) + if (parent === dir) break + dir = parent + const kbPath = Storage.kbPath(dir) + if (await Filesystem.exists(kbPath)) { + return dir + } + } + + return null +} + +interface KnowledgeMetadata { + configured: boolean + exists?: boolean + documents?: number + chunks?: number + results?: number + sources?: string[] +} + +export const KnowledgeTool = Tool.define("knowledge_search", { + description: DESCRIPTION, + parameters: z.object({ + query: z.string().describe("The search query to find relevant information in the knowledge base"), + topK: z.coerce.number().describe("Number of results to return (default: 10)").optional().default(10), + }), + async execute(params, ctx) { + // 查找知识库 + const kbPath = await findKnowledgeBase() + + if (!kbPath) { + return { + title: "Knowledge base not configured", + output: `Knowledge base is not configured or not found. + +To use the knowledge base: +1. Open the Knowledge Base dialog in the UI +2. Select a folder containing your PDF documents +3. Configure the embedding provider (OpenAI, Local, or Custom) +4. Click Sync to index your documents + +Once synced, you can search the knowledge base using this tool.`, + metadata: { + configured: false, + } as KnowledgeMetadata, + } + } + + // 加载知识库索引 + const index = await Knowledge.load(kbPath) + if (!index) { + return { + title: "Knowledge base not found", + output: `Knowledge base index not found at ${kbPath}. Please sync documents first.`, + metadata: { + configured: true, + exists: false, + } as KnowledgeMetadata, + } + } + + // 检查是否有文档 + if (index.stats.totalDocuments === 0 || index.stats.totalChunks === 0) { + return { + title: "Knowledge base empty", + output: `Knowledge base at "${kbPath}" is empty. No documents have been synced yet. + +Please run sync to index your documents: +1. Open the Knowledge Base dialog +2. Click the Sync button to index your PDFs`, + metadata: { + configured: true, + exists: true, + documents: 0, + chunks: 0, + } as KnowledgeMetadata, + } + } + + // 执行搜索 - 优先使用 index.config 中保存的 apiKey/baseURL + const topK = Math.min(params.topK || 10, 20) + const results = await Knowledge.search(kbPath, index, params.query, { + apiKey: index.config.apiKey || globalKnowledgeConfig?.apiKey, + baseURL: index.config.baseURL || globalKnowledgeConfig?.baseURL, + topK, + }) + + if (results.length === 0) { + return { + title: "No results found", + output: `No relevant information found for query: "${params.query}" + +The knowledge base contains ${index.stats.totalDocuments} documents with ${index.stats.totalChunks} chunks, but none matched your query. + +Try: +- Using different keywords +- Making your query more general +- Checking if the topic exists in your synced documents`, + metadata: { + configured: true, + exists: true, + documents: index.stats.totalDocuments, + chunks: index.stats.totalChunks, + results: 0, + } as KnowledgeMetadata, + } + } + + // 格式化输出 + const outputParts: string[] = [ + ``, + "", + ] + + // 收集所有来源文件(用于生成来源链接) + const sourceFiles = new Map() // fileName -> filePath + + for (let i = 0; i < results.length; i++) { + const result = results[i] + const filePath = result.document.filePath + const fileName = result.document.fileName + + // 记录来源文件 + if (!sourceFiles.has(fileName)) { + sourceFiles.set(fileName, filePath) + } + + // 生成 file:// URL + const fileUrl = `file://${filePath}` + + outputParts.push(``) + outputParts.push(` ${fileName}`) + if (result.chunk.pageNumber) { + outputParts.push(` ${result.chunk.pageNumber}`) + } + outputParts.push(` `) + outputParts.push(` ${result.chunk.content.trim()}`) + outputParts.push(` `) + outputParts.push(``) + outputParts.push("") + } + + outputParts.push(``) + outputParts.push("") + + // 生成来源链接列表(Markdown 格式,可点击) + outputParts.push(``) + outputParts.push(`Referenced documents (click to open):`) + for (const [fileName, filePath] of sourceFiles) { + outputParts.push(`- [${fileName}](file://${filePath})`) + } + outputParts.push(``) + outputParts.push("") + + outputParts.push(``) + outputParts.push(`Found ${results.length} relevant chunks from ${sourceFiles.size} documents.`) + outputParts.push(`Knowledge base: ${kbPath}`) + outputParts.push(`Total: ${index.stats.totalDocuments} documents, ${index.stats.totalChunks} chunks.`) + outputParts.push(``) + + return { + title: `Found ${results.length} results`, + output: outputParts.join("\n"), + metadata: { + configured: true, + exists: true, + documents: index.stats.totalDocuments, + chunks: index.stats.totalChunks, + results: results.length, + sources: [...new Set(results.map(r => r.document.fileName))], + } as KnowledgeMetadata, + } + }, +}) \ No newline at end of file diff --git a/packages/opencode/src/tool/knowledge.txt b/packages/opencode/src/tool/knowledge.txt new file mode 100644 index 000000000000..d45658547c6d --- /dev/null +++ b/packages/opencode/src/tool/knowledge.txt @@ -0,0 +1,17 @@ +Search the knowledge base for relevant information. + +This tool searches through indexed documents (PDFs, papers, etc.) that have been synced to the knowledge base. Use this when the user asks questions about topics that might be in their uploaded documents or papers. + +The knowledge base uses semantic search with embeddings, so you can search by meaning, not just exact keywords. + +Parameters: +- query: The search query describing what information you're looking for +- topK: Number of results to return (default: 5, max: 20) + +Returns relevant document chunks with their source filenames and similarity scores. + +Usage notes: + - Always use this tool when the user's question relates to documents they have uploaded to the knowledge base + - The query should describe the information you're looking for in natural language + - Results include the source filename, page number (if available), and relevant text content + - The score indicates relevance (higher is better) \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5efed0c02283..fee956a24b4c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,6 +1,7 @@ import { PlanExitTool } from "./plan" import { QuestionTool } from "./question" import { BashTool } from "./bash" +import { KnowledgeTool } from "./knowledge" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -120,6 +121,7 @@ export namespace ToolRegistry { SkillTool, ApplyPatchTool, SummarizeDirsTool, + KnowledgeTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 89655384eceb..b7820c992e84 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2080,6 +2080,11 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string + knowledgeBase?: { + path: string + apiKey?: string + baseURL?: string + } parts?: Array }, options?: Options, @@ -2100,6 +2105,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "format" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, + { in: "body", key: "knowledgeBase" }, { in: "body", key: "parts" }, ], }, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 1a50ed48c0ce..406624eabb3e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -2214,3 +2214,104 @@ ToolRegistry.register({ return }, }) + +// Knowledge search tool - displays sources as clickable links +ToolRegistry.register({ + name: "knowledge_search", + render(props) { + const i18n = useI18n() + const dialog = useDialog() + const pending = createMemo(() => props.status === "pending" || props.status === "running") + + // Extract sources from metadata + const sources = createMemo(() => { + const meta = props.metadata + if (!meta || !Array.isArray(meta.sources)) return [] + return meta.sources as string[] + }) + + // Parse file:// links from output and convert to API URLs + const sourceLinks = createMemo(() => { + const output = props.output || "" + const links: { name: string; filePath: string }[] = [] + const regex = /\[([^\]]+)\]\(file:\/\/([^)]+)\)/g + let match + while ((match = regex.exec(output)) !== null) { + const name = match[1] + const filePath = match[2] + // Avoid duplicates + if (!links.find(l => l.filePath === filePath)) { + links.push({ name, filePath }) + } + } + return links + }) + + const subtitle = createMemo(() => { + const count = sources().length + if (count === 0) return "" + return `${count} ${i18n.t(count > 1 ? "ui.common.source.other" : "ui.common.source.one")}` + }) + + // Open PDF in dialog using backend API + const openPdf = (filePath: string, fileName: string) => { + const apiUrl = `/knowledge/file?path=${encodeURIComponent(filePath)}` + dialog.show(() => ( +
+
+
+ {fileName} + dialog.close()} + aria-label={i18n.t("ui.common.close")} + /> +
+