pygpt-net 2.6.51__py3-none-any.whl → 2.6.53__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. pygpt_net/CHANGELOG.txt +10 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +3 -1
  4. pygpt_net/controller/__init__.py +4 -2
  5. pygpt_net/controller/audio/audio.py +22 -1
  6. pygpt_net/controller/chat/chat.py +5 -1
  7. pygpt_net/controller/chat/remote_tools.py +116 -0
  8. pygpt_net/controller/ctx/ctx.py +8 -3
  9. pygpt_net/controller/lang/mapping.py +2 -1
  10. pygpt_net/controller/mode/mode.py +5 -2
  11. pygpt_net/controller/plugins/plugins.py +29 -3
  12. pygpt_net/controller/realtime/realtime.py +8 -3
  13. pygpt_net/controller/ui/mode.py +11 -5
  14. pygpt_net/controller/ui/tabs.py +31 -7
  15. pygpt_net/core/ctx/output.py +4 -2
  16. pygpt_net/core/render/web/renderer.py +5 -4
  17. pygpt_net/core/tabs/tab.py +42 -9
  18. pygpt_net/core/tabs/tabs.py +7 -9
  19. pygpt_net/data/config/config.json +6 -5
  20. pygpt_net/data/config/models.json +3 -3
  21. pygpt_net/data/icons/web_off.svg +1 -0
  22. pygpt_net/data/icons/web_on.svg +1 -0
  23. pygpt_net/data/locale/locale.de.ini +1 -0
  24. pygpt_net/data/locale/locale.en.ini +3 -2
  25. pygpt_net/data/locale/locale.es.ini +1 -0
  26. pygpt_net/data/locale/locale.fr.ini +1 -0
  27. pygpt_net/data/locale/locale.it.ini +1 -0
  28. pygpt_net/data/locale/locale.pl.ini +1 -4
  29. pygpt_net/data/locale/locale.uk.ini +1 -0
  30. pygpt_net/data/locale/locale.zh.ini +1 -0
  31. pygpt_net/data/locale/plugin.mcp.en.ini +12 -0
  32. pygpt_net/icons.qrc +2 -0
  33. pygpt_net/icons_rc.py +232 -147
  34. pygpt_net/plugin/mcp/__init__.py +12 -0
  35. pygpt_net/plugin/mcp/config.py +103 -0
  36. pygpt_net/plugin/mcp/plugin.py +513 -0
  37. pygpt_net/plugin/mcp/worker.py +263 -0
  38. pygpt_net/provider/api/anthropic/tools.py +4 -2
  39. pygpt_net/provider/api/google/__init__.py +3 -2
  40. pygpt_net/provider/api/openai/agents/remote_tools.py +14 -4
  41. pygpt_net/provider/api/openai/chat.py +14 -2
  42. pygpt_net/provider/api/openai/remote_tools.py +5 -2
  43. pygpt_net/provider/api/x_ai/remote.py +6 -1
  44. pygpt_net/provider/core/config/patch.py +8 -1
  45. pygpt_net/ui/dialog/plugins.py +1 -3
  46. pygpt_net/ui/layout/chat/output.py +7 -2
  47. pygpt_net/ui/widget/element/labels.py +1 -2
  48. pygpt_net/ui/widget/tabs/body.py +24 -5
  49. {pygpt_net-2.6.51.dist-info → pygpt_net-2.6.53.dist-info}/METADATA +24 -4
  50. {pygpt_net-2.6.51.dist-info → pygpt_net-2.6.53.dist-info}/RECORD +53 -45
  51. {pygpt_net-2.6.51.dist-info → pygpt_net-2.6.53.dist-info}/LICENSE +0 -0
  52. {pygpt_net-2.6.51.dist-info → pygpt_net-2.6.53.dist-info}/WHEEL +0 -0
  53. {pygpt_net-2.6.51.dist-info → pygpt_net-2.6.53.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.09.16 22:00:00 #
10
+ # ================================================== #
11
+
12
+ import asyncio
13
+ import json
14
+ import shlex
15
+ from contextlib import asynccontextmanager
16
+ from typing import Dict, Any, List, Tuple, Optional
17
+ from urllib.parse import urlparse
18
+
19
+ from PySide6.QtCore import Slot
20
+
21
+ from pygpt_net.plugin.base.worker import BaseWorker, BaseSignals
22
+
23
+
24
+ class WorkerSignals(BaseSignals):
25
+ pass # add custom signals here
26
+
27
+
28
+ class Worker(BaseWorker):
29
+ def __init__(self, *args, **kwargs):
30
+ super(Worker, self).__init__()
31
+ self.signals = BaseSignals()
32
+ self.args = args
33
+ self.kwargs = kwargs
34
+ self.plugin = None
35
+ self.cmds: Optional[List[dict]] = None
36
+ self.ctx = None
37
+ self.tools_index: Dict[str, Dict[str, Any]] = {}
38
+
39
+ @Slot()
40
+ def run(self):
41
+ """
42
+ Worker entry point executed in a background thread.
43
+ Starts an asyncio loop to talk to MCP servers.
44
+ """
45
+ try:
46
+ responses = asyncio.run(self._run_async())
47
+ if responses:
48
+ self.reply_more(responses)
49
+ except Exception as e:
50
+ self.error(e)
51
+ finally:
52
+ self.cleanup()
53
+
54
+ async def _run_async(self) -> List[dict]:
55
+ """
56
+ Group commands per server, open a session per server, then call tools.
57
+ """
58
+ responses: List[dict] = []
59
+
60
+ try:
61
+ from mcp import ClientSession, types # type: ignore
62
+ from mcp.client.stdio import stdio_client # type: ignore
63
+ from mcp.client.streamable_http import streamablehttp_client # type: ignore
64
+ from mcp.client.sse import sse_client # type: ignore
65
+ from mcp import StdioServerParameters # type: ignore
66
+ except Exception as e:
67
+ self.status('MCP SDK not installed. Install with: pip install "mcp[cli]"')
68
+ self.log(f"MCP import error in worker: {e}")
69
+ for item in (self.cmds or []):
70
+ responses.append(self.make_response(item, f"MCP SDK not installed: {e}"))
71
+ return responses
72
+
73
+ # Group by server
74
+ grouped: Dict[str, List[dict]] = {}
75
+ for item in self.cmds or []:
76
+ meta = self.tools_index.get(item["cmd"])
77
+ if not meta:
78
+ continue
79
+ server_key = self._server_key(meta["server"])
80
+ grouped.setdefault(server_key, []).append(item)
81
+
82
+ # Execute per server
83
+ for server_key, items in grouped.items():
84
+ meta0 = self.tools_index.get(items[0]["cmd"])
85
+ if not meta0:
86
+ continue
87
+ server_cfg = meta0["server"]
88
+ address = (server_cfg.get("server_address") or "").strip()
89
+ transport = meta0["transport"]
90
+ headers = self._build_headers(server_cfg)
91
+
92
+ try:
93
+ async with self._open_session(address, transport, headers=headers) as session:
94
+ for item in items:
95
+ if self.is_stopped():
96
+ break
97
+
98
+ meta = self.tools_index.get(item["cmd"])
99
+ if not meta:
100
+ continue
101
+
102
+ tool_name = meta["tool_name"]
103
+ schema = meta.get("schema")
104
+ arguments = self._coerce_arguments(item.get("params", {}), schema)
105
+
106
+ try:
107
+ result = await session.call_tool(tool_name, arguments=arguments)
108
+ text = self._extract_text_result(result)
109
+ responses.append(self.make_response(item, text))
110
+ except Exception as e:
111
+ responses.append(self.make_response(item, self.throw_error(e)))
112
+
113
+ except Exception as e:
114
+ msg = f"MCP server error ({address}): {e}"
115
+ self.log(msg)
116
+ self.status(msg)
117
+ for item in items:
118
+ responses.append(self.make_response(item, self.throw_error(e)))
119
+
120
+ return responses
121
+
122
+ # ---------------------------
123
+ # Session / transport helpers
124
+ # ---------------------------
125
+
126
+ @asynccontextmanager
127
+ async def _open_session(self, address: str, transport: str, headers: Optional[dict] = None):
128
+ """
129
+ Open and initialize MCP session for given server address and transport.
130
+ Yields a ready-to-use ClientSession.
131
+ """
132
+ from mcp import ClientSession # type: ignore
133
+
134
+ if transport == "stdio":
135
+ from mcp.client.stdio import stdio_client # type: ignore
136
+ from mcp import StdioServerParameters # type: ignore
137
+ cmd, args = self._parse_stdio_command(address)
138
+ params = StdioServerParameters(command=cmd, args=args)
139
+ async with stdio_client(params) as (read, write):
140
+ async with ClientSession(read, write) as session:
141
+ await session.initialize()
142
+ yield session
143
+
144
+ elif transport == "http":
145
+ from mcp.client.streamable_http import streamablehttp_client # type: ignore
146
+ async with streamablehttp_client(address, headers=headers or None) as (read, write, _):
147
+ async with ClientSession(read, write) as session:
148
+ await session.initialize()
149
+ yield session
150
+
151
+ elif transport == "sse":
152
+ from mcp.client.sse import sse_client # type: ignore
153
+ async with sse_client(address, headers=headers or None) as (read, write):
154
+ async with ClientSession(read, write) as session:
155
+ await session.initialize()
156
+ yield session
157
+
158
+ else:
159
+ raise RuntimeError(f"Unsupported transport: {transport}")
160
+
161
+ def _parse_stdio_command(self, address: str) -> Tuple[str, List[str]]:
162
+ """Parse 'stdio: <command line>' into (command, args)."""
163
+ cmdline = address[len("stdio:"):].strip()
164
+ tokens = shlex.split(cmdline)
165
+ if not tokens:
166
+ raise ValueError("Invalid stdio address: empty command")
167
+ return tokens[0], tokens[1:]
168
+
169
+ # ---------------------------
170
+ # Result & argument handling
171
+ # ---------------------------
172
+
173
+ def _coerce_arguments(self, params: Dict[str, Any], schema: Optional[dict]) -> Dict[str, Any]:
174
+ """Coerce incoming params into types expected by the tool schema."""
175
+ if not schema or not isinstance(schema, dict):
176
+ return params or {}
177
+
178
+ props = schema.get("properties", {}) or {}
179
+ coerced: Dict[str, Any] = {}
180
+
181
+ for name, value in (params or {}).items():
182
+ target = props.get(name, {})
183
+ jtype = target.get("type", "string")
184
+
185
+ try:
186
+ if jtype == "integer":
187
+ coerced[name] = int(value)
188
+ elif jtype == "number":
189
+ coerced[name] = float(value)
190
+ elif jtype == "boolean":
191
+ if isinstance(value, bool):
192
+ coerced[name] = value
193
+ elif isinstance(value, str):
194
+ coerced[name] = value.strip().lower() in ("1", "true", "yes", "y", "on")
195
+ else:
196
+ coerced[name] = bool(value)
197
+ elif jtype in ("array", "object"):
198
+ if isinstance(value, (dict, list)):
199
+ coerced[name] = value
200
+ elif isinstance(value, str):
201
+ try:
202
+ coerced[name] = json.loads(value)
203
+ except Exception:
204
+ coerced[name] = value
205
+ else:
206
+ coerced[name] = value
207
+ else:
208
+ coerced[name] = value
209
+ except Exception:
210
+ coerced[name] = value
211
+
212
+ return coerced
213
+
214
+ def _extract_text_result(self, result: Any) -> str:
215
+ """
216
+ Convert MCP tool result into a readable string.
217
+ Prefer structuredContent; fallback to text content blocks.
218
+ """
219
+ try:
220
+ if getattr(result, "structuredContent", None) is not None:
221
+ return json.dumps(result.structuredContent, ensure_ascii=False, indent=2)
222
+
223
+ content_list = getattr(result, "content", None)
224
+ if not content_list:
225
+ return "No result (empty content)"
226
+ from mcp import types # type: ignore
227
+ texts: List[str] = []
228
+ for block in content_list:
229
+ if isinstance(block, types.TextContent):
230
+ texts.append(block.text)
231
+ else:
232
+ btype = getattr(block, "type", "content")
233
+ texts.append(f"[{btype}]")
234
+ return "\n".join(texts) if texts else "No result (no text content)"
235
+ except Exception as e:
236
+ return f"Failed to parse MCP result: {e}"
237
+
238
+ # ---------------------------
239
+ # Misc helpers
240
+ # ---------------------------
241
+
242
+ def _server_key(self, server: dict) -> str:
243
+ """Create a deterministic key for a server config entry."""
244
+ addr = (server.get("server_address") or "").strip()
245
+ if addr.lower().startswith("http"):
246
+ try:
247
+ parsed = urlparse(addr)
248
+ return f"http::{parsed.netloc}{parsed.path}"
249
+ except Exception:
250
+ return f"http::{addr}"
251
+ if addr.lower().startswith(("sse://", "sse+http://", "sse+https://")):
252
+ return f"sse::{addr}"
253
+ if addr.startswith("stdio:"):
254
+ return f"stdio::{addr[len('stdio:'):].strip()}"
255
+ return addr
256
+
257
+ def _build_headers(self, server: dict) -> Optional[dict]:
258
+ """Build optional headers for HTTP/SSE transports (Authorization only)."""
259
+ auth = (server.get("authorization") or "").strip()
260
+ headers = {}
261
+ if auth:
262
+ headers["Authorization"] = auth
263
+ return headers or None
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.05 01:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
@@ -177,8 +177,10 @@ class Tools:
177
177
  if model and model.id and model.id.startswith("claude-3-5"):
178
178
  return tools
179
179
 
180
+ is_web = self.window.controller.chat.remote_tools.enabled(model, "web_search") # get global config
181
+
180
182
  # Web Search tool
181
- if cfg.get("remote_tools.anthropic.web_search"):
183
+ if is_web:
182
184
  ttype = cfg.get("remote_tools.anthropic.web_search.type", "web_search_20250305") # stable as of docs
183
185
  tname = "web_search"
184
186
 
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.15 01:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
@@ -260,9 +260,10 @@ class ApiGoogle:
260
260
  tools: list = []
261
261
  cfg = self.window.core.config
262
262
  model_id = (model.id if model and getattr(model, "id", None) else "").lower()
263
+ is_web = self.window.controller.chat.remote_tools.enabled(model, "web_search") # get global config
263
264
 
264
265
  # Google Search tool
265
- if cfg.get("remote_tools.google.web_search") and "image" not in model.id:
266
+ if is_web and "image" not in model.id:
266
267
  try:
267
268
  if not model_id.startswith("gemini-1.5") and not model_id.startswith("models/gemini-1.5"):
268
269
  # Gemini 2.x uses GoogleSearch
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.01 03:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
@@ -135,9 +135,11 @@ def get_remote_tools(
135
135
  "computer_use": False,
136
136
  }
137
137
 
138
+ enabled_global = window.controller.chat.remote_tools.enabled # get global config
139
+
138
140
  # from global config if not expert call
139
141
  if not is_expert_call:
140
- enabled["web_search"] = window.core.config.get("remote_tools.web_search", False)
142
+ enabled["web_search"] = enabled_global(model, "web_search") # <-- from global config
141
143
  enabled["image"] = window.core.config.get("remote_tools.image", False)
142
144
  enabled["code_interpreter"] = window.core.config.get("remote_tools.code_interpreter", False)
143
145
  enabled["mcp"] = window.core.config.get("remote_tools.mcp", False)
@@ -147,8 +149,16 @@ def get_remote_tools(
147
149
  # for expert call, get from preset config
148
150
  if preset:
149
151
  if preset.remote_tools:
150
- tools_list = [preset_remote_tool.strip() for preset_remote_tool in preset.remote_tools.split(",") if
151
- preset_remote_tool.strip()]
152
+ if isinstance(preset.remote_tools, str):
153
+ tools_list = [preset_remote_tool.strip() for preset_remote_tool in preset.remote_tools.split(",") if
154
+ preset_remote_tool.strip()]
155
+ elif isinstance(preset.remote_tools, list):
156
+ tools_list = [str(preset_remote_tool).strip() for preset_remote_tool in preset.remote_tools if
157
+ str(preset_remote_tool).strip()]
158
+ else:
159
+ tools_list = []
160
+ if "web_search" not in tools_list:
161
+ enabled["web_search"] = enabled_global(model, "web_search") # <-- from global config
152
162
  for item in tools_list:
153
163
  if item in enabled:
154
164
  enabled[item] = True
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.28 09:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
@@ -145,9 +145,21 @@ class Chat:
145
145
  if stream:
146
146
  response_kwargs['stream_options'] = {"include_usage": True}
147
147
 
148
+ # OpenRouter: add web search remote tool (if enabled)
149
+ # https://openrouter.ai/docs/features/web-search
150
+ model_id = model.id
151
+ if model.provider == "open_router":
152
+ is_web = self.window.controller.chat.remote_tools.enabled(model, "web_search") # web search config
153
+ if is_web:
154
+ if not model_id.endswith(":online"):
155
+ model_id += ":online"
156
+ else:
157
+ if model_id.endswith(":online"):
158
+ model_id = model_id.replace(":online", "")
159
+
148
160
  response = client.chat.completions.create(
149
161
  messages=messages,
150
- model=model.id,
162
+ model=model_id,
151
163
  stream=stream,
152
164
  **response_kwargs,
153
165
  )
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.07.30 00:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
@@ -87,10 +87,11 @@ class RemoteTools:
87
87
  "file_search": False,
88
88
  "computer_use": False,
89
89
  }
90
+ enabled_global = self.window.controller.chat.remote_tools.enabled # get global config
90
91
 
91
92
  # from global config if not expert call
92
93
  if not is_expert_call:
93
- enabled["web_search"] = self.window.core.config.get("remote_tools.web_search", False)
94
+ enabled["web_search"] = enabled_global(model, "web_search") # <-- from global config
94
95
  enabled["image"] = self.window.core.config.get("remote_tools.image", False)
95
96
  enabled["code_interpreter"] = self.window.core.config.get("remote_tools.code_interpreter", False)
96
97
  enabled["mcp"] = self.window.core.config.get("remote_tools.mcp", False)
@@ -101,6 +102,8 @@ class RemoteTools:
101
102
  if preset:
102
103
  if preset.remote_tools:
103
104
  tools_list = [preset_remote_tool.strip() for preset_remote_tool in preset.remote_tools.split(",") if preset_remote_tool.strip()]
105
+ if "web_search" not in tools_list:
106
+ enabled["web_search"] = enabled_global(model, "web_search") # <-- from global config
104
107
  for item in tools_list:
105
108
  if item in enabled:
106
109
  enabled[item] = True
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.05 01:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from __future__ import annotations
@@ -57,11 +57,16 @@ class Remote:
57
57
  :return: Dict with 'sdk' and 'http' keys
58
58
  """
59
59
  cfg = self.window.core.config
60
+ is_web = self.window.controller.chat.remote_tools.enabled(model, "web_search") # get global config
60
61
 
61
62
  mode = str(cfg.get("remote_tools.xai.mode") or "auto").lower()
62
63
  if mode not in ("auto", "on", "off"):
63
64
  mode = "auto"
64
65
 
66
+ if mode == "off":
67
+ if is_web:
68
+ mode = "auto" # override off if global web_search enabled
69
+
65
70
  # sources toggles
66
71
  s_web = bool(cfg.get("remote_tools.xai.sources.web", True))
67
72
  s_x = bool(cfg.get("remote_tools.xai.sources.x", True))
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.16 11:00:00 #
9
+ # Updated Date: 2025.09.17 05:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import copy
@@ -104,6 +104,13 @@ class Patch:
104
104
  patch_css('style.dark.css', True)
105
105
  updated = True
106
106
 
107
+ # < 2.6.53
108
+ if old < parse_version("2.6.53"):
109
+ print("Migrating config from < 2.6.53...")
110
+ if "remote_tools.global.web_search" not in data:
111
+ data["remote_tools.global.web_search"] = True
112
+ updated = True
113
+
107
114
  # update file
108
115
  migrated = False
109
116
  if updated:
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.05 18:00:00 #
9
+ # Updated Date: 2025.09.16 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtCore import Qt
@@ -454,7 +454,6 @@ class Plugins:
454
454
  # cols_widget.setMaximumHeight(90)
455
455
 
456
456
  self.window.ui.nodes[desc_key] = DescLabel(txt_desc)
457
- self.window.ui.nodes[desc_key].setMaximumHeight(40)
458
457
  # self.window.ui.nodes[desc_key].setToolTip(txt_tooltip)
459
458
 
460
459
  layout = QVBoxLayout()
@@ -473,7 +472,6 @@ class Plugins:
473
472
 
474
473
  if option['type'] not in no_desc_types:
475
474
  self.window.ui.nodes[desc_key] = DescLabel(txt_desc)
476
- self.window.ui.nodes[desc_key].setMaximumHeight(40)
477
475
  # self.window.ui.nodes[desc_key].setToolTip(txt_tooltip)
478
476
  layout.addWidget(self.window.ui.nodes[desc_key])
479
477
 
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.09.17 07:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtCore import Qt
@@ -72,7 +72,7 @@ class Output:
72
72
 
73
73
  nodes['icon.audio.output'] = IconLabel(":/icons/volume.svg", window=self.window)
74
74
  nodes['icon.audio.output'].setToolTip(trans("icon.audio.output"))
75
- nodes['icon.audio.output'].clicked.connect(lambda: ctrl.plugins.toggle('audio_output'))
75
+ nodes['icon.audio.output'].clicked.connect(lambda: ctrl.plugins.toggle_audio_output()) # special case for AUDIO mode
76
76
 
77
77
  nodes['icon.audio.input'] = IconLabel(":/icons/mic.svg", window=self.window)
78
78
  nodes['icon.audio.input'].setToolTip(trans("icon.audio.input"))
@@ -86,6 +86,10 @@ class Output:
86
86
  nodes['icon.indexer'].setToolTip("Indexer")
87
87
  nodes['icon.indexer'].clicked.connect(lambda: tools.get("indexer").toggle())
88
88
 
89
+ nodes['icon.remote_tool.web'] = IconLabel(":/icons/web_on.svg", window=self.window)
90
+ nodes['icon.remote_tool.web'].setToolTip(trans("icon.remote_tool.web"))
91
+ nodes['icon.remote_tool.web'].clicked.connect(lambda: ctrl.chat.remote_tools.toggle('web_search'))
92
+
89
93
  min_policy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
90
94
 
91
95
  nodes['chat.label'] = ChatStatusLabel("")
@@ -135,6 +139,7 @@ class Output:
135
139
  nodes['anim.loading'].hide()
136
140
 
137
141
  right_bar_layout = QHBoxLayout()
142
+ right_bar_layout.addWidget(nodes['icon.remote_tool.web'])
138
143
  right_bar_layout.addWidget(nodes['icon.video.capture'])
139
144
  right_bar_layout.addWidget(nodes['icon.audio.input'])
140
145
  right_bar_layout.addWidget(nodes['icon.audio.output'])
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.05 18:00:00 #
9
+ # Updated Date: 2025.09.16 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtCore import Qt, QTimer, QRect, Signal, QUrl, QEvent
@@ -34,7 +34,6 @@ class DescLabel(BaseLabel):
34
34
  def __init__(self, text, window=None):
35
35
  super().__init__(text, window)
36
36
  self.window = window
37
- self.setMaximumHeight(80)
38
37
  self.setProperty('class', 'label-desc')
39
38
 
40
39
 
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.16 02:00:00 #
9
+ # Updated Date: 2025.09.16 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from typing import Any
@@ -35,7 +35,7 @@ class TabBody(QTabWidget):
35
35
  self.on_delete(self)
36
36
  self.delete_refs()
37
37
 
38
- def remove_widget(self, widget: QWidget) -> None:
38
+ def unwrap(self, widget: QWidget) -> None:
39
39
  """
40
40
  Remove widget from tab body
41
41
 
@@ -46,7 +46,7 @@ class TabBody(QTabWidget):
46
46
  layout.removeWidget(widget)
47
47
  self.delete_ref(widget)
48
48
 
49
- def remove_all_widgets(self) -> None:
49
+ def unwrap_all(self) -> None:
50
50
  """
51
51
  Remove all widgets from tab body
52
52
  """
@@ -55,7 +55,14 @@ class TabBody(QTabWidget):
55
55
  while layout.count():
56
56
  item = layout.takeAt(0)
57
57
  widget = item.widget()
58
- layout.removeWidget(widget)
58
+ try:
59
+ layout.removeWidget(widget)
60
+ except Exception:
61
+ pass
62
+ try:
63
+ self.delete_ref(widget)
64
+ except Exception:
65
+ pass
59
66
 
60
67
  def add_ref(self, ref: Any) -> None:
61
68
  """
@@ -157,4 +164,16 @@ class TabBody(QTabWidget):
157
164
  if self.owner is not None:
158
165
  col_idx = self.owner.column_idx
159
166
  self.window.controller.ui.tabs.on_column_focus(col_idx)
160
- return super().eventFilter(source, event)
167
+ return super().eventFilter(source, event)
168
+
169
+ def to_dict(self) -> dict:
170
+ """
171
+ Convert to dict
172
+
173
+ :return: dict
174
+ """
175
+ return {
176
+ "refs": [str(ref) for ref in self.refs], # references to widgets
177
+ "body": [str(b) for b in self.body], # body widgets
178
+ "len(layout)": self.layout().count() if self.layout() else 0,
179
+ }