diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5587a06..82b531f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -478,7 +478,117 @@ async def get_user_language(self): **注意**: 即使插件有 `Valves` 配置,也应优先尝试自动探测,提升用户体验。 -### 8. 智能代理文件交付规范 (Agent File Delivery Standards) +### 8. 国际化 (i18n) 适配规范 (Internationalization Standards) + +开发供全球用户使用的插件时,必须预置多语言支持(如中文、英文等)。 + +#### i18n 字典定义 + +在文件顶部定义 `TRANSLATIONS` 字典存储多语言字符串: + +```python +TRANSLATIONS = { + "en-US": { + "status_starting": "Smart Mind Map is starting...", + }, + "zh-CN": { + "status_starting": "智能思维导图正在启动...", + }, + # ... 其他语言 +} + +# 语言回退映射 (Fallback Map) +FALLBACK_MAP = { + "zh": "zh-CN", + "zh-TW": "zh-CN", + "zh-HK": "zh-CN", + "en": "en-US", + "en-GB": "en-US" +} +``` + +#### 获取当前用户真实语言 (Robust Language Detection) + +Open WebUI 的前端(localStorage)并未自动同步语言设置到后端数据库或通过标准 API 参数传递。为了获取精准的用户偏好语言,**必须**使用多层级回退机制(Multi-level Fallback): +`JS 动态探测 (localStorage)` > `HTTP 浏览器头 (Accept-Language)` > `用户 Profile 默认设置` > `en-US` + +> **注意!防卡死指南 (Anti-Deadlock Guide)** +> 在通过 `__event_call__` 执行前端 JS 脚本时,如果前端脚本不慎抛出异常 (`Exception`) 会导致回调函数 `cb()` 永不执行,这会让后端的 `asyncio` 永远阻塞并卡死整个请求队列! +> **必须**做两重防护: +> 1. JS 内部包裹 `try...catch` 保证必须有 `return`。 +> 2. 后端使用 `asyncio.wait_for` 设置强制超时(建议 2 秒)。 + +```python +import asyncio +from fastapi import Request + +async def _get_user_context( + self, + __user__: Optional[dict], + __event_call__: Optional[callable] = None, + __request__: Optional[Request] = None, +) -> dict: + user_language = __user__.get("language", "en-US") if __user__ else "en-US" + + # 1st Fallback: HTTP Accept-Language header + if __request__ and hasattr(__request__, "headers") and "accept-language" in __request__.headers: + raw_lang = __request__.headers.get("accept-language", "") + if raw_lang: + user_language = raw_lang.split(",")[0].split(";")[0] + + # 2nd Fallback (Best): Execute JS in frontend to read localStorage + if __event_call__: + try: + js_code = """ + try { + return ( + document.documentElement.lang || + localStorage.getItem('locale') || + navigator.language || + 'en-US' + ); + } catch (e) { + return 'en-US'; + } + """ + # 【致命!】必须设置 wait_for 防止前端无响应卡死后端 + frontend_lang = await asyncio.wait_for( + __event_call__({"type": "execute", "data": {"code": js_code}}), + timeout=2.0 + ) + if frontend_lang and isinstance(frontend_lang, str): + user_language = frontend_lang + except Exception as e: + pass # fallback to accept-language or en-US + + return { + "user_language": user_language, + # ... user_name, user_id etc. + } +``` + +#### 实际使用 (Usage in Action/Filter) + +在 Action 或者 Filter 执行时引用这套上下文获取机制,然后传入映射器获取最终翻译: + +```python +async def action( + self, + body: dict, + __user__: Optional[dict] = None, + __event_call__: Optional[callable] = None, + __request__: Optional[Request] = None, + **kwargs +) -> Optional[dict]: + + user_ctx = await self._get_user_context(__user__, __event_call__, __request__) + user_lang = user_ctx["user_language"] + + # 获取多语言文本 (通过你的 translation.get() 扩展) + # start_msg = self._get_translation(user_lang, "status_starting") +``` + +### 9. 智能代理文件交付规范 (Agent File Delivery Standards) 在开发具备文件生成能力的智能代理插件(如 GitHub Copilot SDK 集成)时,必须遵循以下标准流程,以确保文件在不同存储后端(本地/S3)下的可用性并绕过不必要的 RAG 处理。 @@ -498,7 +608,7 @@ async def get_user_language(self): - 代理应始终将“当前目录”视为其受保护所在的私有工作空间。 - `publish_file_from_workspace` 的参数 `filename` 仅需传入相对于当前目录的文件名。 -### 9. Copilot SDK 插件工具定义规范 (Copilot SDK Tool Definition Standards) +### 10. Copilot SDK 插件工具定义规范 (Copilot SDK Tool Definition Standards) 在为 GitHub Copilot SDK 开发自定义工具时,为了确保大模型能正确识别参数(避免生成空的 `properties` Schema),必须遵循以下定义模式: @@ -532,6 +642,63 @@ my_tool = define_tool( 2. **Field 描述**: 在 `BaseModel` 中使用 `Field(..., description="...")` 为每个参数提供详细的描述信息。 3. **Required vs Optional**: 明确标注必填项(无默认值)和可选项(带 `default`)。 +### 11. Copilot SDK 流式渲染与工具卡片规范 (Streaming & Tool Card Standards) + +在处理大模型的思维链(Reasoning)输出和工具调用(Tool Calls)时,为了确保能完美兼容 OpenWebUI 0.8.x 前端的 Markdown 解析器及原生折叠 UI 组件,必须遵循以下极度严格的输出格式规范。 + +#### 思维链流式渲染 (Reasoning Streaming) + +为了让前端能够正确显示“Thinking...”的折叠框和 Spinner 动画,**必须**使用原生的 `` 标签。 + +- **正确的标签包裹**: + ```html + + 这里是思考过程... + + ``` +- **关键细节**: + - **标签闭合检测**: 必须在代码内部维护状态(如 `state["thinking_started"]`)。当(1)正文内容即将开始输出,或(2)工具调用触发 (`tool.execution_start`) 时,**必须优先输出 `\n\n` 强制闭合标签**。如果不闭合,后续的正文或工具面板会被全部吞进思考框内,导致页面完全崩坏! + - **不要手动拼装**: 严禁通过手动输出 `
` 等大段 HTML 来模拟思考过程,这种方式极易在流式片段发送中破坏前端 DOM 树并导致错位。 + +#### 工具调用原生卡片 (Native Tool Calls Block) + +为了在对话界面中生成标准、原生的下拉折叠“工具调用”卡片,当 `event_type == "tool.execution_complete"` 时,必须向队列输出如下严格格式的 HTML: + +```python +# 必须转义属性中的双引号为 " +args_for_attr = args_json_str.replace('"', """) +result_for_attr = result_content.replace('"', """) + +tool_block = ( + f'\\n
\\n' + f"Tool Executed\\n" + f"
\\n\\n" +) +queue.put_nowait(tool_block) +``` + +- **致命避坑点 (Critical Pitfalls)**: + 1. **属性转义 (Extremely Important)**: `
` 内的 `arguments` 和 `result` 属性**必须**将内部的所有双引号 `"` 替换为 `"`。因为 OpenWebUI 前端提取这些数据的 Regex 是严格的 `="([^"]*)"`,一旦内容中出现原生双引号,就会被瞬间截断,导致参数被渲染为空并引发解析错误! + 2. **换行符要求**: `
` 尖括号闭合后紧接着的内容**必须换行**(即 `>\\n`),否则 Markdown 扩展引擎无法将其识别为独立的 UI Block。 + 3. **去除冗余通知**: 不要在 `tool.execution_start` 事件中提前向对话流输出普通的 `🔧 Executing...` 纯文本块,这会导致最终页面上同时出现两块工具提示(一个文本,一个折叠卡片)。 + +#### Debug 信息的解耦 (Decoupling Debug Logs) + +对于连接建立、运行环境、缓存加载等属于 *脚本自身运行状态* 的 Debug 信息: +- **禁止**: 不要将这些内容 yield 到最终的回答数据流(或塞进 `` 标签内),这会污染回答的纯粹性。 +- **推荐**: 统一使用 OpenWebUI 顶部的原生状态反馈气泡(Status Events): + ```python + await __event_emitter__({ + "type": "status", + "data": {"description": "连接建立,正在等待响应...", "done": True} + }) + ``` + --- ## ⚡ Action 插件规范 (Action Plugin Standards) diff --git a/docs/plugins/pipes/github-copilot-sdk.md b/docs/plugins/pipes/github-copilot-sdk.md index 77ffeaf..6cf091e 100644 --- a/docs/plugins/pipes/github-copilot-sdk.md +++ b/docs/plugins/pipes/github-copilot-sdk.md @@ -1,6 +1,6 @@ # GitHub Copilot SDK Pipe for OpenWebUI -**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.6.2 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **License:** MIT +**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.7.0 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **License:** MIT This is an advanced Pipe function for [OpenWebUI](https://github.com/open-webui/open-webui) that integrates the official [GitHub Copilot SDK](https://github.com/github/copilot-sdk). It enables you to use **GitHub Copilot models** (e.g., `gpt-5.2-codex`, `claude-sonnet-4.5`,`gemini-3-pro`, `gpt-5-mini`) **AND** your own models via **BYOK** (OpenAI, Anthropic) directly within OpenWebUI, providing a unified agentic experience with **strict User & Chat-level Workspace Isolation**. @@ -14,12 +14,13 @@ This is an advanced Pipe function for [OpenWebUI](https://github.com/open-webui/ --- -## ✨ v0.6.2 Updates (What's New) +## ✨ v0.7.0 Updates (What's New) -- **🛠️ New Workspace Artifacts Tool**: Introduced `publish_file_from_workspace`. Agents can now generate files (e.g., Python-generated Excel/CSV) and provide direct download links for the user to click and save. -- **⚙️ Workflow Optimization**: Improved reliability of the internal agentic workspace management. -- **🛡️ Enhanced Security**: Refined access control for system resources within the isolated environment. -- **🔧 Performance Tuning**: Optimized stream processing for larger context windows. +- **🚀 Integrated CLI Management**: The Copilot CLI is now automatically managed and bundled via the `github-copilot-sdk` pip package. (v0.7.0) +- **🧠 Native Tool Call UI**: Full adaptation to **OpenWebUI's native tool call UI** and thinking process visualization. (v0.7.0) +- **🏠 OpenWebUI v0.8.0+ Fix**: Resolved "Error getting file content" download failure by switching to absolute path registration for published files. (v0.7.0) +- **🌐 Comprehensive Multi-language Support**: Native localization for status messages in 11 languages (EN, ZH, JA, KO, FR, DE, ES, IT, RU, VI, ID). (v0.7.0) +- **🧹 Architecture Cleanup**: Refactored core setup and optimized reasoning status display for a leaner experience. (v0.7.0) --- @@ -31,8 +32,8 @@ This is an advanced Pipe function for [OpenWebUI](https://github.com/open-webui/ - **♾️ Infinite Session Management**: Smart context window management with automatic compaction for indefinite conversation capability. - **🧠 Deep Database Integration**: Real-time persistence of TOD·O lists for long-running workflows. - **🌊 Advanced Streaming**: Full support for thinking process/Chain of Thought visualization. -- **🖼️ Intelligent Multimodal**: Vision capabilities and raw file analysis support. -- **⚡ Full-Lifecycle File Agent**: Supports receiving uploaded files for raw bypass analysis and publishing results (Excel/reports) as downloadable links. +- **🖼️ Intelligent Multimodal**: Vision capabilities and raw file analysis support (bypasses RAG for direct binary access). +- **📤 Workspace Artifacts (`publish_file_from_workspace`)**: Agents can generate files (Excel, CSV, HTML reports, etc.) and provide **persistent download links** directly in the chat. - **🖼️ Interactive Artifacts**: Automatically renders HTML/JS apps generated by the agent directly in the chat interface. --- @@ -110,7 +111,7 @@ If this plugin has been useful, a **Star** on [OpenWebUI Extensions](https://git - **Agent ignores files?**: Ensure the Files Filter is enabled, otherwise RAG will interfere with raw binaries. - **No progress bar?**: The bar only appears when the Agent uses the `update_todo` tool. -- **Dependencies**: This Pipe automatically installs `github-copilot-sdk` (Python) and `github-copilot-cli` (Binary). +- **Dependencies**: This Pipe automatically manages `github-copilot-sdk` (Python) and utilizes the bundled binary CLI. No manual install required. --- diff --git a/docs/plugins/pipes/github-copilot-sdk.zh.md b/docs/plugins/pipes/github-copilot-sdk.zh.md index 1ab2e2a..5f620be 100644 --- a/docs/plugins/pipes/github-copilot-sdk.zh.md +++ b/docs/plugins/pipes/github-copilot-sdk.zh.md @@ -1,6 +1,6 @@ # GitHub Copilot SDK 官方管道 -**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.6.2 | **项目:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **许可证:** MIT +**作者:** [Fu-Jie](https://github.com/Fu-Jie) | **版本:** 0.7.0 | **项目:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **许可证:** MIT 这是一个用于 [OpenWebUI](https://github.com/open-webui/open-webui) 的高级 Pipe 函数,深度集成了 **GitHub Copilot SDK**。它不仅支持 **GitHub Copilot 官方模型**(如 `gpt-5.2-codex`, `claude-sonnet-4.5`, `gemini-3-pro`, `gpt-5-mini`),还支持 **BYOK (自带 Key)** 模式对接自定义服务商(OpenAI, Anthropic),并具备**严格的用户与会话级工作区隔离**能力,提供统一且安全的 Agent 交互体验。 @@ -14,12 +14,13 @@ --- -## ✨ 0.6.2 更新内容 (What's New) +## ✨ 0.7.0 更新内容 (What's New) -- **🛠️ 新增工作区产物工具**: 引入 `publish_file_from_workspace`。Agent 现在可以生成物理文件(如使用 Python 生成的 Excel/CSV 报表),并直接在聊天界面提供点击下载链接。 -- **⚙️ 工作流优化**: 提升了内部 Agent 物理工作区管理的可靠性与原子性。 -- **🛡️ 安全增强**: 精细化了隔离环境下系统资源的访问控制策略。 -- **🔧 性能微调**: 针对大上下文窗口优化了流式数据处理性能。 +- **🚀 CLI 免维护集成**: Copilot CLI 现在通过 `github-copilot-sdk` pip 包自动同步管理,彻底告别手动 `curl | bash` 安装问题。(v0.7.0) +- **🧠 原生工具调用 UI**: 全面适配 **OpenWebUI 原生工具调用 UI** 与模型思考过程(思维链)展示。(v0.7.0) +- **🏠 OpenWebUI v0.8.0+ 兼容性修复**: 通过切换为绝对路径注册发布文件,彻底解决了“Error getting file content”无法下载到本地的问题。(v0.7.0) +- **🌐 全面的多语言支持**: 针对状态消息进行了 11 国语言的原生本地化 (中/英/日/韩/法/德/西/意/俄/越/印尼)。(v0.7.0) +- **🧹 架构精简**: 重构了初始化逻辑并优化了推理状态显示,提供更轻量稳健的体验。(v0.7.0) --- @@ -31,8 +32,8 @@ - **♾️ 无限会话管理**: 智能上下文窗口管理与自动压缩算法,支持无限时长的对话交互。 - **🧠 深度数据库集成**: 实时持久化 TOD·O 列表到 UI 进度条。 - **🌊 深度推理展示**: 完整支持模型思考过程 (Thinking Process) 的流式渲染。 -- **🖼️ 智能多模态**: 完整支持图像识别与附件上传分析。 -- **⚡ 全生命周期文件 Agent**: 支持接收上传文件进行绕过 RAG 的深度分析,并将处理结果(如 Excel/报告)发布为下载链接实现闭环。 +- **🖼️ 智能多模态**: 完整支持图像识别与附件上传分析(绕过 RAG 直接访问原始二进制内容)。 +- **📤 工作区产物工具 (`publish_file_from_workspace`)**: Agent 可生成文件(Excel、CSV、HTML 报告等)并直接提供**持久化下载链接**。管理员还可额外获得通过 `/content/html` 接口的**聊天内 HTML 预览**链接。 - **🖼️ 交互式伪影 (Artifacts)**: 自动渲染 Agent 生成的 HTML/JS 应用程序,直接在聊天界面交互。 --- @@ -95,7 +96,7 @@ ### 1) 导入函数 1. 打开 OpenWebUI,前往 **工作区** -> **函数**。 -2. 点击 **+** (创建函数),完整粘贴 `github_copilot_sdk_cn.py` 的内容。 +2. 点击 **+** (创建函数),完整粘贴 `github_copilot_sdk.py` 的内容。 3. 点击保存并确保已启用。 ### 2) 获取 Token (Get Token) @@ -110,7 +111,7 @@ - **Agent 无法识别文件?**: 请确保已安装并启用了 Files Filter 插件,否则原始文件会被 RAG 干扰。 - **看不到 TODO 进度条?**: 进度条仅在 Agent 使用 `update_todo` 工具(通常是处理复杂任务)时出现。 -- **依赖安装**: 本管道会自动尝试安装 `github-copilot-sdk` (Python 包) 和 `github-copilot-cli` (官方二进制)。 +- **依赖安装**: 本管道会自动管理 `github-copilot-sdk` (Python 包) 并优先直接使用内置的二进制 CLI,无需手动干预。 --- diff --git a/docs/plugins/pipes/index.md b/docs/plugins/pipes/index.md index 7318fce..3630b7d 100644 --- a/docs/plugins/pipes/index.md +++ b/docs/plugins/pipes/index.md @@ -15,7 +15,7 @@ Pipes allow you to: ## Available Pipe Plugins -- [GitHub Copilot SDK](github-copilot-sdk.md) (v0.6.2) - Official GitHub Copilot SDK integration. Features **Workspace Isolation**, **Database Persistence**, **Zero-config OpenWebUI Tool Bridge**, **BYOK** support, and **dynamic MCP discovery**. Supports streaming, multimodal, and infinite sessions. [View Deep Dive](github-copilot-sdk-deep-dive.md) | [**View Advanced Tutorial**](github-copilot-sdk-tutorial.md). +- [GitHub Copilot SDK](github-copilot-sdk.md) (v0.7.0) - Official GitHub Copilot SDK integration. Features **Workspace Isolation**, **Database Persistence**, **Zero-config OpenWebUI Tool Bridge**, **BYOK** support, and **dynamic MCP discovery**. Supports streaming, multimodal, and infinite sessions. [View Deep Dive](github-copilot-sdk-deep-dive.md) | [**View Advanced Tutorial**](github-copilot-sdk-tutorial.md). - **[Case Study: GitHub 100 Star Growth Analysis](star-prediction-example.md)** - Learn how to use the GitHub Copilot SDK Pipe with Minimax 2.1 to automatically analyze CSV data and generate project growth reports. - **[Case Study: High-Quality Video to GIF Conversion](video-processing-example.md)** - See how the model uses system-level FFmpeg to accelerate, scale, and optimize colors for screen recordings. diff --git a/docs/plugins/pipes/index.zh.md b/docs/plugins/pipes/index.zh.md index 4b6a5d6..587ebba 100644 --- a/docs/plugins/pipes/index.zh.md +++ b/docs/plugins/pipes/index.zh.md @@ -15,7 +15,7 @@ Pipes 可以用于: ## 可用的 Pipe 插件 -- [GitHub Copilot SDK](github-copilot-sdk.zh.md) (v0.6.2) - GitHub Copilot SDK 官方集成。具备**工作区安全隔离**、**数据库持久化**、**零配置工具桥接**与**BYOK (自带 Key) 支持**。支持流式输出、打字机思考过程及无限会话。[查看深度架构解析](github-copilot-sdk-deep-dive.zh.md) | [**查看进阶实战教程**](github-copilot-sdk-tutorial.zh.md)。 +- [GitHub Copilot SDK](github-copilot-sdk.zh.md) (v0.7.0) - GitHub Copilot SDK 官方集成。具备**工作区安全隔离**、**数据库持久化**、**零配置工具桥接**与**BYOK (自带 Key) 支持**。支持流式输出、打字机思考过程及无限会话。[查看深度架构解析](github-copilot-sdk-deep-dive.zh.md) | [**查看进阶实战教程**](github-copilot-sdk-tutorial.zh.md)。 - **[实战案例:GitHub 100 Star 增长预测](star-prediction-example.zh.md)** - 展示如何使用 GitHub Copilot SDK Pipe 结合 Minimax 2.1 模型,自动编写脚本分析 CSV 数据并生成详细的项目增长报告。 - **[实战案例:视频高质量 GIF 转换与加速](video-processing-example.zh.md)** - 演示模型如何通过底层 FFmpeg 工具对录屏进行加速、缩放及双阶段色彩优化处理。 diff --git a/plugins/actions/smart-mind-map/smart_mind_map.py b/plugins/actions/smart-mind-map/smart_mind_map.py index 12a822b..9dfa969 100644 --- a/plugins/actions/smart-mind-map/smart_mind_map.py +++ b/plugins/actions/smart-mind-map/smart_mind_map.py @@ -9,6 +9,7 @@ icon_url: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAw description: Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge. """ +import asyncio import logging import os import re @@ -1514,6 +1515,7 @@ class Action: self, __user__: Optional[Dict[str, Any]], __event_call__: Optional[Callable[[Any], Awaitable[None]]] = None, + __request__: Optional[Request] = None, ) -> Dict[str, str]: """Extract basic user context with safe fallbacks.""" if isinstance(__user__, (list, tuple)): @@ -1528,20 +1530,36 @@ class Action: # Default from profile user_language = user_data.get("language", "en-US") - # Priority: Document Lang > LocalStorage (Frontend) > Browser > Profile (Default) + # Level 1 Fallback: Accept-Language from __request__ headers + if ( + __request__ + and hasattr(__request__, "headers") + and "accept-language" in __request__.headers + ): + raw_lang = __request__.headers.get("accept-language", "") + if raw_lang: + user_language = raw_lang.split(",")[0].split(";")[0] + + # Priority: Document Lang > LocalStorage (Frontend) > Browser > Request Header > Profile if __event_call__: try: js_code = """ - return ( - document.documentElement.lang || - localStorage.getItem('locale') || - localStorage.getItem('language') || - navigator.language || - 'en-US' - ); + try { + return ( + document.documentElement.lang || + localStorage.getItem('locale') || + localStorage.getItem('language') || + navigator.language || + 'en-US' + ); + } catch (e) { + return 'en-US'; + } """ - frontend_lang = await __event_call__( - {"type": "execute", "data": {"code": js_code}} + # Use asyncio.wait_for to prevent hanging if frontend fails to callback + frontend_lang = await asyncio.wait_for( + __event_call__({"type": "execute", "data": {"code": js_code}}), + timeout=2.0, ) if frontend_lang and isinstance(frontend_lang, str): user_language = frontend_lang @@ -2387,7 +2405,7 @@ class Action: __request__: Optional[Request] = None, ) -> Optional[dict]: logger.info("Action: Smart Mind Map (v1.0.0) started") - user_ctx = await self._get_user_context(__user__, __event_call__) + user_ctx = await self._get_user_context(__user__, __event_call__, __request__) user_language = user_ctx["user_language"] user_name = user_ctx["user_name"] user_id = user_ctx["user_id"] diff --git a/plugins/pipes/github-copilot-sdk/README.md b/plugins/pipes/github-copilot-sdk/README.md index 77ffeaf..60e9548 100644 --- a/plugins/pipes/github-copilot-sdk/README.md +++ b/plugins/pipes/github-copilot-sdk/README.md @@ -1,6 +1,6 @@ # GitHub Copilot SDK Pipe for OpenWebUI -**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.6.2 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **License:** MIT +**Author:** [Fu-Jie](https://github.com/Fu-Jie) | **Version:** 0.7.0 | **Project:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **License:** MIT This is an advanced Pipe function for [OpenWebUI](https://github.com/open-webui/open-webui) that integrates the official [GitHub Copilot SDK](https://github.com/github/copilot-sdk). It enables you to use **GitHub Copilot models** (e.g., `gpt-5.2-codex`, `claude-sonnet-4.5`,`gemini-3-pro`, `gpt-5-mini`) **AND** your own models via **BYOK** (OpenAI, Anthropic) directly within OpenWebUI, providing a unified agentic experience with **strict User & Chat-level Workspace Isolation**. @@ -14,12 +14,13 @@ This is an advanced Pipe function for [OpenWebUI](https://github.com/open-webui/ --- -## ✨ v0.6.2 Updates (What's New) +## ✨ v0.7.0 Updates (What's New) -- **🛠️ New Workspace Artifacts Tool**: Introduced `publish_file_from_workspace`. Agents can now generate files (e.g., Python-generated Excel/CSV) and provide direct download links for the user to click and save. -- **⚙️ Workflow Optimization**: Improved reliability of the internal agentic workspace management. -- **🛡️ Enhanced Security**: Refined access control for system resources within the isolated environment. -- **🔧 Performance Tuning**: Optimized stream processing for larger context windows. +- **🚀 Integrated CLI Management**: The Copilot CLI is now automatically managed and bundled via the `github-copilot-sdk` pip package. No more manual `curl | bash` installation or version mismatches. (v0.7.0) +- **🧠 Native Tool Call UI**: Full adaptation to **OpenWebUI's native tool call UI** and thinking process visualization. (v0.7.0) +- **🏠 OpenWebUI v0.8.0+ Fix**: Resolved "Error getting file content" download failure by switching to absolute path registration for published files. (v0.7.0) +- **🌐 Comprehensive Multi-language Support**: Native localization for status messages in 11 languages (EN, ZH, JA, KO, FR, DE, ES, IT, RU, VI, ID). (v0.7.0) +- **🧹 Architecture Cleanup**: Refactored core setup and optimized reasoning status display for a leaner experience. (v0.7.0) --- @@ -31,8 +32,8 @@ This is an advanced Pipe function for [OpenWebUI](https://github.com/open-webui/ - **♾️ Infinite Session Management**: Smart context window management with automatic compaction for indefinite conversation capability. - **🧠 Deep Database Integration**: Real-time persistence of TOD·O lists for long-running workflows. - **🌊 Advanced Streaming**: Full support for thinking process/Chain of Thought visualization. -- **🖼️ Intelligent Multimodal**: Vision capabilities and raw file analysis support. -- **⚡ Full-Lifecycle File Agent**: Supports receiving uploaded files for raw bypass analysis and publishing results (Excel/reports) as downloadable links. +- **🖼️ Intelligent Multimodal**: Vision capabilities and raw file analysis support (bypasses RAG for direct binary access). +- **📤 Workspace Artifacts (`publish_file_from_workspace`)**: Agents can generate files (Excel, CSV, HTML reports, etc.) and provide **persistent download links** directly in the chat. - **🖼️ Interactive Artifacts**: Automatically renders HTML/JS apps generated by the agent directly in the chat interface. --- @@ -110,7 +111,7 @@ If this plugin has been useful, a **Star** on [OpenWebUI Extensions](https://git - **Agent ignores files?**: Ensure the Files Filter is enabled, otherwise RAG will interfere with raw binaries. - **No progress bar?**: The bar only appears when the Agent uses the `update_todo` tool. -- **Dependencies**: This Pipe automatically installs `github-copilot-sdk` (Python) and `github-copilot-cli` (Binary). +- **Dependencies**: This Pipe automatically manages `github-copilot-sdk` (Python) and utilizes the bundled binary CLI. No manual install required. --- diff --git a/plugins/pipes/github-copilot-sdk/README_CN.md b/plugins/pipes/github-copilot-sdk/README_CN.md index 8a213bc..d07f135 100644 --- a/plugins/pipes/github-copilot-sdk/README_CN.md +++ b/plugins/pipes/github-copilot-sdk/README_CN.md @@ -1,6 +1,6 @@ # GitHub Copilot SDK 官方管道 -**作者:** [Fu-Jie](https://github.com/Fu-Jie/openwebui-extensions) | **版本:** 0.6.2 | **项目:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **许可证:** MIT +**作者:** [Fu-Jie](https://github.com/Fu-Jie/openwebui-extensions) | **版本:** 0.7.0 | **项目:** [OpenWebUI Extensions](https://github.com/Fu-Jie/openwebui-extensions) | **许可证:** MIT 这是一个用于 [OpenWebUI](https://github.com/open-webui/open-webui) 的高级 Pipe 函数,深度集成了 **GitHub Copilot SDK**。它不仅支持 **GitHub Copilot 官方模型**(如 `gpt-5.2-codex`, `claude-sonnet-4.5`, `gemini-3-pro`, `gpt-5-mini`),还支持 **BYOK (自带 Key)** 模式对接自定义服务商(OpenAI, Anthropic),并具备**严格的用户与会话级工作区隔离**能力,提供统一且安全的 Agent 交互体验。 @@ -14,12 +14,13 @@ --- -## ✨ 0.6.2 更新内容 (What's New) +## ✨ 0.7.0 更新内容 (What's New) -- **🛠️ 新增工作区产物工具**: 引入 `publish_file_from_workspace`。Agent 现在可以生成物理文件(如使用 Python 生成的 Excel/CSV 报表),并直接在聊天界面提供点击下载链接。 -- **⚙️ 工作流优化**: 提升了内部 Agent 物理工作区管理的可靠性与原子性。 -- **🛡️ 安全增强**: 精细化了隔离环境下系统资源的访问控制策略。 -- **🔧 性能微调**: 针对大上下文窗口优化了流式数据处理性能。 +- **🚀 CLI 免维护集成**: Copilot CLI 现在通过 `github-copilot-sdk` pip 包自动同步管理,彻底告别手动 `curl | bash` 安装及版本不匹配问题。(v0.7.0) +- **🧠 原生工具调用 UI**: 全面适配 **OpenWebUI 原生工具调用 UI** 与模型思考过程(思维链)展示。(v0.7.0) +- **🏠 OpenWebUI v0.8.0+ 兼容性修复**: 通过切换为绝对路径注册发布文件,彻底解决了“Error getting file content”无法下载到本地的问题。(v0.7.0) +- **🌐 全面的多语言支持**: 针对状态消息进行了 11 国语言的原生本地化 (中/英/日/韩/法/德/西/意/俄/越/印尼)。(v0.7.0) +- **🧹 架构精简**: 重构了初始化逻辑并优化了推理状态显示,提供更轻量稳健的体验。(v0.7.0) --- @@ -31,8 +32,8 @@ - **♾️ 无限会话管理**: 智能上下文窗口管理与自动压缩算法,支持无限时长的对话交互。 - **🧠 深度数据库集成**: 实时持久化 TOD·O 列表到 UI 进度条。 - **🌊 深度推理展示**: 完整支持模型思考过程 (Thinking Process) 的流式渲染。 -- **🖼️ 智能多模态**: 完整支持图像识别与附件上传分析。 -- **⚡ 全生命周期文件 Agent**: 支持接收上传文件进行绕过 RAG 的深度分析,并将处理结果(如 Excel/报告)发布为下载链接。 +- **🖼️ 智能多模态**: 完整支持图像识别与附件上传分析(绕过 RAG 直接访问原始二进制内容)。 +- **📤 工作区产物工具 (`publish_file_from_workspace`)**: Agent 可生成文件(Excel、CSV、HTML 报告等)并直接在聊天中提供**持久化下载链接**。 - **🖼️ 交互式伪影 (Artifacts)**: 自动渲染 Agent 生成的 HTML/JS 应用程序,直接在聊天界面交互。 --- @@ -95,7 +96,7 @@ ### 1) 导入函数 1. 打开 OpenWebUI,前往 **工作区** -> **函数**。 -2. 点击 **+** (创建函数),完整粘贴 `github_copilot_sdk_cn.py` 的内容。 +2. 点击 **+** (创建函数),完整粘贴 `github_copilot_sdk.py` 的内容。 3. 点击保存并确保已启用。 ### 2) 获取 Token (Get Token) @@ -114,7 +115,7 @@ - **Agent 无法识别文件?**: 请确保已安装并启用了 Files Filter 插件,否则原始文件会被 RAG 干扰。 - **看不到 TODO 进度条?**: 进度条仅在 Agent 使用 `update_todo` 工具(通常是处理复杂任务)时出现。 -- **依赖安装**: 本管道会自动尝试安装 `github-copilot-sdk` (Python 包) 和 `github-copilot-cli` (官方二进制)。 +- **依赖安装**: 本管道会自动管理 `github-copilot-sdk` (Python 包) 并优先直接使用内置的二进制 CLI,无需手动干预。 --- diff --git a/plugins/pipes/github-copilot-sdk/github_copilot_sdk.py b/plugins/pipes/github-copilot-sdk/github_copilot_sdk.py index da6d9e2..b2f4650 100644 --- a/plugins/pipes/github-copilot-sdk/github_copilot_sdk.py +++ b/plugins/pipes/github-copilot-sdk/github_copilot_sdk.py @@ -1,12 +1,12 @@ """ title: GitHub Copilot Official SDK Pipe author: Fu-Jie -author_url: https://github.com/Fu-Jie/awesome-openwebui +author_url: https://github.com/Fu-Jie/openwebui-extensions funding_url: https://github.com/open-webui openwebui_id: ce96f7b4-12fc-4ac3-9a01-875713e69359 description: Integrate GitHub Copilot SDK. Supports dynamic models, multi-turn conversation, streaming, multimodal input, infinite sessions, and frontend debug logging. -version: 0.6.2 -requirements: github-copilot-sdk==0.1.23 +version: 0.7.0 +requirements: github-copilot-sdk==0.1.25 """ import os @@ -226,10 +226,7 @@ class Pipe: default=300, description="Timeout for each stream chunk (seconds)", ) - COPILOT_CLI_VERSION: str = Field( - default="0.0.406", - description="Specific Copilot CLI version to install/enforce (e.g. '0.0.406'). Leave empty for latest.", - ) + EXCLUDE_KEYWORDS: str = Field( default="", description="Exclude models containing these keywords (comma separated, e.g.: codex, haiku)", @@ -360,6 +357,116 @@ class Pipe: _env_setup_done = False # Track if env setup has been completed _last_update_check = 0 # Timestamp of last CLI update check + TRANSLATIONS = { + "en-US": { + "status_conn_est": "Connection established, waiting for response...", + "status_reasoning_inj": "Reasoning Effort injected: {effort}", + "debug_agent_working_in": "Agent working in: {path}", + "debug_mcp_servers": "🔌 Connected MCP Servers: {servers}", + "publish_success": "File published successfully.", + "publish_hint_html": "Link: [View {filename}]({view_url}) | [Download]({download_url})", + "publish_hint_default": "Link: [Download {filename}]({download_url})", + }, + "zh-CN": { + "status_conn_est": "已建立连接,等待响应...", + "status_reasoning_inj": "已注入推理级别:{effort}", + "debug_agent_working_in": "Agent 工作目录: {path}", + "debug_mcp_servers": "🔌 已连接 MCP 服务器: {servers}", + "publish_success": "文件发布成功。", + "publish_hint_html": "链接: [查看 {filename}]({view_url}) | [下载]({download_url})", + "publish_hint_default": "链接: [下载 {filename}]({download_url})", + }, + "zh-HK": { + "status_conn_est": "已建立連接,等待響應...", + "status_reasoning_inj": "已注入推理級別:{effort}", + "debug_agent_working_in": "Agent 工作目錄: {path}", + "debug_mcp_servers": "🔌 已連接 MCP 伺服器: {servers}", + "publish_success": "文件發布成功。", + "publish_hint_html": "連結: [查看 {filename}]({view_url}) | [下載]({download_url})", + "publish_hint_default": "連結: [下載 {filename}]({download_url})", + }, + "zh-TW": { + "status_conn_est": "已建立連接,等待響應...", + "status_reasoning_inj": "已注入推理級別:{effort}", + "debug_agent_working_in": "Agent 工作目錄: {path}", + "debug_mcp_servers": "🔌 已連接 MCP 伺服器: {servers}", + "publish_success": "文件發布成功。", + "publish_hint_html": "連結: [查看 {filename}]({view_url}) | [下載]({download_url})", + "publish_hint_default": "連結: [下載 {filename}]({download_url})", + }, + "ja-JP": { + "status_conn_est": "接続が確立されました。応答を待っています...", + "status_reasoning_inj": "推論レベルが注入されました:{effort}", + "debug_agent_working_in": "Agent 作業ディレクトリ: {path}", + "debug_mcp_servers": "🔌 接続済み MCP サーバー: {servers}", + }, + "ko-KR": { + "status_conn_est": "연결이 설정되었습니다. 응답을 기다리는 중...", + "status_reasoning_inj": "추론 수준 설정됨: {effort}", + "debug_agent_working_in": "Agent 작업 디렉토리: {path}", + "debug_mcp_servers": "🔌 연결된 MCP 서버: {servers}", + }, + "fr-FR": { + "status_conn_est": "Connexion établie, en attente de réponse...", + "status_reasoning_inj": "Effort de raisonnement injecté : {effort}", + "debug_agent_working_in": "Répertoire de travail de l'Agent : {path}", + "debug_mcp_servers": "🔌 Serveurs MCP connectés : {servers}", + }, + "de-DE": { + "status_conn_est": "Verbindung hergestellt, warte auf Antwort...", + "status_reasoning_inj": "Argumentationsaufwand injiziert: {effort}", + "debug_agent_working_in": "Agent-Arbeitsverzeichnis: {path}", + "debug_mcp_servers": "🔌 Verbundene MCP-Server: {servers}", + }, + "es-ES": { + "status_conn_est": "Conexión establecida, esperando respuesta...", + "status_reasoning_inj": "Nivel de razonamiento inyectado: {effort}", + "debug_agent_working_in": "Directorio de trabajo del Agente: {path}", + "debug_mcp_servers": "🔌 Servidores MCP conectados: {servers}", + }, + "it-IT": { + "status_conn_est": "Connessione stabilita, in attesa di risposta...", + "status_reasoning_inj": "Livello di ragionamento iniettato: {effort}", + "debug_agent_working_in": "Directory di lavoro dell'Agente: {path}", + "debug_mcp_servers": "🔌 Server MCP connessi: {servers}", + }, + "ru-RU": { + "status_conn_est": "Соединение установлено, ожидание ответа...", + "status_reasoning_inj": "Уровень рассуждения внедрен: {effort}", + "debug_agent_working_in": "Рабочий каталог Агента: {path}", + "debug_mcp_servers": "🔌 Подключенные серверы MCP: {servers}", + }, + "vi-VN": { + "status_conn_est": "Đã thiết lập kết nối, đang chờ phản hồi...", + "status_reasoning_inj": "Cấp độ suy luận đã được áp dụng: {effort}", + "debug_agent_working_in": "Thư mục làm việc của Agent: {path}", + "debug_mcp_servers": "🔌 Các máy chủ MCP đã kết nối: {servers}", + }, + "id-ID": { + "status_conn_est": "Koneksi terjalin, menunggu respons...", + "status_reasoning_inj": "Tingkat penalaran diterapkan: {effort}", + "debug_agent_working_in": "Direktori kerja Agent: {path}", + "debug_mcp_servers": "🔌 Server MCP yang terhubung: {servers}", + }, + } + + FALLBACK_MAP = { + "zh": "zh-CN", + "zh-TW": "zh-TW", + "zh-HK": "zh-HK", + "en": "en-US", + "en-GB": "en-US", + "ja": "ja-JP", + "ko": "ko-KR", + "fr": "fr-FR", + "de": "de-DE", + "es": "es-ES", + "it": "it-IT", + "ru": "ru-RU", + "vi": "vi-VN", + "id": "id-ID", + } + def __init__(self): self.type = "pipe" self.id = "github_copilot_sdk" @@ -390,6 +497,83 @@ class Pipe: except Exception as e: logger.error(f"[Database] ❌ Initialization failed: {str(e)}") + def _resolve_language(self, user_language: str) -> str: + """Normalize user language code to a supported translation key.""" + if not user_language: + return "en-US" + if user_language in self.TRANSLATIONS: + return user_language + lang_base = user_language.split("-")[0] + if user_language in self.FALLBACK_MAP: + return self.FALLBACK_MAP[user_language] + if lang_base in self.FALLBACK_MAP: + return self.FALLBACK_MAP[lang_base] + return "en-US" + + def _get_translation(self, lang: str, key: str, **kwargs) -> str: + """Helper function to get translated string for a key.""" + lang_key = self._resolve_language(lang) + trans_map = self.TRANSLATIONS.get(lang_key, self.TRANSLATIONS["en-US"]) + text = trans_map.get(key, self.TRANSLATIONS["en-US"].get(key, key)) + if kwargs: + try: + text = text.format(**kwargs) + except Exception as e: + logger.warning(f"Translation formatting failed for {key}: {e}") + return text + + async def _get_user_context(self, __user__, __event_call__=None, __request__=None): + """Extract basic user context with safe fallbacks including JS localStorage.""" + if isinstance(__user__, (list, tuple)): + user_data = __user__[0] if __user__ else {} + elif isinstance(__user__, dict): + user_data = __user__ + else: + user_data = {} + + user_id = user_data.get("id", "unknown_user") + user_name = user_data.get("name", "User") + user_language = user_data.get("language", "en-US") + + if ( + __request__ + and hasattr(__request__, "headers") + and "accept-language" in __request__.headers + ): + raw_lang = __request__.headers.get("accept-language", "") + if raw_lang: + user_language = raw_lang.split(",")[0].split(";")[0] + + if __event_call__: + try: + js_code = """ + try { + return ( + document.documentElement.lang || + localStorage.getItem('locale') || + localStorage.getItem('language') || + navigator.language || + 'en-US' + ); + } catch (e) { + return 'en-US'; + } + """ + frontend_lang = await asyncio.wait_for( + __event_call__({"type": "execute", "data": {"code": js_code}}), + timeout=2.0, + ) + if frontend_lang and isinstance(frontend_lang, str): + user_language = frontend_lang + except Exception as e: + pass + + return { + "user_id": user_id, + "user_name": user_name, + "user_language": user_language, + } + @contextlib.contextmanager def _db_session(self): """Yield a database session using Open WebUI helpers with graceful fallbacks.""" @@ -611,6 +795,8 @@ class Pipe: user_data = {} user_id = user_data.get("id") or user_data.get("user_id") + user_lang = user_data.get("language") or "en-US" + is_admin = user_data.get("role") == "admin" if not user_id: return None @@ -746,10 +932,7 @@ class Pipe: dest_path = Path(UPLOAD_DIR) / f"{file_id}_{safe_filename}" await asyncio.to_thread(shutil.copy2, target_path, dest_path) - try: - db_path = str(os.path.relpath(dest_path, DATA_DIR)) - except: - db_path = str(dest_path) + db_path = str(dest_path) file_form = FileForm( id=file_id, @@ -769,12 +952,37 @@ class Pipe: # 5. Result download_url = f"/api/v1/files/{file_id}/content" + view_url = download_url + is_html = safe_filename.lower().endswith(".html") + + # For HTML files, if user is admin, provide a direct view link (/content/html) + if is_html and is_admin: + view_url = f"{download_url}/html" + + # Localized output + msg = self._get_translation(user_lang, "publish_success") + if is_html and is_admin: + hint = self._get_translation( + user_lang, + "publish_hint_html", + filename=safe_filename, + view_url=view_url, + download_url=download_url, + ) + else: + hint = self._get_translation( + user_lang, + "publish_hint_default", + filename=safe_filename, + download_url=download_url, + ) + return { "file_id": file_id, "filename": safe_filename, "download_url": download_url, - "message": "File published successfully.", - "hint": f"Link: [Download {safe_filename}]({download_url})", + "message": msg, + "hint": hint, } except Exception as e: return {"error": str(e)} @@ -1921,10 +2129,6 @@ class Pipe: "on_post_tool_use": on_post_tool_use, } - def _get_user_context(self): - """Helper to get user context (placeholder for future use).""" - return {} - def _get_chat_context( self, body: dict, @@ -2327,25 +2531,11 @@ class Pipe: token: str = None, enable_mcp: bool = True, enable_cache: bool = True, - skip_cli_install: bool = False, + skip_cli_install: bool = False, # Kept for call-site compatibility, no longer used __event_emitter__=None, + user_lang: str = "en-US", ): - """Setup environment variables and verify Copilot CLI. Dynamic Token Injection.""" - def emit_status_sync(description: str, done: bool = False): - if not __event_emitter__: - return - try: - loop = asyncio.get_running_loop() - loop.create_task( - __event_emitter__( - { - "type": "status", - "data": {"description": description, "done": done}, - } - ) - ) - except Exception: - pass + """Setup environment variables and resolve Copilot CLI path from SDK bundle.""" # 1. Real-time Token Injection (Always updates on each call) effective_token = token or self.valves.GH_TOKEN @@ -2353,8 +2543,6 @@ class Pipe: os.environ["GH_TOKEN"] = os.environ["GITHUB_TOKEN"] = effective_token if self._env_setup_done: - # If done, we only sync MCP if called explicitly or in debug mode - # To improve speed, we avoid redundant file I/O here for regular requests if debug_enabled: self._sync_mcp_config( __event_call__, @@ -2365,186 +2553,46 @@ class Pipe: return os.environ["COPILOT_AUTO_UPDATE"] = "false" - self._emit_debug_log_sync( - "Disabled CLI auto-update (COPILOT_AUTO_UPDATE=false)", - __event_call__, - debug_enabled=debug_enabled, - ) - # 2. CLI Path Discovery - cli_path = "/usr/local/bin/copilot" - if os.environ.get("COPILOT_CLI_PATH"): - cli_path = os.environ["COPILOT_CLI_PATH"] - - target_version = self.valves.COPILOT_CLI_VERSION.strip() - found = False - current_version = None - - def get_cli_version(path): - try: - output = ( - subprocess.check_output( - [path, "--version"], stderr=subprocess.STDOUT - ) - .decode() - .strip() - ) - import re - - match = re.search(r"(\d+\.\d+\.\d+)", output) - return match.group(1) if match else output - except Exception: - return None - - # Check existing version - if os.path.exists(cli_path): - found = True - current_version = get_cli_version(cli_path) + # 2. CLI Path Discovery (priority: env var > PATH > SDK bundle) + cli_path = os.environ.get("COPILOT_CLI_PATH", "") + found = bool(cli_path and os.path.exists(cli_path)) if not found: sys_path = shutil.which("copilot") if sys_path: cli_path = sys_path found = True - current_version = get_cli_version(cli_path) if not found: - pkg_path = os.path.join(os.path.dirname(__file__), "bin", "copilot") - if os.path.exists(pkg_path): - cli_path = pkg_path - found = True - current_version = get_cli_version(cli_path) - - # 3. Installation/Update Logic - should_install = not found - install_reason = "CLI not found" - if found and target_version: - norm_target = target_version.lstrip("v") - norm_current = current_version.lstrip("v") if current_version else "" - - # Only install if target version is GREATER than current version try: - from packaging.version import parse as parse_version + from copilot.client import _get_bundled_cli_path - if parse_version(norm_target) > parse_version(norm_current): - should_install = True - install_reason = ( - f"Upgrade needed ({current_version} -> {target_version})" - ) - elif parse_version(norm_target) < parse_version(norm_current): - self._emit_debug_log_sync( - f"Current version ({current_version}) is newer than specified ({target_version}). Skipping downgrade.", - __event_call__, - debug_enabled=debug_enabled, - ) - except Exception as e: - # Fallback to string comparison if packaging is not available - if norm_target != norm_current: - should_install = True - install_reason = ( - f"Version mismatch ({current_version} != {target_version})" - ) + bundled_path = _get_bundled_cli_path() + if bundled_path and os.path.exists(bundled_path): + cli_path = bundled_path + found = True + except ImportError: + pass - if should_install and not skip_cli_install: - self._emit_debug_log_sync( - f"Installing/Updating Copilot CLI: {install_reason}...", - __event_call__, - debug_enabled=debug_enabled, - ) - emit_status_sync( - "🔧 正在安装/更新 Copilot CLI(首次可能需要 1-3 分钟)...", - done=False, - ) - try: - env = os.environ.copy() - if target_version: - env["VERSION"] = target_version - proc = subprocess.Popen( - "curl -fsSL https://gh.io/copilot-install | bash", - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - env=env, - ) - - progress_percent = -1 - line_count = 0 - while True: - raw_line = proc.stdout.readline() if proc.stdout else "" - if raw_line == "" and proc.poll() is not None: - break - - line = (raw_line or "").strip() - if not line: - continue - - line_count += 1 - percent_match = re.search(r"(\d{1,3})%", line) - if percent_match: - try: - pct = int(percent_match.group(1)) - if pct >= progress_percent + 5: - progress_percent = pct - emit_status_sync( - f"📦 Copilot CLI 安装中:{pct}%", done=False - ) - except Exception: - pass - elif line_count % 20 == 0: - emit_status_sync( - f"📦 Copilot CLI 安装中:{line[:120]}", done=False - ) - - return_code = proc.wait() - if return_code != 0: - raise subprocess.CalledProcessError( - return_code, - "curl -fsSL https://gh.io/copilot-install | bash", - ) - - # Re-verify - current_version = get_cli_version(cli_path) - emit_status_sync( - f"✅ Copilot CLI 安装完成(v{current_version or target_version or 'latest'})", - done=False, - ) - except Exception as e: - self._emit_debug_log_sync( - f"CLI installation failed: {e}", - __event_call__, - debug_enabled=debug_enabled, - ) - emit_status_sync( - f"❌ Copilot CLI 安装失败:{str(e)[:120]}", - done=True, - ) - elif should_install and skip_cli_install: - self._emit_debug_log_sync( - f"Skipping CLI install during model listing: {install_reason}", - __event_call__, - debug_enabled=debug_enabled, - ) - - # 4. Finalize - cli_ready = bool(cli_path and os.path.exists(cli_path)) + # 3. Finalize + cli_ready = found if cli_ready: os.environ["COPILOT_CLI_PATH"] = cli_path + # Add the CLI's parent directory to PATH so subprocesses can invoke `copilot` directly + cli_bin_dir = os.path.dirname(cli_path) + current_path = os.environ.get("PATH", "") + if cli_bin_dir and cli_bin_dir not in current_path.split(os.pathsep): + os.environ["PATH"] = cli_bin_dir + os.pathsep + current_path self.__class__._env_setup_done = cli_ready self.__class__._last_update_check = datetime.now().timestamp() self._emit_debug_log_sync( - f"Environment setup complete. CLI ready={cli_ready}. Path: {cli_path} (v{current_version})", + f"Environment setup complete. CLI ready={cli_ready}. Path: {cli_path}", __event_call__, debug_enabled=debug_enabled, ) - if not skip_cli_install: - if cli_ready: - emit_status_sync("✅ Copilot CLI 已就绪", done=True) - else: - emit_status_sync("⚠️ Copilot CLI 尚未就绪,请稍后重试。", done=True) def _process_attachments( self, @@ -2822,6 +2870,9 @@ class Pipe: effective_mcp = user_valves.ENABLE_MCP_SERVER effective_cache = user_valves.ENABLE_TOOL_CACHE + user_ctx = await self._get_user_context(__user__, __event_call__, __request__) + user_lang = user_ctx["user_language"] + # 2. Setup environment with effective settings self._setup_env( __event_call__, @@ -2830,11 +2881,12 @@ class Pipe: enable_mcp=effective_mcp, enable_cache=effective_cache, __event_emitter__=__event_emitter__, + user_lang=user_lang, ) cwd = self._get_workspace_dir(user_id=user_id, chat_id=chat_id) await self._emit_debug_log( - f"Agent working in: {cwd} (Admin: {is_admin}, MCP: {effective_mcp})", + f"{self._get_translation(user_lang, 'debug_agent_working_in', path=cwd)} (Admin: {is_admin}, MCP: {effective_mcp})", __event_call__, debug_enabled=effective_debug, ) @@ -3269,9 +3321,9 @@ class Pipe: if body.get("stream", False): init_msg = "" if effective_debug: - init_msg = f"> [Debug] Agent working in: {self._get_workspace_dir(user_id=user_id, chat_id=chat_id)}\n" + init_msg = f"> [Debug] {self._get_translation(user_lang, 'debug_agent_working_in', path=self._get_workspace_dir(user_id=user_id, chat_id=chat_id))}\n" if mcp_server_names: - init_msg += f"> [Debug] 🔌 Connected MCP Servers: {', '.join(mcp_server_names)}\n" + init_msg += f"> [Debug] {self._get_translation(user_lang, 'debug_mcp_servers', servers=', '.join(mcp_server_names))}\n" # Transfer client ownership to stream_response should_stop_client = False @@ -3284,9 +3336,14 @@ class Pipe: init_message=init_msg, __event_call__=__event_call__, __event_emitter__=__event_emitter__, - reasoning_effort=effective_reasoning_effort, + reasoning_effort=( + effective_reasoning_effort + if (is_reasoning and not is_byok_model) + else "off" + ), show_thinking=show_thinking, debug_enabled=effective_debug, + user_lang=user_lang, ) else: try: @@ -3332,6 +3389,7 @@ class Pipe: reasoning_effort: str = "", show_thinking: bool = True, debug_enabled: bool = False, + user_lang: str = "en-US", ) -> AsyncGenerator: """ Stream response from Copilot SDK, handling various event types. @@ -3476,14 +3534,8 @@ class Pipe: queue.put_nowait("\n\n") state["thinking_started"] = False - # Display tool call with improved formatting - if tool_args: - tool_args_json = json.dumps(tool_args, indent=2, ensure_ascii=False) - tool_display = f"\n\n
\n🔧 Executing Tool: {tool_name}\n\n**Parameters:**\n\n```json\n{tool_args_json}\n```\n\n
\n\n" - else: - tool_display = f"\n\n
\n🔧 Executing Tool: {tool_name}\n\n*No parameters*\n\n
\n\n" - - queue.put_nowait(tool_display) + # Note: We do NOT emit a done="false" card here to avoid card duplication + # (unless we have a way to update text which SSE content stream doesn't) self._emit_debug_log_sync( f"Tool Start: {tool_name}", @@ -3600,31 +3652,55 @@ class Pipe: ) # ------------------------ - # Try to detect content type for better formatting - is_json = False - try: - json_obj = ( - json.loads(result_content) - if isinstance(result_content, str) - else result_content + # --- Build native OpenWebUI 0.8.3 tool_calls block --- + # Serialize input args (from execution_start) + tool_args_for_block = {} + if tool_call_id and tool_call_id in active_tools: + tool_args_for_block = active_tools[tool_call_id].get( + "arguments", {} ) - if isinstance(json_obj, (dict, list)): - result_content = json.dumps( - json_obj, indent=2, ensure_ascii=False - ) - is_json = True - except: - pass - # Format based on content type - if is_json: - # JSON content: use code block with syntax highlighting - result_display = f"\n
\n{status_icon} Tool Result: {tool_name}\n\n```json\n{result_content}\n```\n\n
\n\n" - else: - # Plain text: use text code block to preserve formatting and add line breaks - result_display = f"\n
\n{status_icon} Tool Result: {tool_name}\n\n```text\n{result_content}\n```\n\n
\n\n" + try: + args_json_str = json.dumps( + tool_args_for_block, ensure_ascii=False + ) + except Exception: + args_json_str = "{}" - queue.put_nowait(result_display) + def escape_html_attr(s: str) -> str: + if not isinstance(s, str): + return "" + return ( + str(s) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("\n", " ") + .replace("\r", " ") + ) + + # MUST escape both arguments and result with " and to satisfy OpenWebUI's strict regex /="([^"]*)"/ + # OpenWebUI `marked` extension does not match multiline attributes properly without + args_for_attr = ( + escape_html_attr(args_json_str) if args_json_str else "{}" + ) + result_for_attr = escape_html_attr(result_content) + + # Emit the unified native tool_calls block: + # OpenWebUI 0.8.3 frontend regex explicitly expects: name="xxx" arguments="..." result="..." done="true" + # CRITICAL:
tag MUST be followed immediately by \n for the frontend Markdown extension to parse it! + tool_block = ( + f'\n
\n' + f"Tool Executed\n" + f"
\n\n" + ) + queue.put_nowait(tool_block) elif event_type == "tool.execution_progress": # Tool execution progress update (for long-running tools) @@ -3725,20 +3801,42 @@ class Pipe: # Safe initial yield with error handling try: - if debug_enabled and show_thinking: - yield "\n" + if debug_enabled and __event_emitter__: + # Emit debug info as UI status rather than reasoning block + async def _emit_status(key: str, desc: str = None, **kwargs): + try: + final_desc = ( + desc + if desc + else self._get_translation(user_lang, key, **kwargs) + ) + await __event_emitter__( + { + "type": "status", + "data": {"description": final_desc, "done": True}, + } + ) + except: + pass + if init_message: - yield init_message + for line in init_message.split("\n"): + if line.strip(): + clean_msg = line.replace("> [Debug] ", "").strip() + asyncio.create_task(_emit_status("custom", desc=clean_msg)) if reasoning_effort and reasoning_effort != "off": - yield f"> [Debug] Reasoning Effort injected: {reasoning_effort.upper()}\n" + asyncio.create_task( + _emit_status( + "status_reasoning_inj", effort=reasoning_effort.upper() + ) + ) - yield "> [Debug] Connection established, waiting for response...\n" - state["thinking_started"] = True + asyncio.create_task(_emit_status("status_conn_est")) except Exception as e: # If initial yield fails, log but continue processing self._emit_debug_log_sync( - f"Initial yield warning: {e}", + f"Initial status warning: {e}", __event_call__, debug_enabled=debug_enabled, ) @@ -3766,12 +3864,21 @@ class Pipe: except asyncio.TimeoutError: if done.is_set(): break - if state["thinking_started"]: + if __event_emitter__ and debug_enabled: try: - yield f"> [Debug] Waiting for response ({self.valves.TIMEOUT}s exceeded)...\n" + asyncio.create_task( + __event_emitter__( + { + "type": "status", + "data": { + "description": f"Waiting for response ({self.valves.TIMEOUT}s exceeded)...", + "done": True, + }, + } + ) + ) except: - # If yield fails during timeout, connection is gone - break + pass continue while not queue.empty(): diff --git a/plugins/pipes/github-copilot-sdk/github_copilot_sdk_cn.py b/plugins/pipes/github-copilot-sdk/github_copilot_sdk_cn.py deleted file mode 100644 index e7e7aa8..0000000 --- a/plugins/pipes/github-copilot-sdk/github_copilot_sdk_cn.py +++ /dev/null @@ -1,1736 +0,0 @@ -""" -title: GitHub Copilot SDK 官方管道 -author: Fu-Jie -author_url: https://github.com/Fu-Jie/openwebui-extensions -funding_url: https://github.com/open-webui -description: 集成 GitHub Copilot SDK。支持动态模型、多选提供商、流式输出、多模态 input、无限会话及前端调试日志。 -version: 0.6.2 -requirements: github-copilot-sdk==0.1.23 -""" - -import os -import re -import json -import base64 -import tempfile -import asyncio -import logging -import shutil -import subprocess -import hashlib -import aiohttp -from pathlib import Path -from typing import Optional, Union, AsyncGenerator, List, Any, Dict, Literal, Tuple -from types import SimpleNamespace -from pydantic import BaseModel, Field, create_model - -# 导入 Copilot SDK 模块 -from copilot import CopilotClient, define_tool - -# 导入 Tool Server Connections 和 Tool System (从 OpenWebUI 配置) -from open_webui.config import ( - PERSISTENT_CONFIG_REGISTRY, - TOOL_SERVER_CONNECTIONS, -) -from open_webui.utils.tools import get_tools as get_openwebui_tools, get_builtin_tools -from open_webui.models.tools import Tools -from open_webui.models.users import Users -from open_webui.models.files import Files, FileForm -from open_webui.config import UPLOAD_DIR, DATA_DIR -import mimetypes -import uuid -import shutil - -# Setup logger -logger = logging.getLogger(__name__) - -FORMATTING_GUIDELINES = ( - "\n\n[环境与能力上下文]\n" - "你是一个在特定高性能环境中运行的 AI 助手。了解你的上下文对于做出最佳决策至关重要。\n" - "\n" - "**系统环境:**\n" - "- **平台**:你在 **OpenWebUI** 托管的 Linux 容器化环境中运行。\n" - "- **核心引擎**:你由 **GitHub Copilot SDK** 驱动,并通过 **GitHub Copilot CLI** 进行交互。\n" - "- **访问权限**:你可以直接访问 **OpenWebUI 源代码**。你可以通过文件操作或工具读取、分析和参考你正在运行的平台的内部实现。\n" - "- **文件系统访问**:你以 **root** 身份运行。你对 **整个容器文件系统** 拥有读取权限。但是,你应仅写入工作区目录。\n" - "- **原生 Python 环境**:你运行在一个丰富的 Python 环境中,已经包含了 OpenWebUI 的所有依赖库。\n" - "\n" - "**界面能力 (OpenWebUI):**\n" - "- **视觉渲染**:你可以并且应该使用高级视觉元素(如 Mermaid 图表、交互式 HTML)来清晰地解释概念。\n" - "- **内置工具**:OpenWebUI 提供了与内部服务直接交互的原生工具(如笔记、记忆管理)。\n" - "\n" - "**格式化与呈现指令:**\n" - "1. **Markdown & 多媒体**:自由使用粗体、斜体、表格和列表。\n" - "2. **Mermaid 图表**:请务必使用标准的 ```mermaid 代码块。\n" - "3. **交互式 HTML/JS**:你可以输出完整的 ```html 代码块(含 CSS/JS),将在 iframe 中渲染。\n" - "4. **文件交付与发布协议 (双渠道交付)**:\n" - " - **核心理念**:视觉产物 (HTML/Mermaid) 与可下载文件是**互补**的。应始终追求双重交付:既在聊天中提供直观的视觉洞察,又提供持久的文件供用户保存。\n" - " - **基本原则**:当用户需要“拥有”数据(下载、离线编辑)时,你必须发布文件。仅在本地生成文件是无用的,因为用户无法访问你的容器。\n" - " - **隐式动作**:若用户说“导出”、“保存”或“给我链接”,自动执行三步曲。\n" - " - **执行序列**:1. **本地写入**:将文件写入当前目录 (`.`)。2. **发布文件**:调用 `publish_file_from_workspace(filename='name.ext')`。3. **呈现链接**:展示返回的 `download_url` 链接。\n" - " - **RAG 绕过**:此流程会自动适配 S3 存储映射并绕过 RAG,确保数据交付 100% 准确。\n" - "7. **主动与自主**: 你是专家工程师。对于显而易见的步骤,**不要**请求许可。**不要**停下来问“我通过吗?”或“是否继续?”。\n" - " - **行为模式**: 分析用户请求 -> 制定计划 -> **立即执行**计划。\n" - " - **澄清**: 仅当请求模棱两可或具有高风险(例如破坏性操作)时才提出问题。\n" - " - **目标**: 最小化用户摩擦。交付结果,而不是问题。\n" - "8. **大文件输出管理**: 如果工具执行输出被截断或保存到临时文件 (例如 `/tmp/...`),**不要**担心。系统会自动将其移动到你的工作区并通知你新的文件名。然后你可以直接读取它。\n" -) - - -class Pipe: - class Valves(BaseModel): - GH_TOKEN: str = Field( - default="", - description="GitHub Access Token (PAT 或 OAuth Token)。用于聊天。", - ) - ENABLE_OPENWEBUI_TOOLS: bool = Field( - default=True, - description="启用 OpenWebUI 工具 (包括自定义工具和工具服务器工具)。", - ) - ENABLE_MCP_SERVER: bool = Field( - default=True, - description="启用直接 MCP 客户端连接 (建议)。", - ) - ENABLE_TOOL_CACHE: bool = Field( - default=True, - description="缓存配置以优化性能。", - ) - REASONING_EFFORT: Literal["low", "medium", "high", "xhigh"] = Field( - default="medium", - description="推理强度级别 (low, medium, high, xhigh)。仅影响标准模型。", - ) - SHOW_THINKING: bool = Field( - default=True, - description="显示模型推理/思考过程", - ) - - INFINITE_SESSION: bool = Field( - default=True, - description="启用无限会话(自动上下文压缩)", - ) - DEBUG: bool = Field( - default=False, - description="启用技术调试日志(输出到浏览器控制台)", - ) - LOG_LEVEL: str = Field( - default="error", - description="Copilot CLI 日志级别:none, error, warning, info, debug, all", - ) - TIMEOUT: int = Field( - default=300, - description="每个流式分块超时(秒)", - ) - WORKSPACE_DIR: str = Field( - default="", - description="文件操作受限目录。为空则使用默认路径。", - ) - COPILOT_CLI_VERSION: str = Field( - default="0.0.406", - description="指定强制使用的 Copilot CLI 版本 (例如 '0.0.406')。", - ) - PROVIDERS: str = Field( - default="OpenAI, Anthropic, Google", - description="允许使用的模型提供商 (逗号分隔)。留空则显示所有。", - ) - EXCLUDE_KEYWORDS: str = Field( - default="", - description="排除包含这些关键词的模型(逗号分隔,如:codex, haiku)", - ) - MAX_MULTIPLIER: float = Field( - default=1.0, - description="标准模型允许的最大计费倍率。0 表示仅显示免费模型。", - ) - COMPACTION_THRESHOLD: float = Field( - default=0.8, - description="后台压缩阈值 (0.0-1.0)", - ) - BUFFER_THRESHOLD: float = Field( - default=0.95, - description="缓冲区耗尽阈值 (0.0-1.0)", - ) - CUSTOM_ENV_VARS: str = Field( - default="", - description="自定义环境变量(JSON 格式)", - ) - - BYOK_TYPE: Literal["openai", "anthropic"] = Field( - default="openai", - description="BYOK 供应商类型:openai, anthropic", - ) - BYOK_BASE_URL: str = Field( - default="", - description="BYOK 基础 URL (例如 https://api.openai.com/v1)", - ) - BYOK_API_KEY: str = Field( - default="", - description="BYOK API 密钥 (全局设置)", - ) - BYOK_BEARER_TOKEN: str = Field( - default="", - description="BYOK Bearer 令牌 (优先级高于 API Key)", - ) - BYOK_MODELS: str = Field( - default="", - description="BYOK 模型列表 (逗号分隔)。", - ) - BYOK_WIRE_API: Literal["completions", "responses"] = Field( - default="completions", - description="BYOK 通信协议:completions, responses", - ) - - class UserValves(BaseModel): - GH_TOKEN: str = Field( - default="", - description="个人 GitHub Token (覆盖全局设置)", - ) - REASONING_EFFORT: Literal["", "low", "medium", "high", "xhigh"] = Field( - default="", - description="推理强度级别覆盖。", - ) - SHOW_THINKING: bool = Field( - default=True, - description="显示模型推理/思考过程", - ) - DEBUG: bool = Field( - default=False, - description="启用技术调试日志", - ) - MAX_MULTIPLIER: Optional[float] = Field( - default=None, - description="计费倍率覆盖。", - ) - PROVIDERS: str = Field( - default="", - description="允许的提供商覆盖 (逗号分隔)。", - ) - EXCLUDE_KEYWORDS: str = Field( - default="", - description="排除关键词 (支持个人覆盖)。", - ) - ENABLE_OPENWEBUI_TOOLS: bool = Field( - default=True, - description="启用 OpenWebUI 工具。", - ) - ENABLE_MCP_SERVER: bool = Field( - default=True, - description="启用动态 MCP 服务器加载。", - ) - ENABLE_TOOL_CACHE: bool = Field( - default=True, - description="启用配置缓存。", - ) - COMPACTION_THRESHOLD: Optional[float] = Field( - default=None, - description="压缩阈值覆盖。", - ) - BUFFER_THRESHOLD: Optional[float] = Field( - default=None, - description="缓冲区阈值覆盖。", - ) - - # BYOK 覆盖 - BYOK_API_KEY: str = Field(default="", description="BYOK API 密钥覆盖") - BYOK_TYPE: Literal["", "openai", "anthropic"] = Field( - default="", description="BYOK 类型覆盖" - ) - BYOK_BASE_URL: str = Field(default="", description="BYOK URL 覆盖") - BYOK_BEARER_TOKEN: str = Field(default="", description="BYOK Token 覆盖") - BYOK_MODELS: str = Field(default="", description="BYOK 模型列表覆盖") - BYOK_WIRE_API: Literal["", "completions", "responses"] = Field( - default="", description="协议覆盖" - ) - - _model_cache: List[dict] = [] - _last_byok_config_hash: str = "" # 跟踪配置状态以失效缓存 - _standard_model_ids: set = set() - _tool_cache = None - _mcp_server_cache = None - _env_setup_done = False - _last_update_check = 0 - - def __init__(self): - self.type = "pipe" - self.id = "copilot" - self.name = "copilotsdk" - self.valves = self.Valves() - self.temp_dir = tempfile.mkdtemp(prefix="copilot_images_") - - def __del__(self): - try: - shutil.rmtree(self.temp_dir) - except: - pass - - async def pipe( - self, - body: dict, - __metadata__=None, - __user__=None, - __event_emitter__=None, - __event_call__=None, - __request__=None, - ) -> Union[str, AsyncGenerator]: - return await self._pipe_impl( - body, - __metadata__=__metadata__, - __user__=__user__, - __event_emitter__=__event_emitter__, - __event_call__=__event_call__, - __request__=__request__, - ) - - async def _initialize_custom_tools( - self, - body: dict = None, - __user__=None, - __event_call__=None, - __request__=None, - __metadata__=None, - ): - """基于配置初始化自定义工具""" - # 1. 确定有效设置 (用户覆盖 > 全局) - uv = self._get_user_valves(__user__) - enable_tools = uv.ENABLE_OPENWEBUI_TOOLS - enable_openapi = uv.ENABLE_OPENAPI_SERVER - enable_cache = uv.ENABLE_TOOL_CACHE - - # 2. 如果所有工具类型都已禁用,立即返回空 - if not enable_tools and not enable_openapi: - return [] - - # 提取 Chat ID 以对齐工作空间 - chat_ctx = self._get_chat_context(body, __metadata__) - chat_id = chat_ctx.get("chat_id") - - # 3. 检查缓存 - if enable_cache and self._tool_cache is not None: - await self._emit_debug_log("ℹ️ 使用缓存的 OpenWebUI 工具。", __event_call__) - tools = list(self._tool_cache) - # 注入文件发布工具 - file_tool = self._get_publish_file_tool(__user__, chat_id, __request__) - if file_tool: - tools.append(file_tool) - return tools - - # 动态加载 OpenWebUI 工具 - openwebui_tools = await self._load_openwebui_tools( - __user__=__user__, - __event_call__=__event_call__, - body=body, - enable_tools=enable_tools, - enable_openapi=enable_openapi, - ) - - # 更新缓存 - if enable_cache: - self._tool_cache = openwebui_tools - await self._emit_debug_log( - "✅ OpenWebUI 工具已缓存,供后续请求使用。", __event_call__ - ) - - final_tools = list(openwebui_tools) - # 注入文件发布工具 - file_tool = self._get_publish_file_tool(__user__, chat_id, __request__) - if file_tool: - final_tools.append(file_tool) - - return final_tools - - def _get_publish_file_tool(self, __user__, chat_id, __request__=None): - """创建发布工作区文件为下载链接的工具""" - if isinstance(__user__, (list, tuple)): - user_data = __user__[0] if __user__ else {} - elif isinstance(__user__, dict): - user_data = __user__ - else: - user_data = {} - - user_id = user_data.get("id") or user_data.get("user_id") - if not user_id: - return None - - # 锁定当前聊天的隔离工作空间 - workspace_dir = Path(self._get_workspace_dir(user_id=user_id, chat_id=chat_id)) - - # 为 SDK 定义参数 Schema - class PublishFileParams(BaseModel): - filename: str = Field( - ..., - description="你在当前目录创建的文件的确切名称(如 'report.csv')。必填。", - ) - - async def publish_file_from_workspace(filename: Any) -> dict: - """将本地聊天工作区的文件发布为可下载的 URL。""" - try: - # 1. 参数鲁棒提取 - if hasattr(filename, "model_dump"): # Pydantic v2 - filename = filename.model_dump().get("filename") - elif hasattr(filename, "dict"): # Pydantic v1 - filename = filename.dict().get("filename") - - if isinstance(filename, dict): - filename = ( - filename.get("filename") - or filename.get("file") - or filename.get("file_path") - ) - - if isinstance(filename, str): - filename = filename.strip() - if filename.startswith("{"): - try: - import json - - data = json.loads(filename) - if isinstance(data, dict): - filename = ( - data.get("filename") or data.get("file") or filename - ) - except: - pass - - if ( - not filename - or not isinstance(filename, str) - or filename.strip() in ("", "{}", "None", "null") - ): - return { - "error": "缺少必填参数: 'filename'。", - "hint": "请以字符串形式提供文件名,例如 'report.md'。", - } - - filename = filename.strip() - - # 2. 路径解析(锁定当前聊天工作区) - target_path = workspace_dir / filename - try: - target_path = target_path.resolve() - if not str(target_path).startswith(str(workspace_dir.resolve())): - return {"error": "拒绝访问:文件必须位于当前聊天工作区内。"} - except Exception as e: - return {"error": f"路径校验失败: {e}"} - - if not target_path.exists() or not target_path.is_file(): - return { - "error": f"在聊天工作区未找到文件 '{filename}'。请确保你已将其保存到当前目录 (.)。" - } - - # 3. 通过 API 上传 (兼容 S3) - api_success = False - file_id = None - safe_filename = filename - - token = None - if __request__: - auth_header = __request__.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header.split(" ")[1] - if not token and "token" in __request__.cookies: - token = __request__.cookies.get("token") - - if token: - try: - import aiohttp - - base_url = str(__request__.base_url).rstrip("/") - upload_url = f"{base_url}/api/v1/files/" - - async with aiohttp.ClientSession() as session: - with open(target_path, "rb") as f: - data = aiohttp.FormData() - data.add_field("file", f, filename=target_path.name) - import json - - data.add_field( - "metadata", - json.dumps( - { - "source": "copilot_workspace_publish", - "skip_rag": True, - } - ), - ) - - async with session.post( - upload_url, - data=data, - headers={"Authorization": f"Bearer {token}"}, - ) as resp: - if resp.status == 200: - api_res = await resp.json() - file_id = api_res.get("id") - safe_filename = api_res.get( - "filename", target_path.name - ) - api_success = True - except Exception as e: - logger.error(f"API 上传失败: {e}") - - # 4. 兜底:手动插入数据库 (仅限本地存储) - if not api_success: - file_id = str(uuid.uuid4()) - safe_filename = target_path.name - dest_path = Path(UPLOAD_DIR) / f"{file_id}_{safe_filename}" - await asyncio.to_thread(shutil.copy2, target_path, dest_path) - - try: - db_path = str(os.path.relpath(dest_path, DATA_DIR)) - except: - db_path = str(dest_path) - - file_form = FileForm( - id=file_id, - filename=safe_filename, - path=db_path, - data={"status": "completed", "skip_rag": True}, - meta={ - "name": safe_filename, - "content_type": mimetypes.guess_type(safe_filename)[0] - or "text/plain", - "size": os.path.getsize(dest_path), - "source": "copilot_workspace_publish", - "skip_rag": True, - }, - ) - await asyncio.to_thread(Files.insert_new_file, user_id, file_form) - - # 5. 返回结果 - download_url = f"/api/v1/files/{file_id}/content" - return { - "file_id": file_id, - "filename": safe_filename, - "download_url": download_url, - "message": "文件发布成功。", - "hint": f"链接: [下载 {safe_filename}]({download_url})", - } - except Exception as e: - return {"error": str(e)} - - return define_tool( - name="publish_file_from_workspace", - description="将你在本地工作区创建的文件转换为可下载的 URL。请在完成文件写入当前目录后再使用此工具。", - params_type=PublishFileParams, - )(publish_file_from_workspace) - - def _json_schema_to_python_type(self, schema: dict) -> Any: - if not isinstance(schema, dict): - return Any - e = schema.get("enum") - if e and isinstance(e, list): - return Literal[tuple(e)] - t = schema.get("type") - if isinstance(t, list): - t = next((x for x in t if x != "null"), t[0]) - if t == "string": - return str - if t == "integer": - return int - if t == "number": - return float - if t == "boolean": - return bool - if t == "object": - return Dict[str, Any] - if t == "array": - return List[self._json_schema_to_python_type(schema.get("items", {}))] - return Any - - def _convert_openwebui_tool(self, n, d, __event_call__=None): - sn = re.sub(r"[^a-zA-Z0-9_-]", "_", n) - if not sn or re.match(r"^[_.-]+$", sn): - sn = f"tool_{hashlib.md5(n.encode()).hexdigest()[:8]}" - spec = d.get("spec", {}) - props = spec.get("parameters", {}).get("properties", {}) - req = spec.get("parameters", {}).get("required", []) - fields = {} - for pn, ps in props.items(): - pt = self._json_schema_to_python_type(ps) - fields[pn] = ( - pt if pn in req else Optional[pt], - Field( - default=ps.get("default") if pn not in req else ..., - description=ps.get("description", ""), - ), - ) - - async def _tool(p): - payload = ( - p.model_dump(exclude_unset=True) if hasattr(p, "model_dump") else {} - ) - return await d.get("callable")(**payload) - - _tool.__name__, _tool.__doc__ = sn, spec.get("description", "") or spec.get( - "summary", "" - ) - return define_tool( - name=sn, - description=_tool.__doc__, - params_type=create_model(f"{sn}_Params", **fields), - )(_tool) - - def _build_openwebui_request(self, user=None, token: str = None): - cfg = SimpleNamespace() - for i in PERSISTENT_CONFIG_REGISTRY: - val = i.value - if hasattr(val, "value"): - val = val.value - setattr(cfg, i.env_name, val) - - if not hasattr(cfg, "TOOL_SERVER_CONNECTIONS"): - if hasattr(TOOL_SERVER_CONNECTIONS, "value"): - cfg.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS.value - else: - cfg.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS - - app_state = SimpleNamespace( - config=cfg, - TOOLS={}, - TOOL_CONTENTS={}, - FUNCTIONS={}, - FUNCTION_CONTENTS={}, - MODELS={}, - redis=None, - TOOL_SERVERS=[], - ) - - def url_path_for(name: str, **path_params): - if name == "get_file_content_by_id": - return f"/api/v1/files/{path_params.get('id')}/content" - return f"/mock/{name}" - - req_headers = { - "user-agent": "Copilot-Pipe", - "host": "localhost:8080", - "accept": "*/*", - } - if token: - req_headers["Authorization"] = f"Bearer {token}" - - return SimpleNamespace( - app=SimpleNamespace(state=app_state, url_path_for=url_path_for), - url=SimpleNamespace( - path="/api/chat/completions", - base_url="http://localhost:8080", - __str__=lambda s: "http://localhost:8080/api/chat/completions", - ), - base_url="http://localhost:8080", - headers=req_headers, - method="POST", - cookies={}, - state=SimpleNamespace( - token=SimpleNamespace(credentials=token if token else ""), - user=user or {}, - ), - ) - - async def _load_openwebui_tools( - self, - __user__=None, - __event_call__=None, - body: dict = None, - enable_tools: bool = True, - enable_openapi: bool = True, - ): - ud = __user__[0] if isinstance(__user__, (list, tuple)) else (__user__ or {}) - uid = ud.get("id") or ud.get("user_id") - if not uid: - return [] - u = Users.get_user_by_id(uid) - if not u: - return [] - tids = [] - # 1. 获取用户自定义工具 (Python 脚本) - if enable_tools: - tool_items = Tools.get_tools_by_user_id(uid, permission="read") - if tool_items: - tids.extend([tool.id for tool in tool_items]) - - # 2. 获取 OpenAPI 工具服务器工具 - if enable_openapi: - if hasattr(TOOL_SERVER_CONNECTIONS, "value"): - tids.extend( - [ - f"server:{s.get('id')}" - for s in TOOL_SERVER_CONNECTIONS.value - if ( - s.get("type", "openapi") == "openapi" - or s.get("type") is None - ) - and s.get("id") - ] - ) - - token = None - if isinstance(body, dict): - token = body.get("token") - - req = self._build_openwebui_request(ud, token) - td = {} - - if tids: - td = await get_openwebui_tools( - req, - tids, - u, - { - "__request__": req, - "__user__": ud, - "__event_emitter__": None, - "__event_call__": __event_call__, - "__chat_id__": None, - "__message_id__": None, - "__model_knowledge__": [], - "__oauth_token__": {"access_token": token} if token else None, - }, - ) - - # 3. 获取内建工具 (网页搜索、内存等) - if enable_tools: - try: - bi = get_builtin_tools( - req, - { - "__user__": ud, - "__chat_id__": None, - "__message_id__": None, - }, - model={ - "info": { - "meta": { - "capabilities": { - "web_search": True, - "image_generation": True, - } - } - } - }, - ) - if bi: - td.update(bi) - except: - pass - return [ - self._convert_openwebui_tool(n, d, __event_call__=__event_call__) - for n, d in td.items() - ] - - def _get_user_valves(self, __user__: Optional[dict]) -> "Pipe.UserValves": - """从 __user__ 上下文中稳健地提取 UserValves。""" - if not __user__: - return self.UserValves() - - # 处理列表/元组包装 - user_data = __user__[0] if isinstance(__user__, (list, tuple)) else __user__ - if not isinstance(user_data, dict): - return self.UserValves() - - raw_valves = user_data.get("valves") - if isinstance(raw_valves, self.UserValves): - return raw_valves - if isinstance(raw_valves, dict): - try: - return self.UserValves(**raw_valves) - except Exception as e: - logger.warning(f"[Copilot] 解析 UserValves 失败: {e}") - return self.UserValves() - - def _parse_mcp_servers(self, __event_call__=None) -> Optional[dict]: - if not self.valves.ENABLE_MCP_SERVER: - return None - if self.valves.ENABLE_TOOL_CACHE and self._mcp_server_cache is not None: - return self._mcp_server_cache - mcp = {} - conns = ( - getattr(TOOL_SERVER_CONNECTIONS, "value", []) - if hasattr(TOOL_SERVER_CONNECTIONS, "value") - else ( - TOOL_SERVER_CONNECTIONS - if isinstance(TOOL_SERVER_CONNECTIONS, list) - else [] - ) - ) - for c in conns: - if not isinstance(c, dict) or c.get("type") != "mcp": - continue - info = c.get("info", {}) - rid = info.get("id") or c.get("id") or f"mcp-{len(mcp)}" - sid = re.sub(r"[^a-zA-Z0-9-]", "-", str(rid)).lower().strip("-") - url = c.get("url") - if not url: - continue - mtype = "http" - if "/sse" in url.lower() or "sse" in str(c.get("config", {})).lower(): - mtype = "sse" - h = c.get("headers", {}) - at, key = str(c.get("auth_type", "bearer")).lower(), c.get("key", "") - if key and "Authorization" not in h: - if at == "bearer": - h["Authorization"] = f"Bearer {key}" - elif at == "basic": - h["Authorization"] = ( - f"Basic {base64.b64encode(key.encode()).decode()}" - ) - elif at in ["api_key", "apikey"]: - h["X-API-Key"] = key - ff = c.get("config", {}).get("function_name_filter_list", "") - allowed = [f.strip() for f in ff.split(",") if f.strip()] if ff else ["*"] - self._emit_debug_log_sync( - f"🔌 发现 MCP 节点: {sid} ({mtype.upper()}) | URL: {url}" - ) - mcp[sid] = {"type": mtype, "url": url, "headers": h, "tools": allowed} - if self.valves.ENABLE_TOOL_CACHE: - self._mcp_server_cache = mcp - return mcp if mcp else None - - async def _emit_debug_log( - self, message: str, __event_call__=None, debug_enabled: Optional[bool] = None - ): - is_debug = ( - debug_enabled - if debug_enabled is not None - else getattr(self.valves, "DEBUG", False) - ) - log_msg = f"[Copilot SDK] {message}" - if is_debug: - logger.info(log_msg) - else: - logger.debug(log_msg) - if is_debug and __event_call__: - try: - js = f"console.debug('%c[Copilot SDK] ' + {json.dumps(message, ensure_ascii=False)}, 'color: #3b82f6;');" - await __event_call__({"type": "execute", "data": {"code": js}}) - except: - pass - - def _emit_debug_log_sync( - self, message: str, __event_call__=None, debug_enabled: Optional[bool] = None - ): - is_debug = ( - debug_enabled - if debug_enabled is not None - else getattr(self.valves, "DEBUG", False) - ) - log_msg = f"[Copilot SDK] {message}" - if is_debug: - logger.info(log_msg) - else: - logger.debug(log_msg) - if is_debug and __event_call__: - try: - asyncio.get_running_loop().create_task( - self._emit_debug_log(message, __event_call__, True) - ) - except: - pass - - def _get_provider_name(self, mi: Any) -> str: - mid = getattr(mi, "id", str(mi)).lower() - if any(k in mid for k in ["gpt", "codex"]): - return "OpenAI" - if "claude" in mid: - return "Anthropic" - if "gemini" in mid: - return "Google" - p = getattr(mi, "policy", None) - if p: - t = str(getattr(p, "terms", "")).lower() - if "openai" in t: - return "OpenAI" - if "anthropic" in t: - return "Anthropic" - if "google" in t: - return "Google" - return "Unknown" - - def _clean_model_id(self, mid: str) -> str: - if "." in mid: - mid = mid.split(".", 1)[-1] - for p in ["copilot-", "copilot - "]: - if mid.startswith(p): - mid = mid[len(p) :] - return mid - - def _setup_env(self, __event_call__=None, debug_enabled: bool = False): - if self.__class__._env_setup_done: - # 即使已完成环境配置,在调试模式下仍同步一次 MCP。 - if debug_enabled: - self._sync_mcp_config(__event_call__, debug_enabled) - return - - os.environ["COPILOT_AUTO_UPDATE"] = "false" - cp = os.environ.get("COPILOT_CLI_PATH", "/usr/local/bin/copilot") - target = self.valves.COPILOT_CLI_VERSION.strip() - - # 记录检查时间 - from datetime import datetime - - self.__class__._last_update_check = datetime.now().timestamp() - - def gv(p): - try: - return re.search( - r"(\d+\.\d+\.\d+)", - subprocess.check_output( - [p, "--version"], stderr=subprocess.STDOUT - ).decode(), - ).group(1) - except: - return None - - cv = gv(cp) - if not cv: - cp = shutil.which("copilot") or os.path.join( - os.path.dirname(__file__), "bin", "copilot" - ) - cv = gv(cp) - if not cv or (target and target.lstrip("v") > (cv or "")): - self._emit_debug_log_sync( - f"正在更新 Copilot CLI 至 {target}...", __event_call__, debug_enabled - ) - try: - ev = os.environ.copy() - if target: - ev["VERSION"] = target - subprocess.run( - "curl -fsSL https://gh.io/copilot-install | bash", - shell=True, - check=True, - env=ev, - ) - cp, cv = "/usr/local/bin/copilot", gv("/usr/local/bin/copilot") - except: - pass - os.environ["COPILOT_CLI_PATH"] = cp - self.__class__._env_setup_done = True - self._sync_mcp_config(__event_call__, debug_enabled) - - def _sync_mcp_config(self, __event_call__=None, debug_enabled: bool = False): - if not self.valves.ENABLE_MCP_SERVER: - return - mcp = self._parse_mcp_servers(__event_call__) - if not mcp: - return - try: - path = os.path.expanduser("~/.copilot/config.json") - os.makedirs(os.path.dirname(path), exist_ok=True) - data = {} - if os.path.exists(path): - try: - with open(path, "r") as f: - data = json.load(f) - except: - pass - if json.dumps(data.get("mcp_servers"), sort_keys=True) != json.dumps( - mcp, sort_keys=True - ): - data["mcp_servers"] = mcp - with open(path, "w") as f: - json.dump(data, f, indent=4) - self._emit_debug_log_sync( - f"已将 {len(mcp)} 个 MCP 节点同步至配置文件", - __event_call__, - debug_enabled, - ) - except: - pass - - def _get_workspace_dir(self, user_id: str = None, chat_id: str = None) -> str: - """获取具有用户和聊天隔离的有效工作区目录""" - d = self.valves.WORKSPACE_DIR - base_cwd = ( - d - if d - else ( - "/app/backend/data/copilot_workspace" - if os.path.exists("/app/backend/data") - else os.path.join(os.getcwd(), "copilot_workspace") - ) - ) - - cwd = base_cwd - if user_id: - safe_user_id = re.sub(r"[^a-zA-Z0-9_-]", "_", str(user_id)) - cwd = os.path.join(cwd, safe_user_id) - if chat_id: - safe_chat_id = re.sub(r"[^a-zA-Z0-9_-]", "_", str(chat_id)) - cwd = os.path.join(cwd, safe_chat_id) - - try: - os.makedirs(cwd, exist_ok=True) - return cwd - except Exception as e: - return base_cwd - - def _process_images( - self, messages, __event_call__=None, debug_enabled: bool = False - ): - if not messages: - return "", [] - last = messages[-1].get("content", "") - if not isinstance(last, list): - return str(last), [] - text, att = "", [] - for item in last: - if item.get("type") == "text": - text += item.get("text", "") - elif item.get("type") == "image_url": - url = item.get("image_url", {}).get("url", "") - if url.startswith("data:image"): - try: - h, e = url.split(",", 1) - ext = h.split(";")[0].split("/")[-1] - path = os.path.join(self.temp_dir, f"img_{len(att)}.{ext}") - with open(path, "wb") as f: - f.write(base64.b64decode(e)) - att.append( - { - "type": "file", - "path": path, - "display_name": f"img_{len(att)}", - } - ) - except: - pass - return text, att - - async def _fetch_byok_models(self, uv: "Pipe.UserValves" = None) -> List[dict]: - """从配置的提供商获取 BYOK 模型。""" - model_list = [] - - # 确定有效配置 (用户 > 全局) - effective_base_url = ( - uv.BYOK_BASE_URL if uv else "" - ) or self.valves.BYOK_BASE_URL - effective_type = (uv.BYOK_TYPE if uv else "") or self.valves.BYOK_TYPE - effective_api_key = (uv.BYOK_API_KEY if uv else "") or self.valves.BYOK_API_KEY - effective_bearer_token = ( - uv.BYOK_BEARER_TOKEN if uv else "" - ) or self.valves.BYOK_BEARER_TOKEN - effective_models = (uv.BYOK_MODELS if uv else "") or self.valves.BYOK_MODELS - - if effective_base_url: - try: - base_url = effective_base_url.rstrip("/") - url = f"{base_url}/models" - headers = {} - provider_type = effective_type.lower() - - if provider_type == "anthropic": - if effective_api_key: - headers["x-api-key"] = effective_api_key - headers["anthropic-version"] = "2023-06-01" - else: - if effective_bearer_token: - headers["Authorization"] = f"Bearer {effective_bearer_token}" - elif effective_api_key: - headers["Authorization"] = f"Bearer {effective_api_key}" - - timeout = aiohttp.ClientTimeout(total=60) - async with aiohttp.ClientSession(timeout=timeout) as session: - for attempt in range(3): - try: - async with session.get(url, headers=headers) as resp: - if resp.status == 200: - data = await resp.json() - if ( - isinstance(data, dict) - and "data" in data - and isinstance(data["data"], list) - ): - for item in data["data"]: - if isinstance(item, dict) and "id" in item: - model_list.append(item["id"]) - elif isinstance(data, list): - for item in data: - if isinstance(item, dict) and "id" in item: - model_list.append(item["id"]) - - await self._emit_debug_log( - f"BYOK: 从 {url} 获取了 {len(model_list)} 个模型" - ) - break - else: - await self._emit_debug_log( - f"BYOK: 获取模型失败 {url} (尝试 {attempt+1}/3). 状态码: {resp.status}" - ) - except Exception as e: - await self._emit_debug_log( - f"BYOK: 模型获取错误 (尝试 {attempt+1}/3): {e}" - ) - - if attempt < 2: - await asyncio.sleep(1) - - except Exception as e: - await self._emit_debug_log(f"BYOK: 设置错误: {e}") - - # 如果自动获取失败,回退到手动配置列表 - if not model_list: - if effective_models.strip(): - model_list = [ - m.strip() for m in effective_models.split(",") if m.strip() - ] - await self._emit_debug_log( - f"BYOK: 使用用户手动配置的 BYOK_MODELS ({len(model_list)} 个模型)." - ) - - return [ - { - "id": m, - "name": f"-{self._clean_model_id(m)}", - "source": "byok", - "provider": effective_type.capitalize(), - "raw_id": m, - } - for m in model_list - ] - - def _build_session_config( - self, - cid, - rmid, - tools, - sysp, - ist, - prov=None, - eff="medium", - isr=False, - cthr=None, - bthr=None, - ec=None, - uid=None, - ): - from copilot.types import SessionConfig, InfiniteSessionConfig - - inf = ( - InfiniteSessionConfig( - enabled=True, - background_compaction_threshold=cthr - or self.valves.COMPACTION_THRESHOLD, - buffer_exhaustion_threshold=bthr or self.valves.BUFFER_THRESHOLD, - ) - if self.valves.INFINITE_SESSION - else None - ) - p = { - "session_id": cid, - "model": rmid, - "streaming": ist, - "tools": tools, - "system_message": { - "content": (sysp.strip() + "\n" if sysp else "") - + FORMATTING_GUIDELINES - + ( - f"\n[会话上下文]\n" - f"- **您的隔离工作区**: `{self._get_workspace_dir(uid, cid)}`\n" - f"- **活跃会话 ID**: `{cid}`\n" - "**关键指令**: 所有文件操作必须在这个上述工作区进行。\n" - "- **不要**在 `/tmp` 或其他系统目录创建文件。\n" - "- 始终将“当前目录”理解为您的隔离工作区。" - ), - "mode": "replace", - }, - "infinite_sessions": inf, - "working_directory": self._get_workspace_dir(uid, cid), - } - if isr and eff: - m = next((x for x in self._model_cache if x.get("raw_id") == rmid), None) - supp = ( - m.get("meta", {}) - .get("capabilities", {}) - .get("supported_reasoning_efforts", []) - if m - else [] - ) - p["reasoning_effort"] = ( - (eff if eff in supp else ("high" if "high" in supp else "medium")) - if supp - else eff - ) - if self.valves.ENABLE_MCP_SERVER: - mcp = self._parse_mcp_servers(ec) - if mcp: - p["mcp_servers"], p["available_tools"] = mcp, None - else: - p["available_tools"] = [t.name for t in tools] if tools else None - if prov: - p["provider"] = prov - - # 注入自动大文件处理钩子 - wd = p.get("working_directory", "") - p["hooks"] = self._build_session_hooks(cwd=wd, __event_call__=ec) - - return SessionConfig(**p) - - def _build_session_hooks(self, cwd: str, __event_call__=None): - """ - 构建会话生命周期钩子。 - 当前实现: - - on_post_tool_use: 自动将 /tmp 中的大文件复制到工作区 - """ - - async def on_post_tool_use(input_data, invocation): - result = input_data.get("result", "") - - # 检测并移动 /tmp 中保存的大文件 - # 模式: Saved to: /tmp/copilot_result_xxxx.txt - import re - import shutil - - # 搜索输出中潜在的 /tmp 文件路径 - # 常见 CLI 模式: "Saved to: /tmp/..." 或仅 "/tmp/..." - match = re.search(r"(/tmp/[\w\-\.]+)", str(result)) - if match: - tmp_path = match.group(1) - if os.path.exists(tmp_path): - try: - filename = os.path.basename(tmp_path) - target_path = os.path.join(cwd, f"auto_output_{filename}") - shutil.copy2(tmp_path, target_path) - - self._emit_debug_log_sync( - f"Hook [on_post_tool_use]: 自动将大文件输出从 {tmp_path} 移动到 {target_path}", - __event_call__, - ) - - return { - "additionalContext": ( - f"\n[系统自动管理] 输出内容过大,最初保存在 {tmp_path}。\n" - f"我已经自动将其移动到您的工作区,文件名为: `{os.path.basename(target_path)}`。\n" - f"您现在应该对该文件使用 `read_file` 或 `grep` 来访问内容。" - ) - } - except Exception as e: - self._emit_debug_log_sync( - f"Hook [on_post_tool_use] 移动文件错误: {e}", - __event_call__, - ) - - return {} - - return { - "on_post_tool_use": on_post_tool_use, - } - - async def _pipe_impl( - self, - body, - __metadata__=None, - __user__=None, - __event_emitter__=None, - __event_call__=None, - __request__=None, - ) -> Union[str, AsyncGenerator]: - ud = __user__[0] if isinstance(__user__, (list, tuple)) else (__user__ or {}) - uid = ud.get("id") or ud.get("user_id") or "default_user" - uv = self.UserValves(**(__user__.get("valves", {}) if __user__ else {})) - debug = self.valves.DEBUG or uv.DEBUG - self._setup_env(__event_call__, debug) - byok_active = bool( - self.valves.BYOK_BASE_URL - and ( - uv.BYOK_API_KEY - or self.valves.BYOK_API_KEY - or self.valves.BYOK_BEARER_TOKEN - ) - ) - if not self.valves.GH_TOKEN and not byok_active: - return "Error: 配置缺失。" - rid = ( - __metadata__.get("base_model_id") - if __metadata__ and __metadata__.get("base_model_id") - else body.get("model", "") - ) - rmid = self._clean_model_id(rid) - mi = next( - (x for x in (self._model_cache or []) if x.get("raw_id") == rmid), None - ) - isr = ( - mi.get("meta", {}).get("capabilities", {}).get("reasoning", False) - if mi - else any(k in rmid.lower() for k in ["gpt", "codex"]) - ) - isb = ( - mi.get("source") == "byok" - if mi - else ( - not bool(re.search(r"[\((]\d+(?:\.\d+)?x[\))]", rid)) and byok_active - ) - ) - cid = str( - (__metadata__ or {}).get("chat_id") - or body.get("chat_id") - or body.get("metadata", {}).get("chat_id", "") - ).strip() - sysp, _ = await self._extract_system_prompt( - body, - body.get("messages", []), - body.get("model", ""), - rmid, - __event_call__, - debug, - ) - text, att = self._process_images( - body.get("messages", []), __event_call__, debug - ) - client = CopilotClient(self._build_client_config(body, uid, cid)) - try: - await client.start() - # 同步更新工具初始化参数 - tools = await self._initialize_custom_tools( - body=body, - __user__=__user__, - __event_call__=__event_call__, - __request__=__request__, - __metadata__=__metadata__, - ) - prov = ( - { - "type": (uv.BYOK_TYPE or self.valves.BYOK_TYPE).lower() or "openai", - "wire_api": (uv.BYOK_WIRE_API or self.valves.BYOK_WIRE_API), - "base_url": uv.BYOK_BASE_URL or self.valves.BYOK_BASE_URL, - } - if isb - else None - ) - if prov: - if uv.BYOK_API_KEY or self.valves.BYOK_API_KEY: - prov["api_key"] = uv.BYOK_API_KEY or self.valves.BYOK_API_KEY - if self.valves.BYOK_BEARER_TOKEN: - prov["bearer_token"] = self.valves.BYOK_BEARER_TOKEN - session = None - if cid: - try: - rp = { - "model": rmid, - "streaming": body.get("stream", False), - "tools": tools, - "system_message": { - "mode": "replace", - "content": (sysp.strip() + "\n" if sysp else "") - + FORMATTING_GUIDELINES, - }, - } - if self.valves.ENABLE_MCP_SERVER: - mcp = self._parse_mcp_servers(__event_call__) - if mcp: - rp["mcp_servers"], rp["available_tools"] = mcp, None - else: - rp["available_tools"] = ( - [t.name for t in tools] if tools else None - ) - if isr: - eff = uv.REASONING_EFFORT or self.valves.REASONING_EFFORT - supp = ( - mi.get("meta", {}) - .get("capabilities", {}) - .get("supported_reasoning_efforts", []) - if mi - else [] - ) - rp["reasoning_effort"] = ( - ( - eff - if eff in supp - else ("high" if "high" in supp else "medium") - ) - if supp - else eff - ) - if prov: - rp["provider"] = prov - session = await client.resume_session(cid, rp) - except: - pass - if not session: - session = await client.create_session( - config=self._build_session_config( - cid, - rmid, - tools, - sysp, - body.get("stream", False), - prov, - uv.REASONING_EFFORT or self.valves.REASONING_EFFORT, - isr, - uv.COMPACTION_THRESHOLD, - uv.BUFFER_THRESHOLD, - __event_call__, - uid, - ) - ) - if body.get("stream", False): - return self.stream_response( - client, - session, - {"prompt": text, "mode": "immediate", "attachments": att}, - "", - __event_call__, - uv.REASONING_EFFORT or self.valves.REASONING_EFFORT, - uv.SHOW_THINKING, - debug, - ) - else: - r = await session.send_and_wait( - {"prompt": text, "mode": "immediate", "attachments": att} - ) - return r.data.content if r else "空响应。" - except Exception as e: - return f"错误: {e}" - finally: - if not body.get("stream"): - await client.stop() - - async def pipes(self, __user__: Optional[dict] = None) -> List[dict]: - # 获取用户配置 - uv = self._get_user_valves(__user__) - token = uv.GH_TOKEN or self.valves.GH_TOKEN - - # 环境初始化 (带有 24 小时冷却时间) - from datetime import datetime - - now = datetime.now().timestamp() - if not self.__class__._env_setup_done or ( - now - self.__class__._last_update_check > 86400 - ): - self._setup_env(debug_enabled=uv.DEBUG or self.valves.DEBUG, token=token) - elif token: - os.environ["GH_TOKEN"] = os.environ["GITHUB_TOKEN"] = token - - # 确定倍率限制 - eff_max = self.valves.MAX_MULTIPLIER - if uv.MAX_MULTIPLIER is not None: - eff_max = uv.MAX_MULTIPLIER - - # 确定关键词和提供商过滤 - ex_kw = [ - k.strip().lower() - for k in (self.valves.EXCLUDE_KEYWORDS + "," + uv.EXCLUDE_KEYWORDS).split( - "," - ) - if k.strip() - ] - allowed_p = [ - p.strip().lower() - for p in (uv.PROVIDERS if uv.PROVIDERS else self.valves.PROVIDERS).split( - "," - ) - if p.strip() - ] - - # --- 新增:配置感知缓存刷新 --- - # 计算当前配置指纹以检测变化 - current_config_str = f"{token}|{(uv.BYOK_BASE_URL if uv else '') or self.valves.BYOK_BASE_URL}|{(uv.BYOK_API_KEY if uv else '') or self.valves.BYOK_API_KEY}|{(uv.BYOK_BEARER_TOKEN if uv else '') or self.valves.BYOK_BEARER_TOKEN}" - import hashlib - - current_config_hash = hashlib.md5(current_config_str.encode()).hexdigest() - - if ( - self._model_cache - and self.__class__._last_byok_config_hash != current_config_hash - ): - self.__class__._model_cache = [] - self.__class__._last_byok_config_hash = current_config_hash - - # 如果缓存为空,刷新模型列表 - if not self._model_cache: - self.__class__._last_byok_config_hash = current_config_hash - byok_models = [] - standard_models = [] - - # 1. 获取 BYOK 模型 (优先使用个人设置) - if ((uv.BYOK_BASE_URL if uv else "") or self.valves.BYOK_BASE_URL) and ( - (uv.BYOK_API_KEY if uv else "") - or self.valves.BYOK_API_KEY - or (uv.BYOK_BEARER_TOKEN if uv else "") - or self.valves.BYOK_BEARER_TOKEN - ): - byok_models = await self._fetch_byok_models(uv=uv) - - # 2. 获取标准 Copilot 模型 - if token: - c = await self._get_client() - try: - raw_models = await c.list_models() - raw = raw_models if isinstance(raw_models, list) else [] - processed = [] - - for m in raw: - try: - m_is_dict = isinstance(m, dict) - mid = m.get("id") if m_is_dict else getattr(m, "id", str(m)) - bill = ( - m.get("billing") - if m_is_dict - else getattr(m, "billing", None) - ) - if bill and not isinstance(bill, dict): - bill = ( - bill.to_dict() - if hasattr(bill, "to_dict") - else vars(bill) - ) - - pol = ( - m.get("policy") - if m_is_dict - else getattr(m, "policy", None) - ) - if pol and not isinstance(pol, dict): - pol = ( - pol.to_dict() - if hasattr(pol, "to_dict") - else vars(pol) - ) - - if (pol or {}).get("state") == "disabled": - continue - - cap = ( - m.get("capabilities") - if m_is_dict - else getattr(m, "capabilities", None) - ) - vis, reas, ctx, supp = False, False, None, [] - if cap: - if not isinstance(cap, dict): - cap = ( - cap.to_dict() - if hasattr(cap, "to_dict") - else vars(cap) - ) - s = cap.get("supports", {}) - vis, reas = s.get("vision", False), s.get( - "reasoning_effort", False - ) - l = cap.get("limits", {}) - ctx = l.get("max_context_window_tokens") - - raw_eff = ( - m.get("supported_reasoning_efforts") - if m_is_dict - else getattr(m, "supported_reasoning_efforts", []) - ) or [] - supp = [str(e).lower() for e in raw_eff if e] - mult = (bill or {}).get("multiplier", 1) - cid = self._clean_model_id(mid) - processed.append( - { - "id": f"{self.id}-{mid}", - "name": ( - f"-{cid} ({mult}x)" - if mult > 0 - else f"-🔥 {cid} (0x)" - ), - "multiplier": mult, - "raw_id": mid, - "source": "copilot", - "provider": self._get_provider_name(m), - "meta": { - "capabilities": { - "vision": vis, - "reasoning": reas, - "supported_reasoning_efforts": supp, - }, - "context_length": ctx, - }, - } - ) - except: - continue - - processed.sort(key=lambda x: (x["multiplier"], x["raw_id"])) - standard_models = processed - self._standard_model_ids = {m["raw_id"] for m in processed} - except: - pass - finally: - await c.stop() - - self._model_cache = standard_models + byok_models - - if not self._model_cache: - return [ - {"id": "error", "name": "未找到任何模型。请检查 Token 或 BYOK 配置。"} - ] - - # 3. 实时过滤结果 - res = [] - for m in self._model_cache: - # 提供商过滤 - if allowed_p and m.get("provider", "Unknown").lower() not in allowed_p: - continue - - mid, mname = (m.get("raw_id") or m.get("id", "")).lower(), m.get( - "name", "" - ).lower() - # 关键词过滤 - if any(kw in mid or kw in mname for kw in ex_kw): - continue - - # 倍率限制 (仅限 Copilot 官方模型) - if m.get("source") == "copilot": - if float(m.get("multiplier", 1)) > (float(eff_max) + 0.0001): - continue - - res.append(m) - - return res if res else [{"id": "none", "name": "没有匹配当前过滤条件的模型"}] - - async def stream_response( - self, - client, - session, - payload, - init_msg, - __event_call__, - effort="", - show_thinking=True, - debug=False, - ) -> AsyncGenerator: - queue, done, sentinel = asyncio.Queue(), asyncio.Event(), object() - state = {"thinking_started": False, "content_sent": False} - has_content = False - - def handler(event): - etype = ( - event.type.value if hasattr(event.type, "value") else str(event.type) - ) - - def get_attr(a): - if not hasattr(event, "data") or event.data is None: - return None - return ( - event.data.get(a) - if isinstance(event.data, dict) - else getattr(event.data, a, None) - ) - - if etype == "assistant.message_delta": - delta = ( - get_attr("delta_content") - or get_attr("deltaContent") - or get_attr("content") - ) - if delta: - state["content_sent"] = True - if state["thinking_started"]: - queue.put_nowait("\n\n") - state["thinking_started"] = False - queue.put_nowait(delta) - elif etype == "assistant.reasoning_delta": - delta = ( - get_attr("reasoning_text") - or get_attr("reasoningText") - or get_attr("delta_content") - ) - if delta and not state["content_sent"] and show_thinking: - if not state["thinking_started"]: - queue.put_nowait("\n") - state["thinking_started"] = True - queue.put_nowait(delta) - elif etype == "assistant.usage": - queue.put_nowait( - { - "choices": [{"delta": {}, "finish_reason": "stop", "index": 0}], - "usage": { - "prompt_tokens": get_attr("input_tokens") or 0, - "completion_tokens": get_attr("output_tokens") or 0, - "total_tokens": get_attr("total_tokens") or 0, - }, - } - ) - elif etype == "session.idle": - done.set() - queue.put_nowait(sentinel) - elif etype == "session.error": - queue.put_nowait(f"\n[错误: {get_attr('message')}]") - done.set() - queue.put_nowait(sentinel) - - unsubscribe = session.on(handler) - asyncio.create_task(session.send(payload)) - try: - while not done.is_set() or not queue.empty(): - try: - chunk = await asyncio.wait_for( - queue.get(), timeout=float(self.valves.TIMEOUT) - ) - if chunk is sentinel: - break - if chunk: - has_content = True - yield chunk - except asyncio.TimeoutError: - if done.is_set(): - break - continue - if state["thinking_started"]: - yield "\n\n" - if not has_content: - yield "⚠️ 未返回内容。" - except Exception as e: - yield f"\n[流错误: {e}]" - finally: - unsubscribe() - await client.stop() - - async def _extract_system_prompt( - self, - body, - messages, - request_model, - real_model_id, - __event_call__=None, - debug_enabled=False, - ): - sysp, src = None, "" - if body.get("system_prompt"): - sysp, src = body.get("system_prompt"), "body_explicit" - if not sysp: - meta = body.get("metadata", {}).get("model", {}).get("params", {}) - if meta.get("system"): - sysp, src = meta.get("system"), "metadata_params" - if not sysp: - try: - from open_webui.models.models import Models - - for mid in [request_model, real_model_id]: - m_rec = Models.get_model_by_id(mid) - if ( - m_rec - and hasattr(m_rec, "params") - and isinstance(m_rec.params, dict) - and m_rec.params.get("system") - ): - sysp, src = m_rec.params.get("system"), f"模型库:{mid}" - break - except: - pass - if not sysp: - for msg in messages: - if msg.get("role") == "system": - sysp, src = msg.get("content", ""), "消息历史" - break - if sysp: - await self._emit_debug_log( - f"系统提示词来源: {src} ({len(sysp)} 字符)", - __event_call__, - debug_enabled, - ) - return sysp, src - - def _build_client_config(self, body, user_id=None, chat_id=None): - c = { - "cli_path": os.environ.get("COPILOT_CLI_PATH"), - "cwd": self._get_workspace_dir(user_id, chat_id), - } - if self.valves.LOG_LEVEL: - c["log_level"] = self.valves.LOG_LEVEL - if self.valves.CUSTOM_ENV_VARS: - try: - e = json.loads(self.valves.CUSTOM_ENV_VARS) - c.update({"env": e}) if isinstance(e, dict) else None - except: - pass - return c diff --git a/plugins/pipes/github-copilot-sdk/v0.7.0.md b/plugins/pipes/github-copilot-sdk/v0.7.0.md new file mode 100644 index 0000000..590b07d --- /dev/null +++ b/plugins/pipes/github-copilot-sdk/v0.7.0.md @@ -0,0 +1,82 @@ +# GitHub Copilot SDK Pipe v0.7.0 + +**GitHub Copilot SDK Pipe v0.7.0** — A major infrastructure and UX upgrade. This release eliminates manual CLI management, fully embraces OpenWebUI's native tool calling interface, and ensures seamless compatibility with the latest OpenWebUI versions. + +--- + +## 📦 Quick Installation + +- **GitHub Copilot SDK (Pipe)**: [Install v0.7.0](https://openwebui.com/posts/ce96f7b4-12fc-4ac3-9a01-875713e69359) +- **GitHub Copilot SDK (Filter)**: [Install v0.1.2](https://openwebui.com/posts/403a62ee-a596-45e7-be65-fab9cc249dd6) + +--- + +## 🚀 What's New in v0.7.0 + +### 1. Zero-Maintenance CLI Integration + +The most significant infrastructure change: you no longer need to worry about CLI versions or background downloads. + +| Before (v0.6.x) | After (v0.7.0) | +| :--- | :--- | +| CLI installed via background `curl \| bash` | CLI bundled inside the `github-copilot-sdk` pip package | +| Version mismatches between SDK and CLI | Versions are always in sync automatically | +| Fails in restricted networks | Works everywhere `pip install` works | + +**How it works**: When you install `github-copilot-sdk==0.1.25`, the matching `copilot-cli v0.0.411` is included. The plugin auto-discovers the path and injects it into the environment—zero configuration required. + +### 2. Native OpenWebUI Tool Call UI + +Tool calls from Copilot agents now render using **OpenWebUI's built-in tool call UI**. + +- Tool execution status is displayed natively in the chat interface. +- Thinking processes (Chain of Thought) are visualized with the standard collapsible UI. +- Improved visual consistency and integration with the main OpenWebUI interface. + +### 3. OpenWebUI v0.8.0+ Compatibility Fix (Bug Fix) + +Resolved the **"Error getting file content"** failure that affected users on OpenWebUI v0.8.0 and later. + +- **The Issue**: Relative path registration for published files was rejected by the latest OpenWebUI versions. +- **The Fix**: Switched to **absolute path registration**, restoring the ability to download generated artifacts to your local machine. + +### 4. Comprehensive Multi-language Support (i18n) + +Native localization for status messages and UI hints in **11 languages**: +*English, Chinese (Simp/Trad/HK/TW), Japanese, Korean, French, German, Spanish, Italian, Russian, Vietnamese, and Indonesian.* + +### 5. Reasoning Status & UX Optimizations + +- **Intelligent Status Display**: `Reasoning Effort injected` status is now only shown for native Copilot reasoning models. +- **Clean UI**: Removed redundant debug/status noise for BYOK and standard models. +- **Architecture Cleanup**: Refactored core setup and removed legacy installation code for a robust "one-click" experience. + +--- + +## 🛠️ Key Capabilities + +| Feature | Description | +| :--- | :--- | +| **Universal Tool Protocol** | Native support for **MCP**, **OpenAPI**, and **OpenWebUI built-in tools**. | +| **Native Tool Call UI** | Adapted to OpenWebUI's built-in tool call rendering. | +| **Workspace Isolation** | Strict sandboxing for per-session data privacy and security. | +| **Workspace Artifacts** | Agents generate files (Excel/CSV/HTML) with persistent download links via `publish_file_from_workspace`. | +| **Tool Execution** | Direct access to system binaries (Python, FFmpeg, Git, etc.). | +| **11-Language Localization** | Auto-detected, native status messages for global users. | +| **OpenWebUI v0.8.0+ Support** | Robust file handling for the latest OpenWebUI platform versions. | + +--- + +## 📥 Import Chat Templates + +- [📥 Star Prediction Chat log](https://fu-jie.github.io/awesome-openwebui/plugins/pipes/star-prediction-chat.json) +- [📥 Video Processing Chat log](https://fu-jie.github.io/awesome-openwebui/plugins/pipes/video-processing-chat.json) + +*Settings -> Data -> Import Chats.* + +--- + +## 🔗 Resources + +- **GitHub Repository**: [openwebui-extensions](https://github.com/Fu-Jie/openwebui-extensions) +- **Full Changelog**: [README.md](https://github.com/Fu-Jie/openwebui-extensions/blob/main/plugins/pipes/github-copilot-sdk/README.md)