diff --git a/README.md b/README.md
index 5bdf284..d4f2fed 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,6 @@ A collection of enhancements, plugins, and prompts for [OpenWebUI](https://githu
## 📊 Community Stats
->
> 
| 👤 Author | 👥 Followers | ⭐ Points | 🏆 Contributions |
@@ -20,19 +19,18 @@ A collection of enhancements, plugins, and prompts for [OpenWebUI](https://githu
| :---: | :---: | :---: | :---: | :---: |
|  |  |  |  |  |
-### 🔥 Top 6 Popular Plugins
+### 🔥 Top 6 Popular Plugins
| Rank | Plugin | Version | Downloads | Views | 📅 Updated |
| :---: | :--- | :---: | :---: | :---: | :---: |
-| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) |  |  |  |  |
+| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) |  |  |  |  |
| 🥈 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) |  |  |  |  |
-| 🥉 | [Markdown Normalizer](https://openwebui.com/posts/markdown_normalizer_baaa8732) |  |  |  |  |
+| 🥉 | [Markdown Normalizer](https://openwebui.com/posts/markdown_normalizer_baaa8732) |  |  |  |  |
| 4️⃣ | [Export to Word Enhanced](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) |  |  |  |  |
-| 5️⃣ | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) |  |  |  |  |
+| 5️⃣ | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) |  |  |  |  |
| 6️⃣ | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) |  |  |  |  |
### 📈 Total Downloads Trend
-

