feat(pipe): release v0.6.2 - full-lifecycle file agent support

This commit is contained in:
fujie
2026-02-10 14:55:16 +08:00
parent 3343e73848
commit a7b244602f
10 changed files with 743 additions and 113 deletions

View File

@@ -5,7 +5,7 @@ author_url: https://github.com/Fu-Jie/awesome-openwebui
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.1
version: 0.6.2
requirements: github-copilot-sdk==0.1.23
"""
@@ -44,6 +44,11 @@ from open_webui.config import (
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
# Open WebUI internal database (re-use shared connection)
try:
@@ -129,18 +134,22 @@ BASE_GUIDELINES = (
" - 2. **Render**: Immediately output the SAME code in a ` ```html ` block so the user can interact with it.\n"
" - **Result**: The user gets both a saved file AND a live app. Never force the user to choose one over the other.\n"
"4. **Images & Files**: ALWAYS embed generated images/files directly using `![caption](url)`. Never provide plain text links.\n"
"5. **TODO Visibility**: Every time you call the `update_todo` tool, you **MUST** immediately follow up with a beautifully formatted **Markdown summary** of the current TODO list. Use task checkboxes (`- [ ]`), progress indicators, and clear headings so the user can see the status directly in the chat.\n"
"6. **Python Execution Standard**: For ANY task requiring Python logic (not just data analysis), you **MUST NOT** embed multi-line code directly in a shell command (e.g., using `python -c` or `<< 'EOF'`).\n"
"5. **File Delivery & Publishing (CRITICAL)**:\n"
" - **Implicit Requests**: If the user says 'publish this', 'export your response', or 'give me a link to this content', you MUST: 1. Write the relevant content to a `.md` (or other appropriate) file in the current directory (`.`). 2. Call `publish_file_from_workspace(filename='name.md')` to get a link.\n"
" - **Manual Sequence**: 1. **Write Local**: Create the file in `.` (your only workspace). 2. **Publish**: Call `publish_file_from_workspace(filename='your_file.ext')`. **WARNING**: You MUST provide the filename argument; never call this tool with empty parentheses.\n"
" - *Rule*: Only files in the current directory (`.`) can be published. The tool bypasses RAG and handles S3/Local storage automatically.\n"
"6. **TODO Visibility**: Every time you call the `update_todo` tool, you **MUST** immediately follow up with a beautifully formatted **Markdown summary** of the current TODO list. Use task checkboxes (`- [ ]`), progress indicators, and clear headings so the user can see the status directly in the chat.\n"
"7. **Python Execution Standard**: For ANY task requiring Python logic (not just data analysis), you **MUST NOT** embed multi-line code directly in a shell command (e.g., using `python -c` or `<< 'EOF'`).\n"
' - **Exception**: Trivial one-liners (e.g., `python -c "print(1+1)"`) are permitted.\n'
" - **Protocol**: For everything else, you MUST:\n"
" 1. **Create** a `.py` file in the workspace (e.g., `script.py`).\n"
" 2. **Run** it using `python3 script.py`.\n"
" - **Reason**: This ensures code is debuggable, readable, and persistent.\n"
"7. **Active & Autonomous**: You are an expert engineer. **DO NOT** ask for permission to proceed with obvious steps. **DO NOT** stop to ask 'Shall I continue?'.\n"
"8. **Active & Autonomous**: You are an expert engineer. **DO NOT** ask for permission to proceed with obvious steps. **DO NOT** stop to ask 'Shall I continue?'.\n"
" - **Behavior**: Analyze the user's request -> Formulate a plan -> **EXECUTE** the plan immediately.\n"
" - **Clarification**: Only ask questions if the request is ambiguous or carries high risk (e.g., destructive actions).\n"
" - **Goal**: Minimize user friction. Deliver results, not questions.\n"
"8. **Large Output Management**: If a tool execution output is truncated or saved to a temporary file (e.g., `/tmp/...`), DO NOT worry. The system will automatically move it to your workspace and notify you of the new filename. You can then read it directly.\n"
"9. **Large Output Management**: If a tool execution output is truncated or saved to a temporary file (e.g., `/tmp/...`), DO NOT worry. The system will automatically move it to your workspace and notify you of the new filename. You can then read it directly.\n"
)
# Sensitive extensions only for Administrators
@@ -343,6 +352,7 @@ class Pipe:
# =============================================================
_model_cache: List[dict] = [] # Model list cache
_standard_model_ids: set = set() # Track standard model IDs
_last_byok_config_hash: str = "" # Track BYOK config for cache invalidation
_tool_cache = None # Cache for converted OpenWebUI tools
_mcp_server_cache = None # Cache for MCP server config
_env_setup_done = False # Track if env setup has been completed
@@ -488,6 +498,7 @@ class Pipe:
__user__: Optional[dict] = None,
__event_emitter__=None,
__event_call__=None,
__request__=None,
) -> Union[str, AsyncGenerator]:
return await self._pipe_impl(
body,
@@ -495,6 +506,7 @@ class Pipe:
__user__=__user__,
__event_emitter__=__event_emitter__,
__event_call__=__event_call__,
__request__=__request__,
)
# ==================== Functional Areas ====================
@@ -507,7 +519,12 @@ class Pipe:
# Tool registration: Add @define_tool decorated functions at module level,
# then register them in _initialize_custom_tools() -> all_tools dict.
async def _initialize_custom_tools(
self, body: dict = None, __user__=None, __event_call__=None
self,
body: dict = None,
__user__=None,
__event_call__=None,
__request__=None,
__metadata__=None,
):
"""Initialize custom tools based on configuration"""
# 1. Determine effective settings (User override > Global)
@@ -525,7 +542,17 @@ class Pipe:
await self._emit_debug_log(
" Using cached OpenWebUI tools.", __event_call__
)
return self._tool_cache
# Create a shallow copy to append user-specific tools without polluting cache
tools = list(self._tool_cache)
# Inject File Publish Tool
chat_ctx = self._get_chat_context(body, __metadata__)
chat_id = chat_ctx.get("chat_id")
file_tool = self._get_publish_file_tool(__user__, chat_id, __request__)
if file_tool:
tools.append(file_tool)
return tools
# Load OpenWebUI tools dynamically
openwebui_tools = await self._load_openwebui_tools(
@@ -557,7 +584,204 @@ class Pipe:
__event_call__,
)
return openwebui_tools
# Create a shallow copy to append user-specific tools without polluting cache
final_tools = list(openwebui_tools)
# Inject File Publish Tool
chat_ctx = self._get_chat_context(body, __metadata__)
chat_id = chat_ctx.get("chat_id")
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):
"""
Create a tool to publish files from the workspace to a downloadable URL.
"""
# Resolve user_id
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
# Resolve workspace directory
workspace_dir = Path(self._get_workspace_dir(user_id=user_id, chat_id=chat_id))
# Define parameter schema explicitly for the SDK
class PublishFileParams(BaseModel):
filename: str = Field(
...,
description="The EXACT name of the file you just created in the current directory (e.g., 'report.csv'). REQUIRED.",
)
async def publish_file_from_workspace(filename: Any) -> dict:
"""
Publishes a file from the local chat workspace to a downloadable URL.
"""
try:
# 1. Robust Parameter Extraction
# Case A: filename is a Pydantic model (common when using params_type)
if hasattr(filename, "model_dump"): # Pydantic v2
filename = filename.model_dump().get("filename")
elif hasattr(filename, "dict"): # Pydantic v1
filename = filename.dict().get("filename")
# Case B: filename is a dict
if isinstance(filename, dict):
filename = (
filename.get("filename")
or filename.get("file")
or filename.get("file_path")
)
# Case C: filename is a JSON string or wrapped string
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
# 2. Final String Validation
if (
not filename
or not isinstance(filename, str)
or filename.strip() in ("", "{}", "None", "null")
):
return {
"error": "Missing or invalid required argument: 'filename'.",
"hint": f"Received value: {type(filename).__name__}. Please provide the filename as a simple string like 'report.md'.",
}
filename = filename.strip()
# 2. Path Resolution (Lock to current chat workspace)
target_path = workspace_dir / filename
try:
target_path = target_path.resolve()
if not str(target_path).startswith(str(workspace_dir.resolve())):
return {
"error": f"Access denied: File must be within the current chat workspace."
}
except Exception as e:
return {"error": f"Path validation failed: {e}"}
if not target_path.exists() or not target_path.is_file():
return {
"error": f"File '{filename}' not found in chat workspace. Ensure you saved it to the CURRENT DIRECTORY (.)."
}
# 3. Upload via API (S3 Compatible)
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_result = await resp.json()
file_id = api_result.get("id")
safe_filename = api_result.get(
"filename", target_path.name
)
api_success = True
except Exception as e:
logger.error(f"API upload failed: {e}")
# 4. Fallback: Manual DB Insert (Local only)
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. Result
download_url = f"/api/v1/files/{file_id}/content"
return {
"file_id": file_id,
"filename": safe_filename,
"download_url": download_url,
"message": "File published successfully.",
"hint": f"Link: [Download {safe_filename}]({download_url})",
}
except Exception as e:
return {"error": str(e)}
return define_tool(
name="publish_file_from_workspace",
description="Converts a file created in your local workspace into a downloadable URL. Use this tool AFTER writing a file to the current directory.",
params_type=PublishFileParams,
)(publish_file_from_workspace)
def _json_schema_to_python_type(self, schema: dict) -> Any:
"""Convert JSON Schema type to Python type for Pydantic models."""
@@ -1756,13 +1980,17 @@ class Pipe:
async def _fetch_byok_models(self, uv: "Pipe.UserValves" = None) -> List[dict]:
"""Fetch BYOK models from configured provider."""
model_list = []
# Resolve effective settings (User > Global)
# Note: We handle the case where uv might be None
effective_base_url = (uv.BYOK_BASE_URL if uv else "") or self.valves.BYOK_BASE_URL
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_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:
@@ -1778,9 +2006,7 @@ class Pipe:
headers["anthropic-version"] = "2023-06-01"
else:
if effective_bearer_token:
headers["Authorization"] = (
f"Bearer {effective_bearer_token}"
)
headers["Authorization"] = f"Bearer {effective_bearer_token}"
elif effective_api_key:
headers["Authorization"] = f"Bearer {effective_api_key}"
@@ -1813,7 +2039,9 @@ class Pipe:
f"BYOK: Failed to fetch models from {url} (Attempt {attempt+1}/3). Status: {resp.status}"
)
except Exception as e:
await self._emit_debug_log(f"BYOK: Model fetch error (Attempt {attempt+1}/3): {e}")
await self._emit_debug_log(
f"BYOK: Model fetch error (Attempt {attempt+1}/3): {e}"
)
if attempt < 2:
await asyncio.sleep(1)
@@ -1941,7 +2169,25 @@ class Pipe:
if k.strip()
]
# --- NEW: CONFIG-AWARE CACHE INVALIDATION ---
# Calculate current config fingerprint to detect changes
current_config_str = f"{token}|{uv.BYOK_BASE_URL or self.valves.BYOK_BASE_URL}|{uv.BYOK_API_KEY or self.valves.BYOK_API_KEY}|{self.valves.BYOK_BEARER_TOKEN}"
current_config_hash = hashlib.md5(current_config_str.encode()).hexdigest()
if (
self._model_cache
and self.__class__._last_byok_config_hash != current_config_hash
):
if self.valves.DEBUG:
logger.info(
f"[Pipes] Configuration change detected. Invalidating model cache."
)
self.__class__._model_cache = []
self.__class__._last_byok_config_hash = current_config_hash
if not self._model_cache:
# Update the hash when we refresh the cache
self.__class__._last_byok_config_hash = current_config_hash
if self.valves.DEBUG:
logger.info("[Pipes] Refreshing model cache...")
try:
@@ -2437,6 +2683,7 @@ class Pipe:
__user__: Optional[dict] = None,
__event_emitter__=None,
__event_call__=None,
__request__=None,
) -> Union[str, AsyncGenerator]:
# --- PROBE LOG ---
if __event_call__:
@@ -2716,7 +2963,11 @@ class Pipe:
# Initialize custom tools (Handles caching internally)
custom_tools = await self._initialize_custom_tools(
body=body, __user__=__user__, __event_call__=__event_call__
body=body,
__user__=__user__,
__event_call__=__event_call__,
__request__=__request__,
__metadata__=__metadata__,
)
if custom_tools:
tool_names = [t.name for t in custom_tools]
@@ -3468,4 +3719,6 @@ class Pipe:
await client.stop()
except Exception as e:
pass
# Triggering release after CI fix