diff --git a/CHANGELOG.md b/CHANGELOG.md index d38676eef3..92a9dee71c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ 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.8.8] - 2026-03-02 + +### Added + +- ๐Ÿ“ **Open Terminal file moving.** Users can now move files and folders between directories in the Open Terminal file browser by dragging and dropping them. [Commit](https://github.com/open-webui/open-webui/commit/0c42cd2c012f9f49816adac897e2b46573b3cb6c), [Commit](https://github.com/open-webui/open-webui/commit/72951324dfeef64e09f4776898d675bc1c44f040), [Commit](https://github.com/open-webui/open-webui/commit/395098c6f1b7499d37ad55145a5931431d3e72e9), [Commit](https://github.com/open-webui/open-webui/commit/11487d66fc1a2dfafbdaa2b7ef939a86caaf3872) +- ๐Ÿ“„ **Open Terminal HTML file preview.** Users can now preview HTML files directly in the Open Terminal file browser, with a rendered iframe view and source toggle, enabling iterative AI editing of HTML files. [Commit](https://github.com/open-webui/open-webui/commit/3909b62ffcf49839fa57346ed8487ae759811503), [Commit](https://github.com/open-webui/open-webui/commit/933a3bbbd3f4fc3eeb0ec52c7965e9ac1c4cea39) +- ๐ŸŒ **Open Terminal WebSocket proxy.** Added a new WebSocket proxy endpoint for interactive terminal sessions, enabling real-time bidirectional terminal communication with the terminal server. [Commit](https://github.com/open-webui/open-webui/commit/4f6cb771f1afded09aad6199cdb244dd8a6c77a6) +- โš™๏ธ **Open Terminal feature toggle.** Administrators can now enable or disable the Interactive Terminal feature for Open Terminal via configuration on the terminal server, controlling access to terminal routes. [Commit](https://github.com/open-webui/open-webui/commit/b5c3395f79bcc7ff5bc1d82bb86a60583bb3b5bd) +- ๐Ÿ”„ **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- ๐ŸŒ Translations for Simplified Chinese, Traditional Chinese, Irish, and Catalan were enhanced and expanded. + +### Fixed + +- ๐Ÿ”ง **Middleware variable shadowing.** Fixed a variable shadowing issue in the middleware that could cause incorrect tool output processing during chat. [#22145](https://github.com/open-webui/open-webui/pull/22145) +- โšก **ChatControls reactivity fix.** Fixed a Svelte reactivity issue where the active tab state in the ChatControls panel was not properly saved when switching between chats. [#22127](https://github.com/open-webui/open-webui/pull/22127) +- ๐Ÿ”ง **ChatControls TypeScript fix.** Fixed a TypeScript syntax error in ChatControls.svelte where the module script block was missing lang="ts", causing esbuild to fail during vite dev. [#22131](https://github.com/open-webui/open-webui/pull/22131) +- ๐Ÿ”Œ **Open Terminal tools for direct connections.** Fixed an issue where Open Terminal tools were not available to the model when the terminal was configured via direct connection settings, ensuring users can now interact with terminal files and operations through the AI. [#22137](https://github.com/open-webui/open-webui/issues/22137) +- ๐Ÿ“œ **Chat history pagination.** Fixed an issue where older messages in long chats were not loaded when scrolling to the top. [Commit](https://github.com/open-webui/open-webui/commit/d7147d6cddfd314f0f1be77b15cec406a609ef36), [Commit](https://github.com/open-webui/open-webui/commit/c701ebe07bd152eecb42b0bf6de26071358a5c76) +- ๐Ÿ”ง **Terminal tool null parameter handling.** Fixed a bug where null parameters in terminal tool calls were sent as the string "None" instead of being omitted, causing 422 validation errors from the open-terminal server. [#22124](https://github.com/open-webui/open-webui/issues/22124), [#22144](https://github.com/open-webui/open-webui/pull/22144) + +### Changed + ## [0.8.7] - 2026-03-01 ### Fixed diff --git a/CHANGELOG_EXTRA.md b/CHANGELOG_EXTRA.md index 79651a800e..6d7bcb7cab 100644 --- a/CHANGELOG_EXTRA.md +++ b/CHANGELOG_EXTRA.md @@ -5,6 +5,12 @@ 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.8.8.1] - 2026.03.03 + +### Changed + +- ๅˆๅนถๅฎ˜ๆ–น 0.8.8 ๆ”นๅŠจ + ## [0.8.7.1] - 2026.03.02 ### Changed diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 254545a273..fe03816865 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -8,13 +8,14 @@ import logging import aiohttp -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, Request, Response, WebSocket from fastapi.responses import JSONResponse, StreamingResponse from starlette.background import BackgroundTask from open_webui.utils.auth import get_verified_user from open_webui.utils.access_control import has_connection_access from open_webui.models.groups import Groups +from open_webui.models.users import Users log = logging.getLogger(__name__) @@ -149,3 +150,155 @@ async def cleanup(): return JSONResponse( {"error": f"Terminal proxy error: {error}"}, status_code=502 ) + + +# --------------------------------------------------------------------------- +# WebSocket proxy for interactive terminal sessions +# --------------------------------------------------------------------------- + + +async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): + """Authenticate a WebSocket via first-message auth and resolve the terminal server. + + The client must send ``{"type": "auth", "token": ""}`` as its first + message after connecting. + + Returns ``(user, connection)`` on success, or ``None`` after closing *ws* + with an appropriate error code. + """ + import asyncio + import json + from open_webui.utils.auth import decode_token + + # First-message authentication + try: + raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0) + payload = json.loads(raw) + if payload.get("type") != "auth": + await ws.close(code=4001, reason="Expected auth message") + return None + token = payload.get("token", "") + data = decode_token(token) + if data is None or "id" not in data: + await ws.close(code=4001, reason="Invalid token") + return None + user = Users.get_user_by_id(data["id"]) + if user is None: + await ws.close(code=4001, reason="User not found") + return None + except (asyncio.TimeoutError, json.JSONDecodeError): + await ws.close(code=4001, reason="Auth timeout or invalid payload") + return None + except Exception: + await ws.close(code=4001, reason="Invalid token") + return None + + # Resolve terminal server + connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connection = next((c for c in connections if c.get("id") == server_id), None) + + if connection is None: + await ws.close(code=4004, reason="Terminal server not found") + return None + + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + if not has_connection_access(user, connection, user_group_ids): + await ws.close(code=4003, reason="Access denied") + return None + + return user, connection + + +@router.websocket("/{server_id}/api/terminals/{session_id}") +async def ws_terminal( + ws: WebSocket, + server_id: str, + session_id: str, +): + """Proxy an interactive WebSocket terminal session to a terminal server. + + Uses first-message auth: the client sends ``{"type": "auth", "token": ""}`` + as its first message. The proxy validates the JWT, then connects to the + upstream terminal server and authenticates with the server's API key. + """ + await ws.accept() + + result = await _resolve_authenticated_connection(ws, server_id) + if result is None: + return + user, connection = result + + base_url = (connection.get("url") or "").rstrip("/") + if not base_url: + await ws.close(code=4003, reason="Terminal server URL not configured") + return + + # Build upstream WebSocket URL (no token in URL) + ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://") + + auth_type = connection.get("auth_type", "bearer") + upstream_params = {} + # For orchestrator-backed servers, pass user_id + upstream_params["user_id"] = user.id + + import urllib.parse + + upstream_url = f"{ws_base}/api/terminals/{session_id}" + if upstream_params: + upstream_url += f"?{urllib.parse.urlencode(upstream_params)}" + + session = aiohttp.ClientSession() + try: + async with session.ws_connect(upstream_url) as upstream: + import asyncio + import json as _json + + # First-message auth to upstream terminal server + auth_type = connection.get("auth_type", "bearer") + if auth_type == "bearer": + key = connection.get("key", "") + await upstream.send_str(_json.dumps({"type": "auth", "token": key})) + + async def _client_to_upstream(): + """Forward client โ†’ upstream.""" + try: + while True: + msg = await ws.receive() + if msg["type"] == "websocket.disconnect": + break + elif "bytes" in msg and msg["bytes"]: + await upstream.send_bytes(msg["bytes"]) + elif "text" in msg and msg["text"]: + await upstream.send_str(msg["text"]) + except Exception: + pass + + async def _upstream_to_client(): + """Forward upstream โ†’ client.""" + try: + async for msg in upstream: + if msg.type == aiohttp.WSMsgType.BINARY: + await ws.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.TEXT: + await ws.send_text(msg.data) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.ERROR, + ): + break + except Exception: + pass + + await asyncio.gather( + _client_to_upstream(), + _upstream_to_client(), + return_exceptions=True, + ) + except Exception as e: + log.exception("Terminal WebSocket proxy error: %s", e) + finally: + await session.close() + try: + await ws.close() + except Exception: + pass diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index a59df3813f..eec760fe01 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -1831,7 +1831,7 @@ async def query_knowledge_files( elif item_type == "file": # Individual file - use file-{id} as collection name file = Files.get_file_by_id(item_id) - if file and (user_role == "admin" or file.user_id == user_id): + if file: collection_names.append(f"file-{item_id}") elif item_type == "note": diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index 11c06f14ad..e3d52b0f55 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -7,6 +7,7 @@ from open_webui.models.channels import Channels from open_webui.models.chats import Chats from open_webui.models.groups import Groups +from open_webui.models.models import Models from open_webui.models.access_grants import AccessGrants log = logging.getLogger(__name__) @@ -21,6 +22,7 @@ def has_access_to_file( """ Check if a user has the specified access to a file through any of: - Knowledge bases (ownership or access grants) + - Shared workspace models that attach the file directly - Channels the user is a member of - Shared chats @@ -72,4 +74,15 @@ def has_access_to_file( if chats: return True + # Check if the file is directly attached to a shared workspace model + for model in Models.get_models_by_user_id(user.id, permission=access_type, db=db): + knowledge_items = getattr(model.meta, "knowledge", None) or [] + for item in knowledge_items: + if ( + isinstance(item, dict) + and item.get("type") == "file" + and item.get("id") == file.id + ): + return True + return False diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 55f55ab033..9c11c96447 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -91,6 +91,7 @@ get_last_user_message_item, get_last_assistant_message, get_system_message, + replace_system_message_content, prepend_to_first_user_message_content, convert_logit_bias_input_to_json, get_content_from_message, @@ -375,9 +376,9 @@ def serialize_output(output: list) -> str: result_item = tool_outputs.get(call_id) if result_item: result_text = "" - for output in result_item.get("output", []): - if "text" in output: - output_text = output.get("text", "") + for result_output in result_item.get("output", []): + if "text" in result_output: + output_text = result_output.get("text", "") result_text += ( str(output_text) if not isinstance(output_text, str) @@ -4090,6 +4091,22 @@ async def flush_pending_delta_data(threshold: int = 0): all_tool_call_sources = [] # Accumulated sources across all iterations user_message = get_last_user_message(form_data["messages"]) + # Check if citations are enabled for this model + citations_enabled = ( + model.get("info", {}).get("meta", {}).get("capabilities") or {} + ).get("citations", True) + + # Save original system message so we can restore it before + # re-applying source context (prevents duplication when + # RAG_SYSTEM_CONTEXT is enabled and the template is appended + # to the system message on each iteration). + original_system_message = get_system_message(form_data["messages"]) + original_system_content = ( + get_content_from_message(original_system_message) + if original_system_message + else None + ) + while ( len(tool_calls) > 0 and tool_call_retries < CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES @@ -4244,7 +4261,8 @@ async def flush_pending_delta_data(threshold: int = 0): # Extract citation sources from tool results if ( - tool_function_name + citations_enabled + and tool_function_name in [ "search_web", "fetch_url", @@ -4334,27 +4352,35 @@ async def flush_pending_delta_data(threshold: int = 0): } ) - # Emit citation sources for UI display - for source in tool_call_sources: - await event_emitter({"type": "source", "data": source}) - - # Apply source context to messages for model - # Use metadata_only=True to avoid duplicating content - # that is already in the tool result message. - all_tool_call_sources.extend(tool_call_sources) - if all_tool_call_sources and user_message: - # Restore original user message before re-applying to avoid recursive nesting - set_last_user_message_content( - user_message, form_data["messages"] - ) - form_data["messages"] = apply_source_context_to_messages( - request, - form_data["messages"], - all_tool_call_sources, - user_message, - include_content=False, - ) - tool_call_sources.clear() + # Emit citation sources and apply source context to messages + if citations_enabled: + for source in tool_call_sources: + await event_emitter({"type": "source", "data": source}) + + # Apply source context to messages for model. + # Use include_content=False to avoid duplicating content + # that is already in the tool result message. + all_tool_call_sources.extend(tool_call_sources) + if all_tool_call_sources and user_message: + # Restore original messages before re-applying to + # avoid recursive nesting (user message) and + # duplication (system message with RAG_SYSTEM_CONTEXT). + set_last_user_message_content( + user_message, form_data["messages"] + ) + if original_system_content is not None: + replace_system_message_content( + original_system_content, + form_data["messages"], + ) + form_data["messages"] = apply_source_context_to_messages( + request, + form_data["messages"], + all_tool_call_sources, + user_message, + include_content=False, + ) + tool_call_sources.clear() await event_emitter( { diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index ab9c1b661b..2c4b983bd8 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1247,7 +1247,8 @@ async def execute_tool_server( if param_in == "path": path_params[param_name] = params[param_name] elif param_in == "query": - query_params[param_name] = params[param_name] + if params[param_name] is not None: + query_params[param_name] = params[param_name] final_url = f"{url}{route_path}" for key, value in path_params.items(): diff --git a/package-lock.json b/package-lock.json index 23d9defff1..10d097843b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.7.1", + "version": "0.8.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.7.1", + "version": "0.8.8.1", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -38,6 +38,8 @@ "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", "@tiptap/suggestion": "^3.4.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^0.1.19", "alpinejs": "^3.15.0", @@ -3266,9 +3268,9 @@ } }, "node_modules/@tiptap/extension-collaboration": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz", - "integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.20.0.tgz", + "integrity": "sha512-JItmI4U0i4kqorO114u24hM9k945IdaQ6Uc2DEtPBFFuS8cepJf2zw+ulAT1kAx6ZRiNvNpT9M7w+J0mWRn+Sg==", "license": "MIT", "peer": true, "funding": { @@ -3276,9 +3278,9 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.4.5", - "@tiptap/pm": "^3.4.5", - "@tiptap/y-tiptap": "^3.0.0-beta.3", + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0", + "@tiptap/y-tiptap": "^3.0.2", "yjs": "^13" } }, @@ -3525,9 +3527,9 @@ } }, "node_modules/@tiptap/extension-node-range": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz", - "integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.20.0.tgz", + "integrity": "sha512-XeKKTV88VuJ4Mh0Rxvc/PPzG76cb44sE+rB4u0J/ms63R/WFTm6yJQlCgUVGnGeHleSlrWuZY8gGSuoljmQzqg==", "license": "MIT", "peer": true, "funding": { @@ -3535,8 +3537,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.4.5", - "@tiptap/pm": "^3.4.5" + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" } }, "node_modules/@tiptap/extension-ordered-list": { @@ -3606,9 +3608,9 @@ } }, "node_modules/@tiptap/extension-text-style": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.0.7.tgz", - "integrity": "sha512-naJ1XxlbFJ1qlpA+i54lQYKuhWP1dnkUslM86OT0TZt0zJBeu7LIrqSOVGmMB++lF/btnQLMnYkYSSnkLgIw3A==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.0.tgz", + "integrity": "sha512-zyWW1a6W+kaXAn3wv2svJ1XuVMapujftvH7Xn2Q3QmKKiDkO+NiFkrGe8BhMopu8Im51nO3NylIgVA0X1mS1rQ==", "license": "MIT", "peer": true, "funding": { @@ -3616,7 +3618,7 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.0" } }, "node_modules/@tiptap/extension-typography": { @@ -3753,9 +3755,9 @@ } }, "node_modules/@tiptap/y-tiptap": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.0.tgz", - "integrity": "sha512-HIeJZCj+KYJde2x6fONzo4o6kd7gW7eonwhQsv2p2VQnUgwNXMVhN+D6Z3AH/2i541Sq33y1PO4U/1ThCPjqbA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.2.tgz", + "integrity": "sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA==", "license": "MIT", "peer": true, "dependencies": { @@ -4481,6 +4483,18 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/package.json b/package.json index 75773b2545..5134692696 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.7.1", + "version": "0.8.8.1", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -82,6 +82,8 @@ "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", "@tiptap/suggestion": "^3.4.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^0.1.19", "alpinejs": "^3.15.0", diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 5815bcd3c8..aa99810753 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -5,6 +5,10 @@ export type FileEntry = { modified?: number; }; +export type TerminalFeatures = { + terminal?: boolean; +}; + import { WEBUI_API_BASE_URL } from '$lib/constants'; export type TerminalServer = { @@ -23,6 +27,18 @@ export const getTerminalServers = async (token: string): Promise []); }; +export const getTerminalConfig = async ( + baseUrl: string, + apiKey: string +): Promise<{ features: TerminalFeatures } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/api/config`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` } + }).catch(() => null); + if (!res || !res.ok) return null; + return res.json().catch(() => null); +}; + export const getCwd = async (baseUrl: string, apiKey: string): Promise => { const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; const res = await fetch(url, { @@ -193,3 +209,29 @@ export const setCwd = async ( }); return res; }; + +export const moveEntry = async ( + baseUrl: string, + apiKey: string, + source: string, + destination: string +): Promise<{ source: string; destination: string } | { error: string }> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/move`; + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ source, destination }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error('open-terminal moveEntry error:', err); + return { error: err?.detail ?? 'Move failed' }; + }); + return res; +}; diff --git a/src/lib/components/AddTerminalServerModal.svelte b/src/lib/components/AddTerminalServerModal.svelte index 233eada3dc..c26460549e 100644 --- a/src/lib/components/AddTerminalServerModal.svelte +++ b/src/lib/components/AddTerminalServerModal.svelte @@ -25,7 +25,7 @@ let id = ''; let auth_type = 'bearer'; let path = '/openapi.json'; - let enabled = true; + let enabled = false; let showAdvanced = false; let showAccessControlModal = false; let accessGrants: any[] = []; @@ -47,7 +47,7 @@ name = ''; auth_type = 'bearer'; path = '/openapi.json'; - enabled = true; + enabled = false; accessGrants = []; } }; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 189c5c3d30..4bd565e89f 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -147,34 +147,6 @@ let webSearchEnabled = false; let codeInterpreterEnabled = false; - // Auto-inject direct terminal servers into selected tool IDs so they act like toggled-on tools - // System terminals (with id field) are handled server-side via terminal_id, not as direct tool servers - $: if ($terminalServers && $terminalServers.length > 0) { - const directTerminalServers = $terminalServers.filter((t) => !t.id); - const terminalIds = directTerminalServers.map( - (_, i) => `direct_server:terminal_${$terminalServers.indexOf(directTerminalServers[i])}` - ); - const missingIds = terminalIds.filter((id) => !selectedToolIds.includes(id)); - if (missingIds.length > 0) { - selectedToolIds = [...selectedToolIds, ...missingIds]; - } - } - - // Remove disabled terminal servers from selectedToolIds automatically - $: if (selectedToolIds.length > 0) { - const directTerminalServers = ($terminalServers ?? []).filter((t) => !t.id); - const terminalIds = directTerminalServers.map( - (_, i) => - `direct_server:terminal_${($terminalServers ?? []).indexOf(directTerminalServers[i])}` - ); - const invalidTerminalIds = selectedToolIds.filter( - (id) => id.startsWith('direct_server:terminal_') && !terminalIds.includes(id) - ); - if (invalidTerminalIds.length > 0) { - selectedToolIds = selectedToolIds.filter((id) => !invalidTerminalIds.includes(id)); - } - } - let showCommands = false; let generating = false; @@ -355,25 +327,12 @@ [...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id)) ) ]; - } else if ( - $settings?.tools && - $settings.tools.some((id) => !id.startsWith('direct_server:terminal_')) - ) { + } else if ($settings?.tools) { selectedToolIds = $settings.tools; } else { - // Don't wipe existing terminal servers if no default tool IDs selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:')); } - // Auto-inject direct terminal servers (system ones are handled via terminal_id) - if ($terminalServers && $terminalServers.length > 0) { - const directTerminalServers = $terminalServers.filter((t) => !t.id); - const terminalIds = directTerminalServers.map( - (_, i) => `direct_server:terminal_${$terminalServers.indexOf(directTerminalServers[i])}` - ); - selectedToolIds = [...new Set([...selectedToolIds, ...terminalIds])]; - } - // Set Default Filters (Toggleable only) if (model?.info?.meta?.defaultFilterIds) { selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) => @@ -2210,7 +2169,9 @@ tool_servers: [ ...($toolServers ?? []).filter( (server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id) - ) + ), + // Direct terminal servers โ€” always included when enabled (not routed through selectedToolIds) + ...($terminalServers ?? []).filter((t) => !t.id) ], features: getFeatures(), variables: { diff --git a/src/lib/components/chat/ChatControls.svelte b/src/lib/components/chat/ChatControls.svelte index deff2fa3bb..2f5aec4b09 100644 --- a/src/lib/components/chat/ChatControls.svelte +++ b/src/lib/components/chat/ChatControls.svelte @@ -58,8 +58,12 @@ let paneReady = false; // Tab state for Controls+Files panel - let activeTab: 'controls' | 'files' | 'overview' = savedTab; - $: savedTab = activeTab; + let activeTab = savedTab; + // svelte-ignore reactive_declaration_module_script_dependency + $: { + savedTab = activeTab; + } + $: hasMessages = history?.messages && Object.keys(history.messages).length > 0; $: showControlsTab = $user?.role === 'admin' || ($user?.permissions?.chat?.controls ?? true); @@ -280,10 +284,11 @@
-
+
{#if showControlsTab}
{:else}
(isDragOver = false)} @@ -465,6 +534,7 @@ onNewFolder={startNewFolder} onNewFile={startNewFile} onUploadFiles={handleUploadFiles} + onMove={handleMove} > {#if fileImageUrl !== null} @@ -488,7 +558,7 @@ {/if} - {#if (isMarkdown || isCsv) && fileContent !== null && !editing} + {#if (isMarkdown || isCsv || isHtml) && fileContent !== null && !editing}
+ + + {#if terminalEnabled} +
+ {#if terminalExpanded} + + +
+
+
+ {/if} + + + + + {#if terminalExpanded} +
+ +
+ {/if} +
+ {/if}
{/if} diff --git a/src/lib/components/chat/FileNav/FileEntryRow.svelte b/src/lib/components/chat/FileNav/FileEntryRow.svelte index c5d778555e..b572b15255 100644 --- a/src/lib/components/chat/FileNav/FileEntryRow.svelte +++ b/src/lib/components/chat/FileNav/FileEntryRow.svelte @@ -18,24 +18,70 @@ export let onOpen: (entry: FileEntry) => void = () => {}; export let onDownload: (path: string) => void = () => {}; export let onDelete: (path: string, name: string) => void = () => {}; + export let onMove: (source: string, destFolder: string) => void = () => {}; + + let dragOverFolder = false;
  • -
    +
    { + if (entry.type !== 'directory') return; + if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return; + e.preventDefault(); + e.stopPropagation(); + dragOverFolder = true; + }} + on:dragleave={(e) => { + if (entry.type !== 'directory') return; + e.stopPropagation(); + dragOverFolder = false; + }} + on:drop={(e) => { + if (entry.type !== 'directory') return; + const raw = e.dataTransfer?.getData('application/x-terminal-file-move'); + if (!raw) return; + e.preventDefault(); + e.stopPropagation(); + dragOverFolder = false; + try { + const data = JSON.parse(raw); + if (data.path) { + const destFolder = `${currentPath}${entry.name}/`; + // Don't allow dropping a folder onto itself + if (data.path + '/' === destFolder || data.path === destFolder) return; + onMove(data.path, destFolder); + } + } catch {} + }} + > diff --git a/src/lib/components/chat/FileNav/FilePreview.svelte b/src/lib/components/chat/FileNav/FilePreview.svelte index 8de75e238a..c95d909ee2 100644 --- a/src/lib/components/chat/FileNav/FilePreview.svelte +++ b/src/lib/components/chat/FileNav/FilePreview.svelte @@ -3,6 +3,7 @@ import panzoom, { type PanZoom } from 'panzoom'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; + import { settings } from '$lib/stores'; import Spinner from '../../common/Spinner.svelte'; import PDFViewer from '../../common/PDFViewer.svelte'; @@ -16,6 +17,8 @@ export let filePdfData: ArrayBuffer | null = null; export let fileContent: string | null = null; + export let overlay = false; + export let onSave: ((content: string) => Promise) | null = null; export let editing = false; @@ -57,10 +60,12 @@ const MD_EXTS = new Set(['md', 'markdown', 'mdx']); const CSV_EXTS = new Set(['csv', 'tsv']); + const HTML_EXTS = new Set(['html', 'htm']); const getExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? ''; $: isMarkdown = MD_EXTS.has(getExt(selectedFile)); $: isCsv = CSV_EXTS.has(getExt(selectedFile)); + $: isHtml = HTML_EXTS.has(getExt(selectedFile)); $: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ','; $: renderedHtml = isMarkdown && fileContent @@ -166,7 +171,19 @@ {:else if filePdfData !== null} {:else if fileContent !== null} - {#if isMarkdown && !showRaw} + {#if isHtml && !showRaw} + {#if overlay} +
    + {/if} +