*See full stats and charts in [Community Stats Report](./docs/community-stats.md)*
diff --git a/README_CN.md b/README_CN.md
index 4395c81..ff5348b 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -6,7 +6,6 @@ OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词
## 📊 社区统计
->
> 
| 👤 作者 | 👥 粉丝 | ⭐ 积分 | 🏆 贡献 |
@@ -17,19 +16,18 @@ OpenWebUI 增强功能集合。包含个人开发与收集的插件、提示词
| :---: | :---: | :---: | :---: | :---: |
|  |  |  |  |  |
-### 🔥 热门插件 Top 6
+### 🔥 热门插件 Top 6
| 排名 | 插件 | 版本 | 下载 | 浏览 | 📅 更新 |
| :---: | :--- | :---: | :---: | :---: | :---: |
-| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) |  |  |  |  |
+| 🥇 | [Smart Mind Map](https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a) |  |  |  |  |
| 🥈 | [Smart Infographic](https://openwebui.com/posts/smart_infographic_ad6f0c7f) |  |  |  |  |
-| 🥉 | [Markdown Normalizer](https://openwebui.com/posts/markdown_normalizer_baaa8732) |  |  |  |  |
+| 🥉 | [Markdown Normalizer](https://openwebui.com/posts/markdown_normalizer_baaa8732) |  |  |  |  |
| 4️⃣ | [Export to Word Enhanced](https://openwebui.com/posts/export_to_word_enhanced_formatting_fca6a315) |  |  |  |  |
-| 5️⃣ | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) |  |  |  |  |
+| 5️⃣ | [Async Context Compression](https://openwebui.com/posts/async_context_compression_b1655bc8) |  |  |  |  |
| 6️⃣ | [Export to Excel](https://openwebui.com/posts/export_mulit_table_to_excel_244b8f9d) |  |  |  |  |
### 📈 总下载量累计趋势
-

*完整统计与趋势图请查看 [社区统计报告](./docs/community-stats.zh.md)*
diff --git a/docs/community-stats.json b/docs/community-stats.json
index d3bcff7..182e39e 100644
--- a/docs/community-stats.json
+++ b/docs/community-stats.json
@@ -1,16 +1,17 @@
{
"total_posts": 25,
- "total_downloads": 6379,
- "total_views": 67827,
- "total_upvotes": 254,
- "total_downvotes": 3,
- "total_saves": 337,
+ "total_downloads": 7058,
+ "total_views": 75199,
+ "total_upvotes": 273,
+ "total_downvotes": 4,
+ "total_saves": 372,
"total_comments": 58,
"by_type": {
- "post": 6,
+ "tool": 1,
+ "post": 5,
"pipe": 1,
- "action": 12,
"filter": 4,
+ "action": 12,
"prompt": 1,
"review": 1
},
@@ -22,13 +23,13 @@
"version": "1.0.0",
"author": "Fu-Jie",
"description": "Intelligently analyzes text content and generates interactive mind maps to help users structure and visualize knowledge.",
- "downloads": 1328,
- "views": 11410,
- "upvotes": 23,
- "saves": 59,
+ "downloads": 1426,
+ "views": 12082,
+ "upvotes": 26,
+ "saves": 63,
"comments": 15,
- "created_at": "2025-12-30",
- "updated_at": "2026-02-27",
+ "created_at": "2025-12-31",
+ "updated_at": "2026-02-28",
"url": "https://openwebui.com/posts/turn_any_text_into_beautiful_mind_maps_3094c59a"
},
{
@@ -38,10 +39,10 @@
"version": "1.5.0",
"author": "Fu-Jie",
"description": "AI-powered infographic generator based on AntV Infographic. Supports professional templates, auto-icon matching, and SVG/PNG downloads.",
- "downloads": 1076,
- "views": 10746,
+ "downloads": 1155,
+ "views": 11609,
"upvotes": 25,
- "saves": 40,
+ "saves": 45,
"comments": 10,
"created_at": "2025-12-28",
"updated_at": "2026-02-13",
@@ -54,13 +55,13 @@
"version": "1.2.7",
"author": "Fu-Jie",
"description": "A content normalizer filter that fixes common Markdown formatting issues in LLM outputs, such as broken code blocks, LaTeX formulas, and list formatting. Including LaTeX command protection.",
- "downloads": 609,
- "views": 6795,
- "upvotes": 18,
- "saves": 37,
+ "downloads": 661,
+ "views": 7239,
+ "upvotes": 20,
+ "saves": 40,
"comments": 5,
"created_at": "2026-01-12",
- "updated_at": "2026-02-27",
+ "updated_at": "2026-02-28",
"url": "https://openwebui.com/posts/markdown_normalizer_baaa8732"
},
{
@@ -70,10 +71,10 @@
"version": "0.4.4",
"author": "Fu-Jie",
"description": "Export current conversation from Markdown to Word (.docx) with Mermaid diagrams rendered client-side (Mermaid.js, SVG+PNG), LaTeX math, real hyperlinks, improved tables, syntax highlighting, and blockquote support.",
- "downloads": 578,
- "views": 4611,
+ "downloads": 628,
+ "views": 4995,
"upvotes": 16,
- "saves": 30,
+ "saves": 35,
"comments": 5,
"created_at": "2026-01-03",
"updated_at": "2026-02-13",
@@ -86,13 +87,13 @@
"version": "1.3.0",
"author": "Fu-Jie",
"description": "Reduces token consumption in long conversations while maintaining coherence through intelligent summarization and message compression.",
- "downloads": 559,
- "views": 5452,
- "upvotes": 15,
- "saves": 41,
+ "downloads": 619,
+ "views": 5875,
+ "upvotes": 16,
+ "saves": 46,
"comments": 0,
"created_at": "2025-11-08",
- "updated_at": "2026-02-21",
+ "updated_at": "2026-02-28",
"url": "https://openwebui.com/posts/async_context_compression_b1655bc8"
},
{
@@ -102,10 +103,10 @@
"version": "0.3.7",
"author": "Fu-Jie",
"description": "Extracts tables from chat messages and exports them to Excel (.xlsx) files with smart formatting.",
- "downloads": 492,
- "views": 2693,
+ "downloads": 523,
+ "views": 2898,
"upvotes": 10,
- "saves": 8,
+ "saves": 9,
"comments": 0,
"created_at": "2025-05-30",
"updated_at": "2026-02-13",
@@ -118,8 +119,8 @@
"version": "",
"author": "",
"description": "",
- "downloads": 473,
- "views": 5498,
+ "downloads": 523,
+ "views": 6055,
"upvotes": 9,
"saves": 14,
"comments": 0,
@@ -127,22 +128,6 @@
"updated_at": "2026-01-28",
"url": "https://openwebui.com/posts/ai_task_instruction_generator_9bab8b37"
},
- {
- "title": "Flash Card",
- "slug": "flash_card_65a2ea8f",
- "type": "action",
- "version": "0.2.4",
- "author": "Fu-Jie",
- "description": "Quickly generates beautiful flashcards from text, extracting key points and categories.",
- "downloads": 285,
- "views": 4128,
- "upvotes": 13,
- "saves": 18,
- "comments": 2,
- "created_at": "2025-12-30",
- "updated_at": "2026-02-13",
- "url": "https://openwebui.com/posts/flash_card_65a2ea8f"
- },
{
"title": "GitHub Copilot Official SDK Pipe",
"slug": "github_copilot_official_sdk_pipe_ce96f7b4",
@@ -150,15 +135,31 @@
"version": "0.9.0",
"author": "Fu-Jie",
"description": "Integrate GitHub Copilot SDK. Supports dynamic models, multi-turn conversation, streaming, multimodal input, infinite sessions, bidirectional OpenWebUI Skills bridge, and manage_skills tool.",
- "downloads": 263,
- "views": 4106,
- "upvotes": 14,
+ "downloads": 301,
+ "views": 4540,
+ "upvotes": 16,
"saves": 10,
"comments": 6,
"created_at": "2026-01-26",
- "updated_at": "2026-02-27",
+ "updated_at": "2026-02-28",
"url": "https://openwebui.com/posts/github_copilot_official_sdk_pipe_ce96f7b4"
},
+ {
+ "title": "Flash Card",
+ "slug": "flash_card_65a2ea8f",
+ "type": "action",
+ "version": "0.2.4",
+ "author": "Fu-Jie",
+ "description": "Quickly generates beautiful flashcards from text, extracting key points and categories.",
+ "downloads": 295,
+ "views": 4297,
+ "upvotes": 13,
+ "saves": 20,
+ "comments": 2,
+ "created_at": "2025-12-30",
+ "updated_at": "2026-02-13",
+ "url": "https://openwebui.com/posts/flash_card_65a2ea8f"
+ },
{
"title": "Deep Dive",
"slug": "deep_dive_c0b846e4",
@@ -166,15 +167,31 @@
"version": "1.0.0",
"author": "Fu-Jie",
"description": "A comprehensive thinking lens that dives deep into any content - from context to logic, insights, and action paths.",
- "downloads": 204,
- "views": 1631,
+ "downloads": 211,
+ "views": 1699,
"upvotes": 6,
- "saves": 13,
+ "saves": 14,
"comments": 0,
"created_at": "2026-01-08",
"updated_at": "2026-01-08",
"url": "https://openwebui.com/posts/deep_dive_c0b846e4"
},
+ {
+ "title": "OpenWebUI Skills Manager Tool",
+ "slug": "openwebui_skills_manager_tool_b4bce8e4",
+ "type": "tool",
+ "version": "",
+ "author": "",
+ "description": "",
+ "downloads": 169,
+ "views": 2629,
+ "upvotes": 6,
+ "saves": 7,
+ "comments": 0,
+ "created_at": "2026-02-28",
+ "updated_at": "2026-02-28",
+ "url": "https://openwebui.com/posts/openwebui_skills_manager_tool_b4bce8e4"
+ },
{
"title": "导出为Word增强版",
"slug": "导出为_word_支持公式流程图表格和代码块_8a6306c0",
@@ -182,8 +199,8 @@
"version": "0.4.4",
"author": "Fu-Jie",
"description": "将对话导出为 Word (.docx),支持 Mermaid 图表 (客户端渲染 SVG+PNG)、LaTeX 数学公式、真实超链接、增强表格格式、代码高亮和引用块。",
- "downloads": 153,
- "views": 2631,
+ "downloads": 157,
+ "views": 2732,
"upvotes": 14,
"saves": 7,
"comments": 4,
@@ -198,8 +215,8 @@
"version": "0.1.0",
"author": "Fu-Jie",
"description": "Automatically extracts project rules from conversations and injects them into the folder's system prompt.",
- "downloads": 99,
- "views": 1839,
+ "downloads": 106,
+ "views": 1911,
"upvotes": 7,
"saves": 11,
"comments": 0,
@@ -207,6 +224,22 @@
"updated_at": "2026-01-20",
"url": "https://openwebui.com/posts/folder_memory_auto_evolving_project_context_4a9875b2"
},
+ {
+ "title": "GitHub Copilot SDK Files Filter",
+ "slug": "github_copilot_sdk_files_filter_403a62ee",
+ "type": "filter",
+ "version": "0.1.3",
+ "author": "Fu-Jie",
+ "description": "A specialized filter to bypass OpenWebUI's default RAG for GitHub Copilot SDK models. It moves uploaded files to a safe location ('copilot_files') so the Copilot Pipe can process them natively without interference.",
+ "downloads": 69,
+ "views": 2231,
+ "upvotes": 4,
+ "saves": 1,
+ "comments": 0,
+ "created_at": "2026-02-09",
+ "updated_at": "2026-02-26",
+ "url": "https://openwebui.com/posts/github_copilot_sdk_files_filter_403a62ee"
+ },
{
"title": "智能信息图",
"slug": "智能信息图_e04a48ff",
@@ -215,7 +248,7 @@
"author": "Fu-Jie",
"description": "基于 AntV Infographic 的智能信息图生成插件。支持多种专业模板,自动图标匹配,并提供 SVG/PNG 下载功能。",
"downloads": 65,
- "views": 1304,
+ "views": 1370,
"upvotes": 10,
"saves": 1,
"comments": 0,
@@ -223,22 +256,6 @@
"updated_at": "2026-02-13",
"url": "https://openwebui.com/posts/智能信息图_e04a48ff"
},
- {
- "title": "GitHub Copilot SDK Files Filter",
- "slug": "github_copilot_sdk_files_filter_403a62ee",
- "type": "filter",
- "version": "0.1.3",
- "author": "Fu-Jie",
- "description": "A specialized filter to bypass OpenWebUI's default RAG for GitHub Copilot SDK models. It moves uploaded files to a safe location ('copilot_files') so the Copilot Pipe can process them natively without interference.",
- "downloads": 54,
- "views": 2098,
- "upvotes": 3,
- "saves": 1,
- "comments": 0,
- "created_at": "2026-02-09",
- "updated_at": "2026-02-25",
- "url": "https://openwebui.com/posts/github_copilot_sdk_files_filter_403a62ee"
- },
{
"title": "思维导图",
"slug": "智能生成交互式思维导图帮助用户可视化知识_8d4b097b",
@@ -246,8 +263,8 @@
"version": "0.9.2",
"author": "Fu-Jie",
"description": "智能分析文本内容,生成交互式思维导图,帮助用户结构化和可视化知识。",
- "downloads": 45,
- "views": 691,
+ "downloads": 50,
+ "views": 734,
"upvotes": 6,
"saves": 2,
"comments": 0,
@@ -263,7 +280,7 @@
"author": "Fu-Jie",
"description": "通过智能摘要和消息压缩,降低长对话的 token 消耗,同时保持对话连贯性。",
"downloads": 38,
- "views": 783,
+ "views": 814,
"upvotes": 7,
"saves": 5,
"comments": 0,
@@ -278,8 +295,8 @@
"version": "0.2.4",
"author": "Fu-Jie",
"description": "快速将文本提炼为精美的学习记忆卡片,支持核心要点提取与分类。",
- "downloads": 32,
- "views": 830,
+ "downloads": 33,
+ "views": 863,
"upvotes": 7,
"saves": 1,
"comments": 0,
@@ -294,8 +311,8 @@
"version": "1.0.0",
"author": "Fu-Jie",
"description": "全方位的思维透镜 —— 从背景全景到逻辑脉络,从深度洞察到行动路径。",
- "downloads": 26,
- "views": 581,
+ "downloads": 29,
+ "views": 626,
"upvotes": 5,
"saves": 1,
"comments": 0,
@@ -304,51 +321,35 @@
"url": "https://openwebui.com/posts/精读_99830b0f"
},
{
- "title": "🚀 GitHub Copilot SDK Pipe v0.9.0: Copilot SDK Skills Core Capabilities & Extended Delivery",
+ "title": "🚀 GitHub Copilot SDK Pipe v0.9.0: Skills & RichUI",
"slug": "github_copilot_sdk_pipe_v090_copilot_sdk_skills_co_99a42452",
"type": "post",
"version": "",
"author": "",
"description": "",
"downloads": 0,
- "views": 7,
- "upvotes": 0,
- "saves": 0,
+ "views": 1162,
+ "upvotes": 5,
+ "saves": 1,
"comments": 0,
- "created_at": "2026-02-27",
- "updated_at": "2026-02-27",
+ "created_at": "2026-02-28",
+ "updated_at": "2026-02-28",
"url": "https://openwebui.com/posts/github_copilot_sdk_pipe_v090_copilot_sdk_skills_co_99a42452"
},
{
- "title": "🚀 GitHub Copilot SDK Pipe v0.8.0: Conditional Tool Filtering & Publish Reliability 🎛️",
- "slug": "github_copilot_sdk_pipe_v080_conditional_tool_filt_a5a3322d",
- "type": "post",
- "version": "",
- "author": "",
- "description": "",
- "downloads": 0,
- "views": 1059,
- "upvotes": 2,
- "saves": 2,
- "comments": 0,
- "created_at": "2026-02-25",
- "updated_at": "2026-02-25",
- "url": "https://openwebui.com/posts/github_copilot_sdk_pipe_v080_conditional_tool_filt_a5a3322d"
- },
- {
- "title": "🚀 GitHub Copilot SDK Pipe v0.7.0: Native Tool UI & Zero-Config CLI 🛠️",
+ "title": "🚀 GitHub Copilot SDK Pipe v0.7.0: Skills & Rich UI 🛠️",
"slug": "github_copilot_sdk_pipe_v070_native_tool_ui_zero_c_4af38131",
"type": "post",
"version": "",
"author": "",
"description": "",
"downloads": 0,
- "views": 2162,
- "upvotes": 7,
+ "views": 2504,
+ "upvotes": 8,
"saves": 2,
"comments": 1,
- "created_at": "2026-02-22",
- "updated_at": "2026-02-22",
+ "created_at": "2026-02-23",
+ "updated_at": "2026-02-28",
"url": "https://openwebui.com/posts/github_copilot_sdk_pipe_v070_native_tool_ui_zero_c_4af38131"
},
{
@@ -359,7 +360,7 @@
"author": "",
"description": "",
"downloads": 0,
- "views": 2257,
+ "views": 2341,
"upvotes": 7,
"saves": 4,
"comments": 0,
@@ -375,12 +376,12 @@
"author": "",
"description": "",
"downloads": 0,
- "views": 1839,
+ "views": 1887,
"upvotes": 12,
- "saves": 19,
+ "saves": 21,
"comments": 8,
"created_at": "2026-01-25",
- "updated_at": "2026-01-28",
+ "updated_at": "2026-01-29",
"url": "https://openwebui.com/posts/open_webui_prompt_plus_ai_powered_prompt_manager_s_15fa060e"
},
{
@@ -391,7 +392,7 @@
"author": "",
"description": "",
"downloads": 0,
- "views": 234,
+ "views": 246,
"upvotes": 2,
"saves": 0,
"comments": 0,
@@ -407,9 +408,9 @@
"author": "",
"description": "",
"downloads": 0,
- "views": 1502,
+ "views": 1531,
"upvotes": 16,
- "saves": 11,
+ "saves": 12,
"comments": 2,
"created_at": "2026-01-10",
"updated_at": "2026-01-10",
@@ -421,10 +422,10 @@
"name": "Fu-Jie",
"profile_url": "https://openwebui.com/u/Fu-Jie",
"profile_image": "https://community.s3.openwebui.com/uploads/users/b15d1348-4347-42b4-b815-e053342d6cb0/profile_d9510745-4bd4-4f8f-a997-4a21847d9300.webp",
- "followers": 295,
+ "followers": 307,
"following": 6,
- "total_points": 299,
- "post_points": 251,
+ "total_points": 319,
+ "post_points": 271,
"comment_points": 48,
"contributions": 54
}
diff --git a/plugins/debug/copilot-sdk/auto_programming_task.py b/plugins/debug/copilot-sdk/auto_programming_task.py
new file mode 100644
index 0000000..329e9e5
--- /dev/null
+++ b/plugins/debug/copilot-sdk/auto_programming_task.py
@@ -0,0 +1,447 @@
+#!/usr/bin/env python3
+"""
+Run an autonomous programming task via Copilot SDK.
+
+Usage:
+ python plugins/debug/copilot-sdk/auto_programming_task.py \
+ --task "Fix failing tests in tests/test_xxx.py" \
+ --cwd /Users/fujie/app/python/oui/openwebui-extensions
+
+Notes:
+- Default model is gpt-5-mini (low-cost for repeated runs).
+- This script DOES NOT pin/upgrade SDK versions.
+- Copilot CLI must be available (or set COPILOT_CLI_PATH).
+"""
+
+import argparse
+import asyncio
+import os
+import sys
+import textwrap
+from pathlib import Path
+from typing import Optional
+
+
+DEFAULT_TASK = (
+ "Convert plugins/actions/smart-mind-map/smart_mind_map.py (Action plugin) "
+ "into a Tool plugin implementation under plugins/tools/. "
+ "Keep Copilot SDK version unchanged, follow patterns from "
+ "plugins/pipes/github-copilot-sdk/, and implement a runnable MVP with "
+ "i18n/status events/basic validation."
+)
+
+
+def _ensure_copilot_importable() -> None:
+ """Try local SDK path fallback if `copilot` package is not installed."""
+ try:
+ import copilot # noqa: F401
+
+ return
+ except Exception:
+ pass
+
+ candidates = []
+
+ env_path = os.environ.get("COPILOT_SDK_PYTHON_PATH", "").strip()
+ if env_path:
+ candidates.append(Path(env_path))
+
+ # Default sibling repo path: ../copilot-sdk/python
+ # Current file: plugins/debug/copilot-sdk/auto_programming_task.py
+ repo_root = Path(__file__).resolve().parents[3]
+ candidates.append(repo_root.parent / "copilot-sdk" / "python")
+
+ for path in candidates:
+ if path.exists():
+ sys.path.insert(0, str(path))
+ try:
+ import copilot # noqa: F401
+
+ return
+ except Exception:
+ continue
+
+ raise RuntimeError(
+ "Cannot import `copilot` package. Install copilot-sdk python package "
+ "or set COPILOT_SDK_PYTHON_PATH to copilot-sdk/python directory."
+ )
+
+
+def _build_agent_prompt(task: str, cwd: str, extra_context: Optional[str]) -> str:
+ extra = extra_context.strip() if extra_context else ""
+ return textwrap.dedent(
+ f"""
+ You are an autonomous coding agent running in workspace: {cwd}
+
+ Primary task:
+ {task}
+
+ Requirements:
+ 1. Inspect relevant files and implement changes directly in the workspace.
+ 2. Keep changes minimal and focused on the task.
+ 3. If tests/build commands exist, run targeted validation for changed scope.
+ 4. If blocked, explain the blocker and propose concrete next steps.
+ 5. At the end, provide a concise summary of:
+ - files changed
+ - what was implemented
+ - validation results
+
+ {f'Additional context:\n{extra}' if extra else ''}
+ """
+ ).strip()
+
+
+def _build_planning_prompt(task: str, cwd: str, extra_context: Optional[str]) -> str:
+ extra = extra_context.strip() if extra_context else ""
+ return textwrap.dedent(
+ f"""
+ You are a senior autonomous coding planner running in workspace: {cwd}
+
+ User requirement (may be underspecified):
+ {task}
+
+ Goal:
+ Expand the requirement into an actionable implementation plan that can be executed end-to-end without extra clarification whenever possible.
+
+ Output format (strict):
+ 1) Expanded Objective (clear, concrete, scoped)
+ 2) Assumptions (only necessary assumptions)
+ 3) Step-by-step Plan (ordered, verifiable)
+ 4) Validation Plan (how to verify changes)
+ 5) Execution Brief (concise instruction for implementation agent)
+
+ Constraints:
+ - Keep scope minimal and aligned with the user requirement.
+ - Do not invent unrelated features.
+ - Prefer practical MVP completion.
+
+ {f'Additional context:\n{extra}' if extra else ''}
+ """
+ ).strip()
+
+
+def _build_execution_prompt(
+ task: str, cwd: str, extra_context: Optional[str], plan_text: str
+) -> str:
+ extra = extra_context.strip() if extra_context else ""
+ return textwrap.dedent(
+ f"""
+ You are an autonomous coding agent running in workspace: {cwd}
+
+ User requirement:
+ {task}
+
+ Planner output (must follow):
+ {plan_text}
+
+ Execution requirements:
+ 1. Execute the plan directly; do not stop after analysis.
+ 2. If the original requirement is underspecified, use the planner assumptions and continue.
+ 3. Keep changes minimal, focused, and runnable.
+ 4. Run targeted validation for changed scope where possible.
+ 5. If blocked by missing prerequisites, report blocker and the smallest next action.
+ 6. Finish with concise summary:
+ - files changed
+ - implemented behavior
+ - validation results
+
+ {f'Additional context:\n{extra}' if extra else ''}
+ """
+ ).strip()
+
+
+async def _run_single_session(
+ client,
+ args: argparse.Namespace,
+ prompt: str,
+ stage_name: str,
+ stream_output: bool,
+) -> tuple[int, str]:
+ from copilot.types import PermissionHandler
+
+ def _auto_user_input_handler(request, _invocation):
+ question = ""
+ if isinstance(request, dict):
+ question = str(request.get("question", "")).lower()
+ choices = request.get("choices") or []
+ if choices and isinstance(choices, list):
+ preferred = args.auto_user_answer.strip()
+ for choice in choices:
+ c = str(choice)
+ if preferred and preferred.lower() == c.lower():
+ return {"answer": c, "wasFreeform": False}
+ return {"answer": str(choices[0]), "wasFreeform": False}
+
+ preferred = args.auto_user_answer.strip() or "continue"
+ if "confirm" in question or "proceed" in question:
+ preferred = "yes"
+ return {"answer": preferred, "wasFreeform": True}
+
+ session_config = {
+ "model": args.model,
+ "reasoning_effort": args.reasoning_effort,
+ "streaming": True,
+ "infinite_sessions": {
+ "enabled": True,
+ },
+ "on_permission_request": PermissionHandler.approve_all,
+ "on_user_input_request": _auto_user_input_handler,
+ }
+
+ session = await client.create_session(session_config)
+
+ done = asyncio.Event()
+ full_messages = []
+ has_error = False
+
+ def on_event(event):
+ nonlocal has_error
+ etype = getattr(event, "type", "unknown")
+ if hasattr(etype, "value"):
+ etype = etype.value
+
+ if args.trace_events:
+ print(f"\n[{stage_name}][EVENT] {etype}", flush=True)
+
+ if etype == "assistant.message_delta" and stream_output:
+ delta = getattr(event.data, "delta_content", "") or ""
+ if delta:
+ print(delta, end="", flush=True)
+
+ elif etype == "assistant.message":
+ content = getattr(event.data, "content", "") or ""
+ if content:
+ full_messages.append(content)
+
+ elif etype == "session.error":
+ has_error = True
+ done.set()
+ elif etype == "session.idle":
+ done.set()
+
+ unsubscribe = session.on(on_event)
+ heartbeat_task = None
+
+ async def _heartbeat():
+ while not done.is_set():
+ await asyncio.sleep(max(3, int(args.heartbeat_seconds)))
+ if not done.is_set():
+ print(
+ f"[{stage_name}][heartbeat] waiting for assistant events...",
+ flush=True,
+ )
+
+ try:
+ heartbeat_task = asyncio.create_task(_heartbeat())
+ await session.send({"prompt": prompt, "mode": "immediate"})
+ await asyncio.wait_for(done.wait(), timeout=args.timeout)
+
+ if stream_output:
+ print("\n")
+
+ final_message = full_messages[-1] if full_messages else ""
+ if final_message:
+ print(f"\n===== {stage_name} FINAL MESSAGE =====\n")
+ print(final_message)
+
+ if has_error:
+ return 1, final_message
+ return 0, final_message
+
+ except asyncio.TimeoutError:
+ print(f"\n❌ [{stage_name}] Timeout after {args.timeout}s")
+ return 124, ""
+ except Exception as exc:
+ print(f"\n❌ [{stage_name}] Run failed: {exc}")
+ return 1, ""
+ finally:
+ if heartbeat_task:
+ heartbeat_task.cancel()
+ try:
+ unsubscribe()
+ except Exception:
+ pass
+ try:
+ await session.destroy()
+ except Exception:
+ pass
+
+
+async def run_task(args: argparse.Namespace) -> int:
+ _ensure_copilot_importable()
+
+ from copilot import CopilotClient
+
+ task_text = (args.task or "").strip()
+ if args.task_file:
+ task_text = Path(args.task_file).read_text(encoding="utf-8").strip()
+
+ if not task_text:
+ task_text = DEFAULT_TASK
+
+ direct_prompt = _build_agent_prompt(task_text, args.cwd, args.extra_context)
+
+ client_options = {
+ "cwd": args.cwd,
+ "log_level": args.log_level,
+ }
+
+ if args.cli_path:
+ client_options["cli_path"] = args.cli_path
+
+ if args.github_token:
+ client_options["github_token"] = args.github_token
+
+ print(f"🚀 Starting Copilot SDK task runner")
+ print(f" cwd: {args.cwd}")
+ print(f" model: {args.model}")
+ print(f" reasoning_effort: {args.reasoning_effort}")
+ print(f" plan_first: {args.plan_first}")
+
+ client = CopilotClient(client_options)
+ await client.start()
+
+ try:
+ if args.plan_first:
+ planning_prompt = _build_planning_prompt(
+ task_text, args.cwd, args.extra_context
+ )
+ print("\n🧭 Stage 1/2: Planning and requirement expansion")
+ plan_code, plan_text = await _run_single_session(
+ client=client,
+ args=args,
+ prompt=planning_prompt,
+ stage_name="PLANNING",
+ stream_output=False,
+ )
+ if plan_code != 0:
+ return plan_code
+
+ execution_prompt = _build_execution_prompt(
+ task=task_text,
+ cwd=args.cwd,
+ extra_context=args.extra_context,
+ plan_text=plan_text or "(No planner output provided)",
+ )
+ print("\n⚙️ Stage 2/2: Execute plan autonomously")
+ exec_code, _ = await _run_single_session(
+ client=client,
+ args=args,
+ prompt=execution_prompt,
+ stage_name="EXECUTION",
+ stream_output=args.stream,
+ )
+ return exec_code
+
+ print("\n⚙️ Direct mode: Execute task without planning stage")
+ exec_code, _ = await _run_single_session(
+ client=client,
+ args=args,
+ prompt=direct_prompt,
+ stage_name="EXECUTION",
+ stream_output=args.stream,
+ )
+ return exec_code
+ finally:
+ try:
+ await client.stop()
+ except Exception:
+ pass
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Run one autonomous programming task with Copilot SDK"
+ )
+ parser.add_argument(
+ "--task",
+ default="",
+ help="Task description text (if empty, uses built-in default task)",
+ )
+ parser.add_argument("--task-file", default="", help="Path to a task text file")
+ parser.add_argument("--cwd", default=os.getcwd(), help="Workspace directory")
+ parser.add_argument(
+ "--model",
+ default="gpt-5-mini",
+ help="Model id for Copilot session (default: gpt-5-mini)",
+ )
+ parser.add_argument(
+ "--reasoning-effort",
+ default="medium",
+ choices=["low", "medium", "high", "xhigh"],
+ help="Reasoning effort",
+ )
+ parser.add_argument("--timeout", type=int, default=1800, help="Timeout seconds")
+ parser.add_argument(
+ "--log-level",
+ default="info",
+ choices=["trace", "debug", "info", "warn", "error"],
+ help="Copilot client log level",
+ )
+ parser.add_argument(
+ "--github-token",
+ default=os.environ.get("GH_TOKEN", ""),
+ help="Optional GitHub token; defaults to GH_TOKEN",
+ )
+ parser.add_argument(
+ "--cli-path",
+ default=os.environ.get("COPILOT_CLI_PATH", ""),
+ help="Optional Copilot CLI path",
+ )
+ parser.add_argument(
+ "--extra-context",
+ default="",
+ help="Optional extra context appended to the task prompt",
+ )
+ parser.add_argument(
+ "--stream",
+ action="store_true",
+ help="Print assistant delta stream in real-time",
+ )
+ parser.add_argument(
+ "--trace-events",
+ action="store_true",
+ help="Print each SDK event type for debugging",
+ )
+ parser.add_argument(
+ "--auto-user-answer",
+ default="continue",
+ help="Default answer for on_user_input_request",
+ )
+ parser.add_argument(
+ "--heartbeat-seconds",
+ type=int,
+ default=12,
+ help="Heartbeat interval while waiting for events",
+ )
+ parser.add_argument(
+ "--plan-first",
+ action="store_true",
+ help="Run planning stage before execution (default behavior)",
+ )
+ parser.add_argument(
+ "--no-plan-first",
+ action="store_true",
+ help="Disable planning stage and run direct execution",
+ )
+ return parser
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+
+ if args.task_file and (args.task or "").strip():
+ parser.error("Use either --task or --task-file, not both")
+
+ args.plan_first = True
+ if args.no_plan_first:
+ args.plan_first = False
+ elif args.plan_first:
+ args.plan_first = True
+
+ return asyncio.run(run_task(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/openwebui_stats.py b/scripts/openwebui_stats.py
index deceb51..0e00016 100644
--- a/scripts/openwebui_stats.py
+++ b/scripts/openwebui_stats.py
@@ -1,20 +1,21 @@
#!/usr/bin/env python3
"""
-OpenWebUI 社区统计工具
+OpenWebUI community stats utility.
-获取并统计你在 openwebui.com 上发布的插件/帖子数据。
+Collect and summarize your published posts/plugins on openwebui.com.
-使用方法:
- 1. 设置环境变量:
- - OPENWEBUI_API_KEY: 你的 API Key
- - OPENWEBUI_USER_ID: 你的用户 ID
- 2. 运行: python scripts/openwebui_stats.py
+Usage:
+ 1. Set environment variables:
+ - OPENWEBUI_API_KEY: required
+ - OPENWEBUI_USER_ID: optional (auto-resolved from /api/v1/auths/ when missing)
+ 2. Run: python scripts/openwebui_stats.py
-获取 API Key:
- 访问 https://openwebui.com/settings/api 创建 API Key (sk-开头)
+How to get API key:
+ Visit https://openwebui.com/settings/api and create a key (starts with sk-).
-获取 User ID:
- 从个人主页的 API 请求中获取,格式如: b15d1348-4347-42b4-b815-e053342d6cb0
+How to get user ID (optional):
+ Read the `id` field from /api/v1/auths/, format example:
+ b15d1348-4347-42b4-b815-e053342d6cb0
"""
import os
@@ -28,16 +29,16 @@ from datetime import datetime, timezone, timedelta
from typing import Optional
from pathlib import Path
-# 北京时区 (UTC+8)
+# Beijing timezone (UTC+8)
BEIJING_TZ = timezone(timedelta(hours=8))
def get_beijing_time() -> datetime:
- """获取当前北京时间"""
+ """Get current time in Beijing timezone."""
return datetime.now(BEIJING_TZ)
-# 尝试加载 .env 文件
+# Try loading local .env file (if python-dotenv is installed)
try:
from dotenv import load_dotenv
@@ -47,7 +48,7 @@ except ImportError:
class OpenWebUIStats:
- """OpenWebUI 社区统计工具"""
+ """OpenWebUI community stats utility."""
BASE_URL = "https://api.openwebui.com/api/v1"
@@ -59,18 +60,18 @@ class OpenWebUIStats:
gist_id: Optional[str] = None,
):
"""
- 初始化统计工具
+ Initialize the stats utility
Args:
api_key: OpenWebUI API Key (JWT Token)
- user_id: 用户 ID,如果为 None 则从 token 中解析
- gist_token: GitHub Personal Access Token (用于读写 Gist)
+ user_id: User ID; if None, will be parsed from token
+ gist_token: GitHub Personal Access Token (for reading/writing Gist)
gist_id: GitHub Gist ID
"""
self.api_key = api_key
self.user_id = user_id or self._parse_user_id_from_token(api_key)
self.gist_token = gist_token
- self.gist_id = gist_id
+ self.gist_id = gist_id or "db3d95687075a880af6f1fba76d679c6"
self.history_filename = "community-stats-history.json"
self.session = requests.Session()
@@ -83,23 +84,32 @@ class OpenWebUIStats:
)
self.history_file = Path("docs/stats-history.json")
- # 定义下载类别的判定(这些类别会计入总浏览量/下载量统计)
+ # Types considered downloadable (included in total view/download stats)
DOWNLOADABLE_TYPES = [
"action",
"filter",
"pipe",
- "toolkit",
+ "tool",
"function",
"prompt",
"model",
]
+ TYPE_ALIASES = {
+ "tools": "tool",
+ }
+
+ def _normalize_post_type(self, post_type: str) -> str:
+ """Normalize post type to avoid synonym type splitting in statistics."""
+ normalized = str(post_type or "").strip().lower()
+ return self.TYPE_ALIASES.get(normalized, normalized)
+
def load_history(self) -> list:
- """加载历史记录 (合并 Gist + 本地文件, 取记录更多的)"""
+ """Load history records (merge Gist + local file, keep the one with more records)"""
gist_history = []
local_history = []
- # 1. 尝试从 Gist 加载
+ # 1. Try loading from Gist
if self.gist_token and self.gist_id:
try:
url = f"https://api.github.com/gists/{self.gist_id}"
@@ -111,30 +121,36 @@ class OpenWebUIStats:
if file_info:
content = file_info.get("content")
gist_history = json.loads(content)
- print(f"✅ 已从 Gist 加载历史记录 ({len(gist_history)} 条)")
+ print(
+ f"✅ Loaded history from Gist ({len(gist_history)} records)"
+ )
except Exception as e:
- print(f"⚠️ 无法从 Gist 加载历史: {e}")
+ print(f"⚠️ Failed to load history from Gist: {e}")
- # 2. 同时从本地文件加载
+ # 2. Also load from local file
if self.history_file.exists():
try:
with open(self.history_file, "r", encoding="utf-8") as f:
local_history = json.load(f)
- print(f"✅ 已从本地加载历史记录 ({len(local_history)} 条)")
+ print(
+ f"✅ Loaded history from local file ({len(local_history)} records)"
+ )
except Exception as e:
- print(f"⚠️ 无法加载本地历史记录: {e}")
+ print(f"⚠️ Failed to load local history: {e}")
- # 3. 合并两个来源 (以日期为 key, 有冲突时保留更新的)
+ # 3. Merge two sources (by date as key, keep newer when conflicts)
hist_dict = {}
for item in gist_history:
hist_dict[item["date"]] = item
for item in local_history:
- hist_dict[item["date"]] = item # 本地数据覆盖 Gist (更可能是最新的)
+ hist_dict[item["date"]] = (
+ item # Local data overrides Gist (more likely to be latest)
+ )
history = sorted(hist_dict.values(), key=lambda x: x["date"])
- print(f"📊 合并后历史记录: {len(history)} 条")
+ print(f"📊 Merged history records: {len(history)}")
- # 4. 如果合并后仍然太少, 尝试从 Git 历史重建
+ # 4. If merged data is still too short, try rebuilding from Git
if len(history) < 5 and os.path.isdir(".git"):
print("📉 History too short, attempting Git rebuild...")
git_history = self.rebuild_history_from_git()
@@ -146,7 +162,7 @@ class OpenWebUIStats:
hist_dict[item["date"]] = item
history = sorted(hist_dict.values(), key=lambda x: x["date"])
- # 5. 如果有新数据, 同步回 Gist
+ # 5. If there is new data, sync back to Gist
if len(history) > len(gist_history) and self.gist_token and self.gist_id:
try:
url = f"https://api.github.com/gists/{self.gist_id}"
@@ -160,7 +176,7 @@ class OpenWebUIStats:
}
resp = requests.patch(url, headers=headers, json=payload)
if resp.status_code == 200:
- print(f"✅ 历史记录已同步至 Gist ({len(history)} 条)")
+ print(f"✅ History synced to Gist ({len(history)} records)")
else:
print(f"⚠️ Gist sync failed: {resp.status_code}")
except Exception as e:
@@ -169,11 +185,11 @@ class OpenWebUIStats:
return history
def save_history(self, stats: dict):
- """保存当前快照到历史记录 (优先保存到 Gist, 其次本地)"""
+ """Save current snapshot to history (prioritize Gist, fallback to local)"""
history = self.load_history()
today = get_beijing_time().strftime("%Y-%m-%d")
- # 构造详细快照 (包含每个插件的下载量)
+ # Build detailed snapshot (including each plugin's download count)
snapshot = {
"date": today,
"total_posts": stats["total_posts"],
@@ -187,7 +203,7 @@ class OpenWebUIStats:
"posts": {p["slug"]: p["downloads"] for p in stats.get("posts", [])},
}
- # 更新或追加数据点
+ # Update or append data point
updated = False
for i, item in enumerate(history):
if item.get("date") == today:
@@ -197,10 +213,10 @@ class OpenWebUIStats:
if not updated:
history.append(snapshot)
- # 限制长度 (90天)
+ # Limit length (90 days)
history = history[-90:]
- # 尝试保存到 Gist
+ # Try saving to Gist
if self.gist_token and self.gist_id:
try:
url = f"https://api.github.com/gists/{self.gist_id}"
@@ -214,19 +230,19 @@ class OpenWebUIStats:
}
resp = requests.patch(url, headers=headers, json=payload)
if resp.status_code == 200:
- print(f"✅ 历史记录已同步至 Gist ({self.gist_id})")
- # 如果同步成功,不再保存到本地,减少 commit 压力
+ print(f"✅ History synced to Gist ({self.gist_id})")
+ # If sync succeeds, do not save to local to reduce commit pressure
return
except Exception as e:
- print(f"⚠️ 同步至 Gist 失败: {e}")
+ print(f"⚠️ Failed to sync to Gist: {e}")
- # 降级:保存到本地
+ # Fallback: save to local
with open(self.history_file, "w", encoding="utf-8") as f:
json.dump(history, f, ensure_ascii=False, indent=2)
- print(f"✅ 历史记录已更新至本地 ({today})")
+ print(f"✅ History updated to local ({today})")
def get_stat_delta(self, stats: dict) -> dict:
- """计算相对于上次记录的增长 (24h)"""
+ """Calculate growth relative to last recorded snapshot (24h delta)"""
history = self.load_history()
if not history:
return {}
@@ -234,7 +250,7 @@ class OpenWebUIStats:
today = get_beijing_time().strftime("%Y-%m-%d")
prev = None
- # 查找非今天的最后一笔数据作为基准
+ # Find last data point from a different day as baseline
for item in reversed(history):
if item.get("date") != today:
prev = item
@@ -262,14 +278,14 @@ class OpenWebUIStats:
}
def _resolve_post_type(self, post: dict) -> str:
- """解析帖子类别"""
+ """Resolve the post category type"""
top_type = post.get("type")
function_data = post.get("data", {}) or {}
function_obj = function_data.get("function", {}) or {}
meta = function_obj.get("meta", {}) or {}
manifest = meta.get("manifest", {}) or {}
- # 类别识别优先级:
+ # Category identification priority:
if top_type == "review":
return "review"
@@ -283,7 +299,9 @@ class OpenWebUIStats:
elif not meta and not function_obj:
post_type = "post"
- # 统一和启发式识别逻辑
+ post_type = self._normalize_post_type(post_type)
+
+ # Unified and heuristic identification logic
if post_type == "unknown" and function_obj:
post_type = "action"
@@ -298,17 +316,17 @@ class OpenWebUIStats:
post_type = "filter"
elif "pipe" in all_metadata:
post_type = "pipe"
- elif "toolkit" in all_metadata:
- post_type = "toolkit"
+ elif "tool" in all_metadata:
+ post_type = "tool"
- return post_type
+ return self._normalize_post_type(post_type)
def rebuild_history_from_git(self) -> list:
- """从 Git 历史提交中重建统计数据"""
+ """Rebuild statistics from Git commit history"""
history = []
try:
- # 从 docs/community-stats.json 的 Git 历史重建 (该文件历史最丰富)
- # 格式: hash date
+ # Rebuild from Git history of docs/community-stats.json (has richest history)
+ # Format: hash date
target = "docs/community-stats.json"
cmd = [
"git",
@@ -324,8 +342,8 @@ class OpenWebUIStats:
seen_dates = set()
- # 从旧到新处理(git log 默认是从新到旧,所以我们要反转或者用 reverse)
- # 其实顺序无所谓,只要最后 sort 一下就行
+ # Process from oldest to newest (git log defaults to newest first, so reverse)
+ # The order doesn't really matter as long as we sort at the end
for line in reversed(commits): # Process from oldest to newest
parts = line.split()
if len(parts) < 2:
@@ -338,7 +356,7 @@ class OpenWebUIStats:
continue
seen_dates.add(commit_date)
- # 读取该 commit 时的文件内容
+ # Read file content at this commit
# Note: The file name in git show needs to be relative to the repo root
show_cmd = ["git", "show", f"{commit_hash}:{target}"]
show_res = subprocess.run(
@@ -405,13 +423,16 @@ class OpenWebUIStats:
return []
def _parse_user_id_from_token(self, token: str) -> str:
- """从 JWT Token 中解析用户 ID"""
+ """Parse user ID from JWT Token"""
import base64
+ if not token or token.startswith("sk-"):
+ return ""
+
try:
- # JWT 格式: header.payload.signature
+ # JWT format: header.payload.signature
payload = token.split(".")[1]
- # 添加 padding
+ # Add padding
padding = 4 - len(payload) % 4
if padding != 4:
payload += "=" * padding
@@ -419,16 +440,36 @@ class OpenWebUIStats:
data = json.loads(decoded)
return data.get("id", "")
except Exception as e:
- print(f"⚠️ 无法从 Token 解析用户 ID: {e}")
+ print(f"⚠️ Failed to parse user ID from token: {e}")
return ""
+ def resolve_user_id(self) -> str:
+ """Auto-resolve current user ID via community API (for sk- type API keys)"""
+ if self.user_id:
+ return self.user_id
+
+ try:
+ resp = self.session.get(f"{self.BASE_URL}/auths/", timeout=20)
+ if resp.status_code == 200:
+ data = resp.json() if resp.text else {}
+ resolved = str(data.get("id", "")).strip()
+ if resolved:
+ self.user_id = resolved
+ return resolved
+ else:
+ print(f"⚠️ Failed to auto-resolve user ID: HTTP {resp.status_code}")
+ except Exception as e:
+ print(f"⚠️ Exception while auto-resolving user ID: {e}")
+
+ return ""
+
def generate_mermaid_chart(self, stats: dict = None, lang: str = "zh") -> str:
- """生成支持 Kroki 服务端渲染的动态 Mermaid 图表链接 (零 Commit)"""
+ """Generate dynamic Mermaid chart links with Kroki server-side rendering (zero commit)"""
history = self.load_history()
if not history:
return ""
- # 多语言标签
+ # Multi-language labels
labels = {
"zh": {
"trend_title": "增长与趋势 (Last 14 Days)",
@@ -495,14 +536,14 @@ class OpenWebUIStats:
def get_user_posts(self, sort: str = "new", page: int = 1) -> list:
"""
- 获取用户发布的帖子列表
+ Fetch list of posts published by the user
Args:
- sort: 排序方式 (new/top/hot)
- page: 页码
+ sort: Sort order (new/top/hot)
+ page: Page number
Returns:
- 帖子列表
+ List of posts
"""
url = f"{self.BASE_URL}/posts/users/{self.user_id}"
params = {"sort": sort, "page": page}
@@ -512,7 +553,7 @@ class OpenWebUIStats:
return response.json()
def get_all_posts(self, sort: str = "new") -> list:
- """获取所有帖子(自动分页)"""
+ """Fetch all posts (automatic pagination)"""
all_posts = []
page = 1
@@ -526,7 +567,7 @@ class OpenWebUIStats:
return all_posts
def generate_stats(self, posts: list) -> dict:
- """生成统计数据"""
+ """Generate statistics"""
stats = {
"total_posts": len(posts),
"total_downloads": 0,
@@ -537,10 +578,10 @@ class OpenWebUIStats:
"total_comments": 0,
"by_type": {},
"posts": [],
- "user": {}, # 用户信息
+ "user": {}, # User info
}
- # 从第一个帖子中提取用户信息
+ # Extract user info from first post
if posts and "user" in posts[0]:
user = posts[0]["user"]
stats["user"] = {
@@ -564,7 +605,7 @@ class OpenWebUIStats:
meta = function_obj.get("meta", {}) or {}
manifest = meta.get("manifest", {}) or {}
- # 累计统计
+ # Accumulate statistics
post_downloads = post.get("downloads", 0)
post_views = post.get("views", 0)
@@ -574,7 +615,7 @@ class OpenWebUIStats:
stats["total_saves"] += post.get("saveCount", 0)
stats["total_comments"] += post.get("commentCount", 0)
- # 关键:总浏览量不包括不可以下载的类型 (如 post, review)
+ # Key: total views do not include non-downloadable types (e.g., post, review)
if post_type in self.DOWNLOADABLE_TYPES or post_downloads > 0:
stats["total_views"] += post_views
@@ -582,7 +623,7 @@ class OpenWebUIStats:
stats["by_type"][post_type] = 0
stats["by_type"][post_type] += 1
- # 单个帖子信息
+ # Individual post information
created_at = datetime.fromtimestamp(post.get("createdAt", 0))
updated_at = datetime.fromtimestamp(post.get("updatedAt", 0))
@@ -605,43 +646,45 @@ class OpenWebUIStats:
}
)
- # 按下载量排序
+ # Sort by download count
stats["posts"].sort(key=lambda x: x["downloads"], reverse=True)
return stats
def print_stats(self, stats: dict):
- """打印统计报告到终端"""
+ """Print statistics report to terminal"""
print("\n" + "=" * 60)
- print("📊 OpenWebUI 社区统计报告")
+ print("📊 OpenWebUI Community Statistics Report")
print("=" * 60)
- print(f"📅 生成时间 (北京): {get_beijing_time().strftime('%Y-%m-%d %H:%M')}")
+ print(
+ f"📅 Generated (Beijing time): {get_beijing_time().strftime('%Y-%m-%d %H:%M')}"
+ )
print()
- # 总览
- print("📈 总览")
+ # Overview
+ print("📈 Overview")
print("-" * 40)
- print(f" 📝 发布数量: {stats['total_posts']}")
- print(f" ⬇️ 总下载量: {stats['total_downloads']}")
- print(f" 👁️ 总浏览量: {stats['total_views']}")
- print(f" 👍 总点赞数: {stats['total_upvotes']}")
- print(f" 💾 总收藏数: {stats['total_saves']}")
- print(f" 💬 总评论数: {stats['total_comments']}")
+ print(f" 📝 Posts: {stats['total_posts']}")
+ print(f" ⬇️ Total Downloads: {stats['total_downloads']}")
+ print(f" 👁️ Total Views: {stats['total_views']}")
+ print(f" 👍 Total Upvotes: {stats['total_upvotes']}")
+ print(f" 💾 Total Saves: {stats['total_saves']}")
+ print(f" 💬 Total Comments: {stats['total_comments']}")
print()
- # 按类型分类
- print("📂 按类型分类")
+ # By type
+ print("📂 By Type")
print("-" * 40)
for post_type, count in stats["by_type"].items():
print(f" • {post_type}: {count}")
print()
- # 详细列表
- print("📋 发布列表 (按下载量排序)")
+ # Detailed list
+ print("📋 Posts List (sorted by downloads)")
print("-" * 60)
- # 表头
- print(f"{'排名':<4} {'标题':<30} {'下载':<8} {'浏览':<8} {'点赞':<6}")
+ # Header
+ print(f"{'Rank':<4} {'Title':<30} {'Downloads':<8} {'Views':<8} {'Upvotes':<6}")
print("-" * 60)
for i, post in enumerate(stats["posts"], 1):
@@ -655,23 +698,23 @@ class OpenWebUIStats:
print("=" * 60)
def _safe_key(self, key: str) -> str:
- """生成安全的文件名 Key (MD5 hash) 以避免中文字符问题"""
+ """Generate safe filename key (MD5 hash) to avoid Chinese character issues"""
import hashlib
return hashlib.md5(key.encode("utf-8")).hexdigest()
def generate_markdown(self, stats: dict, lang: str = "zh") -> str:
"""
- 生成 Markdown 格式报告 (全动态徽章与 Kroki 图表)
+ Generate Markdown format report (fully dynamic badges and Kroki charts)
Args:
- stats: 统计数据
- lang: 语言 ("zh" 中文, "en" 英文)
+ stats: Statistics data
+ lang: Language ("zh" Chinese, "en" English)
"""
- # 获取增量数据
+ # Get delta data
delta = self.get_stat_delta(stats)
- # 中英文文本
+ # Bilingual text
texts = {
"zh": {
"title": "# 📊 OpenWebUI 社区统计报告",
@@ -761,6 +804,7 @@ class OpenWebUIStats:
"filter": "brightgreen",
"action": "orange",
"pipe": "blueviolet",
+ "tool": "teal",
"pipeline": "purple",
"review": "yellow",
"prompt": "lightgrey",
@@ -815,30 +859,30 @@ class OpenWebUIStats:
return "\n".join(md)
def save_json(self, stats: dict, filepath: str):
- """保存 JSON 格式数据"""
+ """Save data in JSON format"""
with open(filepath, "w", encoding="utf-8") as f:
json.dump(stats, f, ensure_ascii=False, indent=2)
- print(f"✅ JSON 数据已保存到: {filepath}")
+ print(f"✅ JSON data saved to: {filepath}")
def generate_shields_endpoints(self, stats: dict, output_dir: str = "docs/badges"):
"""
- 生成 Shields.io endpoint JSON 文件
+ Generate Shields.io endpoint JSON files
Args:
- stats: 统计数据
- output_dir: 输出目录
+ stats: Statistics data
+ output_dir: Output directory
"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
def format_number(n: int) -> str:
- """格式化数字为易读格式"""
+ """Format number to readable format"""
if n >= 1000000:
return f"{n/1000000:.1f}M"
elif n >= 1000:
return f"{n/1000:.1f}k"
return str(n)
- # 各种徽章数据
+ # Badge data
badges = {
"downloads": {
"schemaVersion": 1,
@@ -884,18 +928,18 @@ class OpenWebUIStats:
# 构造并上传 Shields.io 徽章数据
self.upload_gist_badges(stats)
except Exception as e:
- print(f"⚠️ 徽章生成失败: {e}")
+ print(f"⚠️ Badge generation failed: {e}")
print(f"✅ Shields.io endpoints saved to: {output_dir}/")
def upload_gist_badges(self, stats: dict):
- """生成并上传 Gist 徽章数据 (用于 Shields.io Endpoint)"""
+ """Generate and upload Gist badge data (for Shields.io Endpoint)"""
if not (self.gist_token and self.gist_id):
return
delta = self.get_stat_delta(stats)
- # 定义徽章配置 {key: (label, value, color)}
+ # Define badge config {key: (label, value, color)}
badges_config = {
"downloads": ("Downloads", stats["total_downloads"], "brightgreen"),
"views": ("Views", stats["total_views"], "blue"),
@@ -923,7 +967,7 @@ class OpenWebUIStats:
for key, (label, val, color) in badges_config.items():
diff = delta.get(key, 0)
if isinstance(diff, dict):
- diff = 0 # 避免 'posts' key 导致的 dict vs int 比较错误
+ diff = 0 # Avoid dict vs int comparison error with 'posts' key
message = f"{val}"
if diff > 0:
@@ -931,7 +975,7 @@ class OpenWebUIStats:
elif diff < 0:
message += f" ({diff})"
- # 构造 Shields.io endpoint JSON
+ # Build Shields.io endpoint JSON
# 参考: https://shields.io/badges/endpoint-badge
badge_data = {
"schemaVersion": 1,
@@ -945,13 +989,13 @@ class OpenWebUIStats:
"content": json.dumps(badge_data, ensure_ascii=False)
}
- # 生成 Top 6 插件徽章 (基于槽位 p1, p2...)
+ # Generate top 6 plugins badges (based on slots p1, p2...)
post_deltas = delta.get("posts", {})
for i, post in enumerate(stats.get("posts", [])[:6]):
idx = i + 1
diff = post_deltas.get(post["slug"], 0)
- # 下载量徽章
+ # Downloads badge
dl_msg = f"{post['downloads']}"
if diff > 0:
dl_msg += f" (+{diff}🚀)"
@@ -1103,6 +1147,8 @@ class OpenWebUIStats:
def _fmt_delta(k: str) -> str:
val = delta.get(k, 0)
+ if isinstance(val, dict):
+ return ""
if val > 0:
return f"
(+{val}🚀)"
return ""
@@ -1462,86 +1508,93 @@ class OpenWebUIStats:
def main():
- """主函数"""
- # 获取配置
+ """CLI entry point."""
+ # Load runtime config
api_key = os.getenv("OPENWEBUI_API_KEY")
user_id = os.getenv("OPENWEBUI_USER_ID")
if not api_key:
- print("❌ 错误: 未设置 OPENWEBUI_API_KEY 环境变量")
- print("请设置环境变量:")
+ print("❌ Error: OPENWEBUI_API_KEY is not set")
+ print("Please set environment variable:")
print(" export OPENWEBUI_API_KEY='your_api_key_here'")
return 1
if not user_id:
- print("❌ 错误: 未设置 OPENWEBUI_USER_ID 环境变量")
- print("请设置环境变量:")
- print(" export OPENWEBUI_USER_ID='your_user_id_here'")
- print("\n提示: 用户 ID 可以从之前的 curl 请求中获取")
- print(" 例如: b15d1348-4347-42b4-b815-e053342d6cb0")
- return 1
+ print("ℹ️ OPENWEBUI_USER_ID not set, attempting auto-resolve via API key...")
- # 获取 Gist 配置 (用于存储历史记录)
+ # Gist config (optional, for badges/history sync)
gist_token = os.getenv("GIST_TOKEN")
gist_id = os.getenv("GIST_ID")
- # 初始化
+ # Initialize client
stats_client = OpenWebUIStats(api_key, user_id, gist_token, gist_id)
- print(f"🔍 用户 ID: {stats_client.user_id}")
+
+ if not stats_client.user_id:
+ stats_client.resolve_user_id()
+
+ if not stats_client.user_id:
+ print("❌ Error: failed to auto-resolve OPENWEBUI_USER_ID")
+ print("Please set environment variable:")
+ print(" export OPENWEBUI_USER_ID='your_user_id_here'")
+ print("\nTip: user id is the 'id' field returned by /api/v1/auths/")
+ print(" e.g. b15d1348-4347-42b4-b815-e053342d6cb0")
+ return 1
+
+ print(f"🔍 User ID: {stats_client.user_id}")
if gist_id:
- print(f"📦 Gist 存储已启用: {gist_id}")
+ print(f"📦 Gist storage enabled: {gist_id}")
- # 获取所有帖子
- print("📥 正在获取帖子数据...")
+ # Fetch posts
+ print("📥 Fetching posts...")
posts = stats_client.get_all_posts()
- print(f"✅ 获取到 {len(posts)} 个帖子")
+ print(f"✅ Retrieved {len(posts)} posts")
- # 生成统计
+ # Build stats
stats = stats_client.generate_stats(posts)
- # 保存历史快照
+ # Save history snapshot
stats_client.save_history(stats)
- # 打印到终端
+ # Print terminal report
stats_client.print_stats(stats)
- # 保存 Markdown 报告 (中英文双版本)
+ # Save markdown reports (zh/en)
script_dir = Path(__file__).parent.parent
- # 中文报告
+ # Chinese report
md_zh_path = script_dir / "docs" / "community-stats.zh.md"
md_zh_content = stats_client.generate_markdown(stats, lang="zh")
with open(md_zh_path, "w", encoding="utf-8") as f:
f.write(md_zh_content)
- print(f"\n✅ 中文报告已保存到: {md_zh_path}")
+ print(f"\n✅ Chinese report saved to: {md_zh_path}")
- # 英文报告
+ # English report
md_en_path = script_dir / "docs" / "community-stats.md"
md_en_content = stats_client.generate_markdown(stats, lang="en")
with open(md_en_path, "w", encoding="utf-8") as f:
f.write(md_en_content)
- print(f"✅ 英文报告已保存到: {md_en_path}")
+ print(f"✅ English report saved to: {md_en_path}")
- # 保存 JSON 数据
+ # Save JSON snapshot
json_path = script_dir / "docs" / "community-stats.json"
stats_client.save_json(stats, str(json_path))
- # 生成 Shields.io endpoint JSON (用于动态徽章)
+ # Generate Shields.io endpoint JSON (dynamic badges)
badges_dir = script_dir / "docs" / "badges"
- # 生成徽章
+ # Generate badges
stats_client.generate_shields_endpoints(stats, str(badges_dir))
- # 生成并上传 SVG 图表 (每日更新 Gist, README URL 保持不变)
+ # Generate and upload SVG chart (if Gist is configured)
stats_client.upload_chart_svg()
- # 更新 README 文件
+ # Update README files
readme_path = script_dir / "README.md"
readme_cn_path = script_dir / "README_CN.md"
stats_client.update_readme(stats, str(readme_path), lang="en")
stats_client.update_readme(stats, str(readme_cn_path), lang="zh")
- # 更新 docs 中的图表
+ # Update charts in docs pages
stats_client.update_docs_chart(str(md_en_path), lang="en")
stats_client.update_docs_chart(str(md_zh_path), lang="zh")