diff --git a/CHANGELOG.md b/CHANGELOG.md index 71041af..36a914b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.5] - 2026-06-13 + +### Added + +- 🌳 **Chat branching from Open WebUI.** Regenerating or editing a message in Open WebUI now creates proper branches in the cptr sidebar, just like it does in OWUI. Requires custom headers on the OWUI connection (copy from Gateway settings). +- 🚦 **Background task filtering.** Open WebUI's automatic title generation, follow-up suggestions, and tag extraction no longer create ghost chats or trigger the agent loop. +- 📡 **More header templates.** Gateway settings now shows the full recommended header config for Open WebUI connections, including message and task type forwarding. One-click copy. Requires Open WebUI ≥ 0.9.7. + +### Fixed + +- 🐛 **Chat metadata could be empty on second turn.** Fixed an operator precedence issue in message resolution. + ## [0.3.4] - 2026-06-12 ### Changed diff --git a/cptr/frontend/package.json b/cptr/frontend/package.json index 0260b9a..d56009c 100644 --- a/cptr/frontend/package.json +++ b/cptr/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.3.4", + "version": "0.3.5", "type": "module", "scripts": { "dev": "vite dev", @@ -93,4 +93,4 @@ "tippy.js": "^6.3.7", "xlsx": "^0.18.5" } -} +} \ No newline at end of file diff --git a/cptr/frontend/src/lib/components/Admin/Gateway.svelte b/cptr/frontend/src/lib/components/Admin/Gateway.svelte index 599c718..868127c 100644 --- a/cptr/frontend/src/lib/components/Admin/Gateway.svelte +++ b/cptr/frontend/src/lib/components/Admin/Gateway.svelte @@ -30,7 +30,11 @@ /** Newly created key, shown once, then hidden */ let revealedKey = $state(''); const openWebUIHeaders = `{ - "X-OpenWebUI-Chat-Id": "{{CHAT_ID}}" + "X-OpenWebUI-Chat-Id": "{{CHAT_ID}}", + "X-OpenWebUI-Message-Id": "{{MESSAGE_ID}}", + "X-OpenWebUI-User-Message-Id": "{{USER_MESSAGE_ID}}", + "X-OpenWebUI-User-Message-Parent-Id": "{{USER_MESSAGE_PARENT_ID}}", + "X-OpenWebUI-Task": "{{TASK}}" }`; async function loadSettings() { diff --git a/cptr/routers/gateway.py b/cptr/routers/gateway.py index 0bb0fca..efbf759 100644 --- a/cptr/routers/gateway.py +++ b/cptr/routers/gateway.py @@ -40,6 +40,8 @@ # Headers that clients can send with the chat ID. CHAT_ID_HEADER = "X-Chat-Id" OWUI_CHAT_ID_HEADER = "X-OpenWebUI-Chat-Id" +OWUI_MESSAGE_ID_HEADER = "X-OpenWebUI-Message-Id" +OWUI_TASK_HEADER = "X-OpenWebUI-Task" # ── API key management ─────────────────────────────────────── @@ -67,7 +69,7 @@ def _format_tool_call(item: dict) -> str | None: return f"\n\n`{item.get('name', 'tool')}`\n\n" -async def _validate_bearer(request: Request) -> str: +async def _authenticate(request: Request) -> str: """Validate Bearer token from Authorization header. Returns user_id.""" auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): @@ -145,67 +147,58 @@ class ChatCompletionRequest(BaseModel): temperature: float | None = None max_tokens: int | None = None top_p: float | None = None + # OWUI metadata for message tree mapping + id: str | None = None # OWUI assistant message ID + parent_id: str | None = None # OWUI user message's parentId + user_message: dict | None = None # OWUI user message object + + model_config = {"extra": "allow"} # Accept any additional fields @router.post("/chat/completions") async def create_chat_completion(request: Request, body: ChatCompletionRequest): """Run the cptr agentic loop and stream results as OpenAI SSE.""" - user_id = await _validate_bearer(request) + user_id = await _authenticate(request) # 1. Resolve model → workspace path workspace = await _resolve_workspace(user_id, body.model) # 2. Resolve the underlying LLM connection for this workspace - connection, bare_model, model_id = await _resolve_model_connection(workspace) + connection, bare_model, model_id = await _resolve_model(workspace) + + # Intercept OWUI utility tasks (follow-ups, title gen, tags gen). + # These should go directly to the LLM, not through the agentic loop. + utility_result = await _intercept_task( + request, body, connection, bare_model, model_id + ) + if utility_result is not None: + return utility_result # 3. Session mapping: find or create a cptr chat client_chat_id = request.headers.get(CHAT_ID_HEADER) or request.headers.get(OWUI_CHAT_ID_HEADER) - chat_id = await _find_or_create_chat( + chat_id = await _ensure_chat( user_id, workspace, client_chat_id, body.messages, model_id ) - # 4. Create user + assistant messages - user_content = "" - if body.messages: - last_user = next( - (m for m in reversed(body.messages) if m.get("role") == "user"), - None, - ) - user_content = (last_user.get("content", "") if last_user else "") or "" - - # Get parent (latest message in the chat) - chat = await Chat.get_by_id(chat_id) - parent_id = chat.current_message_id if chat else None - - user_msg = await ChatMessage.create( + # 4. Resolve message tree — map OWUI message IDs to cptr message IDs + # Supports: normal messages, regeneration (sibling), edit (fork) + user_msg, assistant_msg = await _resolve_messages( + request=request, chat_id=chat_id, - role="user", - content=user_content, - parent_id=parent_id, - created_at=now_ms(), + model_id=model_id, + messages=body.messages, ) - assistant_msg = await ChatMessage.create( - chat_id=chat_id, - role="assistant", - content="", - parent_id=user_msg.id, - model=model_id, - done=False, - created_at=now_ms(), - ) await Chat.update_current_message(chat_id, assistant_msg.id, now_ms()) # Export JSON so cptr sidebar sees it immediately from cptr.utils.chat_export import export_chat_to_file - await export_chat_to_file(chat_id) # 5. Create output queue and start the agentic loop output_queue: asyncio.Queue = asyncio.Queue() from cptr.utils.chat_task import start_task - start_task( message_id=assistant_msg.id, chat_id=chat_id, @@ -222,7 +215,7 @@ async def create_chat_completion(request: Request, body: ChatCompletionRequest): if body.stream: return StreamingResponse( - _sse_generator(output_queue, completion_id, created, body.model), + _stream(output_queue, completion_id, created, body.model), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", @@ -232,19 +225,17 @@ async def create_chat_completion(request: Request, body: ChatCompletionRequest): ) else: # Non-streaming: collect all text, return as single response - full_text = await _collect_response(output_queue) + full_text = await _collect(output_queue) return { "id": completion_id, "object": "chat.completion", "created": created, "model": body.model, - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": full_text}, - "finish_reason": "stop", - } - ], + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": full_text}, + "finish_reason": "stop", + }], "usage": { "prompt_tokens": 0, "completion_tokens": 0, @@ -253,10 +244,119 @@ async def create_chat_completion(request: Request, body: ChatCompletionRequest): } +# ── Message tree resolution ────────────────────────────────── + + +async def _resolve_messages( + request: Request, + chat_id: str, + model_id: str, + messages: list[dict], +) -> tuple: + """Create user + assistant messages with proper tree structure. + + Uses OWUI headers (set via custom header templates) to map message IDs + and create proper branches for regeneration and edits. + + Headers used: + X-OpenWebUI-Message-Id → OWUI assistant message ID + X-OpenWebUI-User-Message-Id → OWUI user message ID + X-OpenWebUI-User-Message-Parent-Id → OWUI user message's parentId + + Returns (user_msg, assistant_msg). + """ + chat = await Chat.get_by_id(chat_id) + meta = (chat.meta or {}) if chat else {} + msg_map: dict = dict(meta.get("owui_msg_map", {})) + + # Extract OWUI message IDs from headers + # Distinguish "header not set" (no OWUI config) from "header set to empty" (root-level msg) + owui_asst_id = (request.headers.get("X-OpenWebUI-Message-Id") or "").strip() or None + owui_user_id = (request.headers.get("X-OpenWebUI-User-Message-Id") or "").strip() or None + _raw_parent = request.headers.get("X-OpenWebUI-User-Message-Parent-Id") + has_parent_header = _raw_parent is not None + owui_user_parent_id = (_raw_parent or "").strip() or None + + # Extract user content + user_content = "" + if messages: + last_user = next( + (m for m in reversed(messages) if m.get("role") == "user"), + None, + ) + user_content = (last_user.get("content", "") if last_user else "") or "" + + # Determine parent for the user message + cptr_parent_id = chat.current_message_id if chat else None + + if owui_user_id and owui_user_id in msg_map: + # User message already exists in cptr (regeneration case) + existing_user_msg = await ChatMessage.get_by_id(msg_map[owui_user_id]) + if existing_user_msg: + # Skip creating user msg — just create a sibling assistant + assistant_msg = await ChatMessage.create( + chat_id=chat_id, + role="assistant", + content="", + parent_id=existing_user_msg.id, + model=model_id, + done=False, + created_at=now_ms(), + ) + # Update map + if owui_asst_id: + msg_map[owui_asst_id] = assistant_msg.id + meta["owui_msg_map"] = msg_map + await Chat.update_meta(chat_id, meta, now_ms()) + + return existing_user_msg, assistant_msg + + # Resolve parent for edited/new user messages + if owui_user_parent_id and owui_user_parent_id in msg_map: + # Edit/fork case: parent is a known OWUI message → resolve to cptr ID + cptr_parent_id = msg_map[owui_user_parent_id] + elif has_parent_header and not owui_user_parent_id: + # Header present but empty → editing root-level message (first msg has no parent) + # Create as root sibling, not child of current_message_id + cptr_parent_id = None + + # Create user message + user_msg = await ChatMessage.create( + chat_id=chat_id, + role="user", + content=user_content, + parent_id=cptr_parent_id, + created_at=now_ms(), + ) + + # Create assistant message + assistant_msg = await ChatMessage.create( + chat_id=chat_id, + role="assistant", + content="", + parent_id=user_msg.id, + model=model_id, + done=False, + created_at=now_ms(), + ) + + # Update the message ID map + if owui_user_id: + msg_map[owui_user_id] = user_msg.id + if owui_asst_id: + msg_map[owui_asst_id] = assistant_msg.id + + if msg_map: + meta["owui_msg_map"] = msg_map + await Chat.update_meta(chat_id, meta, now_ms()) + + return user_msg, assistant_msg + + # ── SSE generator ──────────────────────────────────────────── -async def _sse_generator( +async def _stream( queue: asyncio.Queue, completion_id: str, created: int, @@ -327,7 +427,7 @@ def _chunk(delta: dict, finish_reason: str | None = None) -> str: # Other output types are persisted in cptr's DB and visible in its sidebar. -async def _collect_response(queue: asyncio.Queue) -> str: +async def _collect(queue: asyncio.Queue) -> str: """Collect all text from the queue for non-streaming mode.""" parts = [] while True: @@ -350,7 +450,103 @@ async def _collect_response(queue: asyncio.Queue) -> str: return "".join(parts) -# ── Workspace resolution ───────────────────────────────────── +# ── Utility task interception ──────────────────────────────── + +# Patterns that identify OWUI background task prompts. +# These should be proxied directly to the LLM, not through the agentic loop. +_UTILITY_PATTERNS = [ + "follow_ups", # follow-up generation + "follow-up questions", + "Generate a concise", # title generation + "generate a brief 2-3 word", + "Generate 1-3 broad tags", # tags generation + "tags_generation", +] + + +async def _intercept_task( + request: Request, + body: ChatCompletionRequest, + connection: dict, + bare_model: str, + model_id: str, +) -> dict | None: + """Detect OWUI utility tasks and proxy them directly to the LLM. + + Detection priority: + 1. X-OpenWebUI-Task header (set via custom header template {{TASK}}) + 2. Message content pattern matching (fallback) + + Returns a response dict if this is a utility task, or None if it should + go through the normal agentic loop. + """ + # 1. Header-based detection (reliable, requires OWUI config) + task_header = request.headers.get(OWUI_TASK_HEADER, "").strip() + if task_header: + logger.info("[gateway] Detected utility task via header: %s", task_header) + return await _proxy_to_llm(body, connection, bare_model) + + # 2. Message content pattern matching (fallback) + if not body.messages: + return None + + last_msg = body.messages[-1] + content = last_msg.get("content", "") if isinstance(last_msg, dict) else "" + if not isinstance(content, str): + return None + + is_utility = any(pattern in content for pattern in _UTILITY_PATTERNS) + if not is_utility: + return None + + logger.info("[gateway] Detected utility task via content matching") + return await _proxy_to_llm(body, connection, bare_model) + + +async def _proxy_to_llm( + body: ChatCompletionRequest, + connection: dict, + bare_model: str, +) -> dict: + """Proxy a utility task directly to the underlying LLM.""" + from cptr.utils.ai import chat_completion + from cptr.utils.config import _get_jwt_secret + from cptr.utils.crypto import decrypt_key + + provider = connection["provider"] + api_key = decrypt_key(connection.get("api_key", ""), _get_jwt_secret()) + base_url = connection.get("base_url") or None + + try: + result = await chat_completion( + provider=provider, + base_url=base_url, + api_key=api_key, + model=bare_model, + messages=body.messages, + max_tokens=200, + ) + except Exception as e: + logger.warning("[gateway] Utility task LLM call failed: %s", e) + result = "" + + completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}" + return { + "id": completion_id, + "object": "chat.completion", + "created": int(time.time()), + "model": body.model, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": result or ""}, + "finish_reason": "stop", + }], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + } async def _resolve_workspace(user_id: str, model_id: str) -> str: @@ -386,7 +582,7 @@ async def _resolve_workspace(user_id: str, model_id: str) -> str: # ── Model connection resolution ────────────────────────────── -async def _resolve_model_connection(workspace: str) -> tuple[dict, str, str]: +async def _resolve_model(workspace: str) -> tuple[dict, str, str]: """Find a model connection to use for the agentic loop. Priority: @@ -443,7 +639,7 @@ async def _resolve_model_connection(workspace: str) -> tuple[dict, str, str]: # ── Session mapping ────────────────────────────────────────── -async def _find_or_create_chat( +async def _ensure_chat( user_id: str, workspace: str, client_chat_id: str | None, diff --git a/pyproject.toml b/pyproject.toml index ce72a46..bda26ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.3.4" +version = "0.3.5" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md"