agent-code 1.0.0__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.
- agent_code/__init__.py +2 -0
- agent_code/__main__.py +3 -0
- agent_code/agent.py +1542 -0
- agent_code/cli.py +69 -0
- agent_code/data/skills/filesystem/skill.md +0 -0
- agent_code/data/skills/filesystem/skill.py +133 -0
- agent_code/data/skills/public_api/skill.md +42 -0
- agent_code/data/skills/public_api/skill.py +105 -0
- agent_code/data/skills/temp_mail/skill.md +51 -0
- agent_code/data/skills/temp_mail/skill.py +716 -0
- agent_code/loader.py +217 -0
- agent_code/paths.py +54 -0
- agent_code/skill_manager.py +1171 -0
- agent_code-1.0.0.dist-info/METADATA +434 -0
- agent_code-1.0.0.dist-info/RECORD +20 -0
- agent_code-1.0.0.dist-info/WHEEL +5 -0
- agent_code-1.0.0.dist-info/entry_points.txt +2 -0
- agent_code-1.0.0.dist-info/licenses/LICENSE +21 -0
- agent_code-1.0.0.dist-info/licenses/LICENSE-GPL +674 -0
- agent_code-1.0.0.dist-info/top_level.txt +1 -0
agent_code/agent.py
ADDED
|
@@ -0,0 +1,1542 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent.py — AI agent with Stop/Continue, local + cloud chat save,
|
|
3
|
+
headless browser, sandboxed shell, skill-loaded commands, two-pane Help.
|
|
4
|
+
|
|
5
|
+
No auto-install of skills — install them yourself via skill-manager.py
|
|
6
|
+
into ./skills/ and they'll be picked up on startup.
|
|
7
|
+
|
|
8
|
+
Deps: DrissionPage, openai
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
import shlex
|
|
17
|
+
import queue
|
|
18
|
+
import threading
|
|
19
|
+
import itertools
|
|
20
|
+
import subprocess
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import tkinter as tk
|
|
26
|
+
from tkinter import ttk, messagebox
|
|
27
|
+
from tkinter import font as tkfont
|
|
28
|
+
|
|
29
|
+
from DrissionPage import ChromiumPage, ChromiumOptions
|
|
30
|
+
from openai import OpenAI
|
|
31
|
+
|
|
32
|
+
# Load the skill loader from skills/loader.py
|
|
33
|
+
from .loader import discover_skills as loader_discover
|
|
34
|
+
from .loader import load_skills as loader_load
|
|
35
|
+
from .loader import get_skill_help as loader_get_help
|
|
36
|
+
from . import paths
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ===========================================================================
|
|
40
|
+
# Paths
|
|
41
|
+
# ===========================================================================
|
|
42
|
+
|
|
43
|
+
ROOT = paths.project_dir()
|
|
44
|
+
WORKSPACE = paths.project_workspace_dir()
|
|
45
|
+
SKILLS_DIR = paths.project_skills_dir()
|
|
46
|
+
CHATS_DIR = paths.project_chats_dir()
|
|
47
|
+
CONFIG_FILE = paths.project_config_file()
|
|
48
|
+
CHAT_FILE = CHATS_DIR / "current.json"
|
|
49
|
+
|
|
50
|
+
paths.install_bundled_skills(overwrite=False)
|
|
51
|
+
os.environ["AGENT_WORKSPACE"] = str(WORKSPACE)
|
|
52
|
+
|
|
53
|
+
print(f"[sandbox] workspace = {WORKSPACE}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ===========================================================================
|
|
57
|
+
# Server URLs — primary HTTPS, IP fallback for cloud sync
|
|
58
|
+
# ===========================================================================
|
|
59
|
+
|
|
60
|
+
SKILL_SERVER_PRIMARY = "https://skills-manager.freesrv.com"
|
|
61
|
+
SKILL_SERVER_FALLBACK = "http://78.154.103.43:9074"
|
|
62
|
+
SKILL_SERVERS = [SKILL_SERVER_PRIMARY, SKILL_SERVER_FALLBACK]
|
|
63
|
+
DEFAULT_SKILL_SERVER = SKILL_SERVER_PRIMARY # kept for display
|
|
64
|
+
|
|
65
|
+
SM_CFG_FILE = Path.home() / ".skill-manager.json"
|
|
66
|
+
SKILL_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
67
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
68
|
+
"Chrome/125.0.0.0 Safari/537.36")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sm_cfg() -> dict:
|
|
72
|
+
if SM_CFG_FILE.exists():
|
|
73
|
+
try:
|
|
74
|
+
return json.loads(SM_CFG_FILE.read_text(encoding="utf-8"))
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _sm_server_list() -> list:
|
|
81
|
+
cfg = _sm_cfg()
|
|
82
|
+
servers = []
|
|
83
|
+
custom = cfg.get("server")
|
|
84
|
+
if custom:
|
|
85
|
+
servers.append(custom)
|
|
86
|
+
for s in SKILL_SERVERS:
|
|
87
|
+
if s not in servers:
|
|
88
|
+
servers.append(s)
|
|
89
|
+
return servers
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ===========================================================================
|
|
93
|
+
# Config
|
|
94
|
+
# ===========================================================================
|
|
95
|
+
|
|
96
|
+
MAX_TURNS = 30
|
|
97
|
+
SHELL_TIMEOUT = 40
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_agent_config() -> dict:
|
|
101
|
+
if CONFIG_FILE.exists():
|
|
102
|
+
try:
|
|
103
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"[config] failed to parse: {e}")
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_agent_config(cfg: dict):
|
|
110
|
+
CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def normalize_keys(raw) -> list:
|
|
114
|
+
if isinstance(raw, str):
|
|
115
|
+
raw = [raw]
|
|
116
|
+
if not raw:
|
|
117
|
+
raw = [k.strip() for k in os.getenv("OPENAI_API_KEYS", "").split(",") if k.strip()]
|
|
118
|
+
return [k.strip().strip('"').strip("'") for k in raw if k and k.strip()]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ===========================================================================
|
|
122
|
+
# LLM
|
|
123
|
+
# ===========================================================================
|
|
124
|
+
|
|
125
|
+
def make_client(api_keys: list, base_url: str):
|
|
126
|
+
if not api_keys:
|
|
127
|
+
return None
|
|
128
|
+
if len(api_keys) == 1:
|
|
129
|
+
return OpenAI(api_key=api_keys[0], base_url=base_url)
|
|
130
|
+
cycle = itertools.cycle(api_keys)
|
|
131
|
+
return OpenAI(api_key=lambda: next(cycle), base_url=base_url)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ===========================================================================
|
|
135
|
+
# Shell (sandboxed)
|
|
136
|
+
# ===========================================================================
|
|
137
|
+
|
|
138
|
+
ALLOWED_CMDS = {
|
|
139
|
+
"ls", "pwd", "cd", "cat", "head", "tail", "grep", "find", "wc", "echo",
|
|
140
|
+
"date", "whoami", "git", "python", "python3", "pip", "pip3", "node", "npm",
|
|
141
|
+
"curl", "wget", "sed", "awk", "sort", "uniq", "diff", "tree", "dir",
|
|
142
|
+
"type", "where", "mkdir", "copy", "move",
|
|
143
|
+
}
|
|
144
|
+
BLOCKED_PATTERNS = [
|
|
145
|
+
r"rm\s+-rf\s+/", r":\(\)\s*\{", r"mkfs\.", r"dd\s+if=",
|
|
146
|
+
r">\s*/dev/sd", r"format\s+[a-z]:", r"del\s+/[sq]", r"rd\s+/s",
|
|
147
|
+
r"shutdown", r"reboot", r"reg\s+delete", r"regedit",
|
|
148
|
+
r"net\s+user\s+.*\s+/add", r"runas",
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _arg_safe(arg: str) -> bool:
|
|
153
|
+
if arg.startswith(("http://", "https://")):
|
|
154
|
+
return True
|
|
155
|
+
if arg.startswith("-"):
|
|
156
|
+
return True
|
|
157
|
+
try:
|
|
158
|
+
p = Path(arg)
|
|
159
|
+
if p.is_absolute():
|
|
160
|
+
return str(p.resolve()).startswith(str(WORKSPACE))
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
if ".." in re.split(r"[\\/]", arg):
|
|
164
|
+
return False
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def shell_run(command):
|
|
169
|
+
for pat in BLOCKED_PATTERNS:
|
|
170
|
+
if re.search(pat, command, re.IGNORECASE):
|
|
171
|
+
return f"BLOCKED: pattern {pat}"
|
|
172
|
+
if re.search(r"[|&;<>`$]", command):
|
|
173
|
+
return ("BLOCKED: shell metacharacters not allowed. "
|
|
174
|
+
"Run separate commands or write a script file first.")
|
|
175
|
+
try:
|
|
176
|
+
parts = shlex.split(command, posix=False)
|
|
177
|
+
except ValueError as e:
|
|
178
|
+
return f"BLOCKED: parse error {e}"
|
|
179
|
+
if not parts:
|
|
180
|
+
return "BLOCKED: empty"
|
|
181
|
+
base = os.path.basename(parts[0].strip('"')).lower()
|
|
182
|
+
if base not in ALLOWED_CMDS:
|
|
183
|
+
return f"BLOCKED: '{base}' not allowed"
|
|
184
|
+
for arg in parts[1:]:
|
|
185
|
+
if not _arg_safe(arg):
|
|
186
|
+
return f"BLOCKED: arg escapes workspace: {arg}"
|
|
187
|
+
try:
|
|
188
|
+
r = subprocess.run(parts, capture_output=True, text=True,
|
|
189
|
+
encoding="utf-8", errors="replace",
|
|
190
|
+
timeout=SHELL_TIMEOUT, cwd=str(WORKSPACE), shell=False)
|
|
191
|
+
out = ((r.stdout or "") + (r.stderr or "")).strip()
|
|
192
|
+
return out[:4000] or "(no output)"
|
|
193
|
+
except subprocess.TimeoutExpired:
|
|
194
|
+
return f"ERROR: timeout {SHELL_TIMEOUT}s"
|
|
195
|
+
except FileNotFoundError:
|
|
196
|
+
return f"ERROR: command not found: {base}"
|
|
197
|
+
except Exception as e:
|
|
198
|
+
return f"ERROR: {e}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ===========================================================================
|
|
202
|
+
# Browser
|
|
203
|
+
# ===========================================================================
|
|
204
|
+
|
|
205
|
+
class Browser:
|
|
206
|
+
def __init__(self, headless=True):
|
|
207
|
+
opts = (ChromiumOptions()
|
|
208
|
+
.headless(headless)
|
|
209
|
+
.set_argument("--disable-blink-features=AutomationControlled")
|
|
210
|
+
.set_argument("--no-sandbox")
|
|
211
|
+
.set_argument("--disable-dev-shm-usage")
|
|
212
|
+
.set_user_data_path(str(Path(os.environ.get("TEMP", "/tmp")) / "agent-profile")))
|
|
213
|
+
self.page = ChromiumPage(opts)
|
|
214
|
+
self._lock = threading.Lock()
|
|
215
|
+
|
|
216
|
+
def self_test(self):
|
|
217
|
+
with self._lock:
|
|
218
|
+
try:
|
|
219
|
+
self.page.get("https://example.com", timeout=15)
|
|
220
|
+
return f"OK · title={self.page.title!r}"
|
|
221
|
+
except Exception as e:
|
|
222
|
+
return f"FAILED · {e}"
|
|
223
|
+
|
|
224
|
+
def navigate(self, url):
|
|
225
|
+
if not url.startswith(("http://", "https://")):
|
|
226
|
+
url = "https://" + url
|
|
227
|
+
with self._lock:
|
|
228
|
+
try:
|
|
229
|
+
self.page.get(url, timeout=30)
|
|
230
|
+
time.sleep(0.3)
|
|
231
|
+
return f"OK title={self.page.title!r} url={self.page.url}"
|
|
232
|
+
except Exception as e:
|
|
233
|
+
return f"ERROR: {e}"
|
|
234
|
+
|
|
235
|
+
def snapshot(self, max_chars=6000):
|
|
236
|
+
with self._lock:
|
|
237
|
+
try:
|
|
238
|
+
body = self.page.ele("tag:body", timeout=5)
|
|
239
|
+
text = body.text if body else self.page.html
|
|
240
|
+
return (text or "(empty)")[:max_chars]
|
|
241
|
+
except Exception as e:
|
|
242
|
+
return f"ERROR: {e}"
|
|
243
|
+
|
|
244
|
+
def links(self, limit=30):
|
|
245
|
+
with self._lock:
|
|
246
|
+
try:
|
|
247
|
+
out = []
|
|
248
|
+
for a in self.page.eles("tag:a")[:limit]:
|
|
249
|
+
t = (a.text or "").strip()[:80]
|
|
250
|
+
h = a.attr("href") or ""
|
|
251
|
+
if t and h:
|
|
252
|
+
out.append(f"{t} -> {h}")
|
|
253
|
+
return "\n".join(out) or "(no links)"
|
|
254
|
+
except Exception as e:
|
|
255
|
+
return f"ERROR: {e}"
|
|
256
|
+
|
|
257
|
+
def click(self, selector):
|
|
258
|
+
with self._lock:
|
|
259
|
+
try:
|
|
260
|
+
ele = self.page.ele(selector, timeout=5)
|
|
261
|
+
if not ele:
|
|
262
|
+
return f"NOT_FOUND: {selector}"
|
|
263
|
+
ele.click()
|
|
264
|
+
time.sleep(0.3)
|
|
265
|
+
return "OK"
|
|
266
|
+
except Exception as e:
|
|
267
|
+
return f"ERROR: {e}"
|
|
268
|
+
|
|
269
|
+
def type_text(self, selector, text, clear=True):
|
|
270
|
+
with self._lock:
|
|
271
|
+
try:
|
|
272
|
+
ele = self.page.ele(selector, timeout=5)
|
|
273
|
+
if not ele:
|
|
274
|
+
return f"NOT_FOUND: {selector}"
|
|
275
|
+
if clear:
|
|
276
|
+
try:
|
|
277
|
+
ele.clear()
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
ele.input(text)
|
|
281
|
+
return "OK"
|
|
282
|
+
except Exception as e:
|
|
283
|
+
return f"ERROR: {e}"
|
|
284
|
+
|
|
285
|
+
def scroll(self, pixels=800):
|
|
286
|
+
with self._lock:
|
|
287
|
+
try:
|
|
288
|
+
self.page.scroll.down(int(pixels))
|
|
289
|
+
return f"OK scrolled {pixels}px"
|
|
290
|
+
except Exception as e:
|
|
291
|
+
return f"ERROR: {e}"
|
|
292
|
+
|
|
293
|
+
def close(self):
|
|
294
|
+
try:
|
|
295
|
+
self.page.quit()
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ===========================================================================
|
|
301
|
+
# Core tools + prompt
|
|
302
|
+
# ===========================================================================
|
|
303
|
+
|
|
304
|
+
CORE_SCHEMAS = [
|
|
305
|
+
{"type": "function", "function": {
|
|
306
|
+
"name": "browser_navigate",
|
|
307
|
+
"description": "Open a URL in the headless browser. Faster than shell curl.",
|
|
308
|
+
"parameters": {"type": "object", "properties": {"url": {"type": "string"}},
|
|
309
|
+
"required": ["url"]}}},
|
|
310
|
+
{"type": "function", "function": {
|
|
311
|
+
"name": "browser_snapshot",
|
|
312
|
+
"description": "Return visible text of the current page.",
|
|
313
|
+
"parameters": {"type": "object", "properties": {}}}},
|
|
314
|
+
{"type": "function", "function": {
|
|
315
|
+
"name": "browser_links",
|
|
316
|
+
"description": "List clickable links (text -> href).",
|
|
317
|
+
"parameters": {"type": "object", "properties": {"limit": {"type": "integer"}}}}},
|
|
318
|
+
{"type": "function", "function": {
|
|
319
|
+
"name": "browser_click",
|
|
320
|
+
"description": "Click an element by CSS selector.",
|
|
321
|
+
"parameters": {"type": "object", "properties": {"selector": {"type": "string"}},
|
|
322
|
+
"required": ["selector"]}}},
|
|
323
|
+
{"type": "function", "function": {
|
|
324
|
+
"name": "browser_type",
|
|
325
|
+
"description": "Type text into an input by CSS selector.",
|
|
326
|
+
"parameters": {"type": "object",
|
|
327
|
+
"properties": {"selector": {"type": "string"}, "text": {"type": "string"}},
|
|
328
|
+
"required": ["selector", "text"]}}},
|
|
329
|
+
{"type": "function", "function": {
|
|
330
|
+
"name": "browser_scroll",
|
|
331
|
+
"description": "Scroll the page down by N pixels.",
|
|
332
|
+
"parameters": {"type": "object", "properties": {"pixels": {"type": "integer"}}}}},
|
|
333
|
+
{"type": "function", "function": {
|
|
334
|
+
"name": "shell_run",
|
|
335
|
+
"description": "Run an allowlisted local command inside the sandbox (CWD=./workspace/).",
|
|
336
|
+
"parameters": {"type": "object", "properties": {"command": {"type": "string"}},
|
|
337
|
+
"required": ["command"]}}},
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
CORE_PROMPT = f"""You are an autonomous agent with:
|
|
341
|
+
|
|
342
|
+
A) BROWSER (headless Chromium):
|
|
343
|
+
browser_navigate, browser_snapshot, browser_links, browser_click,
|
|
344
|
+
browser_type, browser_scroll
|
|
345
|
+
B) SHELL (sandboxed to {WORKSPACE}):
|
|
346
|
+
shell_run
|
|
347
|
+
C) SKILLS (auto-loaded, listed below)
|
|
348
|
+
|
|
349
|
+
TOOL SELECTION:
|
|
350
|
+
- Web tasks → BROWSER FIRST.
|
|
351
|
+
- shell_run("curl ...") for raw JSON/XML only. If it returns empty/403/HTML,
|
|
352
|
+
switch to browser_navigate.
|
|
353
|
+
|
|
354
|
+
SANDBOX:
|
|
355
|
+
- Workspace: {WORKSPACE}
|
|
356
|
+
- File paths are RELATIVE to it.
|
|
357
|
+
- shell_run cannot leave the workspace. No pipes, redirection, ;, &, &&.
|
|
358
|
+
|
|
359
|
+
ENVIRONMENT: Windows CMD. No heredocs. Multi-line Python → write .py file then run.
|
|
360
|
+
|
|
361
|
+
FILE CREATION:
|
|
362
|
+
- Large files: write_file first chunk, append_file the rest.
|
|
363
|
+
- NEVER answer with "hang tight" / "working on it". Either call a tool or
|
|
364
|
+
return the finished result.
|
|
365
|
+
|
|
366
|
+
WORKFLOW:
|
|
367
|
+
1. Plan briefly, then act with tools.
|
|
368
|
+
2. After browser_navigate/click, snapshot to verify.
|
|
369
|
+
3. Login/CAPTCHA/2FA → STOP and ask.
|
|
370
|
+
4. When done, summarize and give the file path.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ===========================================================================
|
|
375
|
+
# Theme
|
|
376
|
+
# ===========================================================================
|
|
377
|
+
|
|
378
|
+
BG = "#0e1117"
|
|
379
|
+
BG_PANEL = "#161b22"
|
|
380
|
+
BG_CARD = "#1c2128"
|
|
381
|
+
BG_INPUT = "#1c2128"
|
|
382
|
+
BG_HOVER = "#21262d"
|
|
383
|
+
FG = "#c9d1d9"
|
|
384
|
+
FG_DIM = "#8b949e"
|
|
385
|
+
ACCENT = "#58a6ff"
|
|
386
|
+
GREEN = "#3fb950"
|
|
387
|
+
YELLOW = "#d29922"
|
|
388
|
+
RED = "#f85149"
|
|
389
|
+
PURPLE = "#bc8cff"
|
|
390
|
+
BORDER = "#30363d"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def apply_theme(root):
|
|
394
|
+
style = ttk.Style(root)
|
|
395
|
+
try:
|
|
396
|
+
style.theme_use("clam")
|
|
397
|
+
except tk.TclError:
|
|
398
|
+
pass
|
|
399
|
+
root.configure(bg=BG)
|
|
400
|
+
style.configure("TFrame", background=BG)
|
|
401
|
+
style.configure("Panel.TFrame", background=BG_PANEL)
|
|
402
|
+
style.configure("TLabel", background=BG, foreground=FG)
|
|
403
|
+
style.configure("Panel.TLabel", background=BG_PANEL, foreground=FG)
|
|
404
|
+
style.configure("Dim.TLabel", background=BG, foreground=FG_DIM)
|
|
405
|
+
style.configure("PanelDim.TLabel", background=BG_PANEL, foreground=FG_DIM)
|
|
406
|
+
style.configure("Header.TLabel", background=BG_PANEL,
|
|
407
|
+
foreground=ACCENT, font=("Segoe UI", 12, "bold"))
|
|
408
|
+
style.configure("Status.TLabel", background=BG_PANEL,
|
|
409
|
+
foreground=FG_DIM, font=("Segoe UI", 9))
|
|
410
|
+
style.configure("TEntry", fieldbackground=BG_INPUT, foreground=FG,
|
|
411
|
+
insertcolor=FG, bordercolor=BORDER,
|
|
412
|
+
lightcolor=BORDER, darkcolor=BORDER, padding=8)
|
|
413
|
+
style.configure("TButton", background=BG_CARD, foreground=FG,
|
|
414
|
+
borderwidth=1, focusthickness=0, padding=(10, 6),
|
|
415
|
+
font=("Segoe UI", 9))
|
|
416
|
+
style.map("TButton",
|
|
417
|
+
background=[("active", BG_HOVER), ("disabled", BG_CARD)],
|
|
418
|
+
foreground=[("disabled", FG_DIM)])
|
|
419
|
+
style.configure("Send.TButton", background=ACCENT, foreground="#ffffff",
|
|
420
|
+
borderwidth=0, focusthickness=0, padding=(16, 8),
|
|
421
|
+
font=("Segoe UI", 10, "bold"))
|
|
422
|
+
style.map("Send.TButton",
|
|
423
|
+
background=[("active", "#79b8ff"), ("disabled", "#30363d")],
|
|
424
|
+
foreground=[("disabled", FG_DIM)])
|
|
425
|
+
style.configure("Stop.TButton", background="#8b1a1a", foreground="#ffffff",
|
|
426
|
+
borderwidth=0, focusthickness=0, padding=(16, 8),
|
|
427
|
+
font=("Segoe UI", 10, "bold"))
|
|
428
|
+
style.map("Stop.TButton",
|
|
429
|
+
background=[("active", "#b02121"), ("disabled", "#30363d")],
|
|
430
|
+
foreground=[("disabled", FG_DIM)])
|
|
431
|
+
style.configure("Accent.TButton", background=ACCENT, foreground="#ffffff",
|
|
432
|
+
borderwidth=0, focusthickness=0, padding=(12, 6),
|
|
433
|
+
font=("Segoe UI", 9, "bold"))
|
|
434
|
+
style.map("Accent.TButton",
|
|
435
|
+
background=[("active", "#79b8ff"), ("disabled", "#30363d")],
|
|
436
|
+
foreground=[("disabled", FG_DIM)])
|
|
437
|
+
style.configure("Vertical.TScrollbar", background=BG_PANEL,
|
|
438
|
+
troughcolor=BG, bordercolor=BG, arrowcolor=FG_DIM)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ===========================================================================
|
|
442
|
+
# Settings dialog
|
|
443
|
+
# ===========================================================================
|
|
444
|
+
|
|
445
|
+
class SettingsDialog(tk.Toplevel):
|
|
446
|
+
def __init__(self, parent, app):
|
|
447
|
+
super().__init__(parent)
|
|
448
|
+
self.app = app
|
|
449
|
+
self.title("Settings")
|
|
450
|
+
self.configure(bg=BG)
|
|
451
|
+
self.geometry("580x620")
|
|
452
|
+
self.resizable(False, False)
|
|
453
|
+
self.transient(parent)
|
|
454
|
+
self.grab_set()
|
|
455
|
+
apply_theme(self)
|
|
456
|
+
|
|
457
|
+
pad = ttk.Frame(self, style="Panel.TFrame")
|
|
458
|
+
pad.pack(fill="both", expand=True)
|
|
459
|
+
ttk.Label(pad, text="SETTINGS", style="Header.TLabel").pack(
|
|
460
|
+
anchor="w", padx=24, pady=(20, 4))
|
|
461
|
+
ttk.Label(pad, text=str(CONFIG_FILE), style="PanelDim.TLabel").pack(
|
|
462
|
+
anchor="w", padx=24, pady=(0, 14))
|
|
463
|
+
|
|
464
|
+
body = tk.Frame(pad, bg=BG_PANEL)
|
|
465
|
+
body.pack(fill="both", expand=True, padx=24)
|
|
466
|
+
|
|
467
|
+
ttk.Label(body, text="LLM", style="PanelDim.TLabel").pack(anchor="w")
|
|
468
|
+
ttk.Separator(body, orient="horizontal").pack(fill="x", pady=(2, 10))
|
|
469
|
+
|
|
470
|
+
ttk.Label(body, text="API keys (one per line)", style="Panel.TLabel").pack(anchor="w")
|
|
471
|
+
keys_frame = tk.Frame(body, bg=BG_PANEL)
|
|
472
|
+
keys_frame.pack(fill="x", pady=(2, 10))
|
|
473
|
+
self.keys_text = tk.Text(keys_frame, height=3, bg=BG_INPUT, fg=FG,
|
|
474
|
+
insertbackground=FG, relief="flat",
|
|
475
|
+
padx=8, pady=6, font=("Consolas", 10), wrap="none")
|
|
476
|
+
self.keys_text.pack(side="left", fill="x", expand=True)
|
|
477
|
+
ks = ttk.Scrollbar(keys_frame, orient="vertical", command=self.keys_text.yview)
|
|
478
|
+
ks.pack(side="right", fill="y")
|
|
479
|
+
self.keys_text.configure(yscrollcommand=ks.set)
|
|
480
|
+
for k in app.api_keys:
|
|
481
|
+
self.keys_text.insert("end", k + "\n")
|
|
482
|
+
|
|
483
|
+
ttk.Label(body, text="Base URL", style="Panel.TLabel").pack(anchor="w")
|
|
484
|
+
self.base_entry = ttk.Entry(body, font=("Segoe UI", 11))
|
|
485
|
+
self.base_entry.insert(0, app.base_url)
|
|
486
|
+
self.base_entry.pack(fill="x", pady=(2, 10))
|
|
487
|
+
|
|
488
|
+
ttk.Label(body, text="Model", style="Panel.TLabel").pack(anchor="w")
|
|
489
|
+
self.model_entry = ttk.Entry(body, font=("Segoe UI", 11))
|
|
490
|
+
self.model_entry.insert(0, app.model)
|
|
491
|
+
self.model_entry.pack(fill="x", pady=(2, 18))
|
|
492
|
+
|
|
493
|
+
ttk.Label(body, text="CLOUD SYNC", style="PanelDim.TLabel").pack(anchor="w")
|
|
494
|
+
ttk.Separator(body, orient="horizontal").pack(fill="x", pady=(2, 10))
|
|
495
|
+
self.cloud_var = tk.BooleanVar(value=app.cloud_sync)
|
|
496
|
+
ttk.Checkbutton(body, text="Sync chat state to skill-manager server",
|
|
497
|
+
variable=self.cloud_var).pack(anchor="w", pady=(0, 6))
|
|
498
|
+
ttk.Label(body, text="servers:",
|
|
499
|
+
style="PanelDim.TLabel").pack(anchor="w", pady=(0, 2))
|
|
500
|
+
for s in app.cloud_servers:
|
|
501
|
+
ttk.Label(body, text=f" {s}",
|
|
502
|
+
style="PanelDim.TLabel").pack(anchor="w")
|
|
503
|
+
ttk.Label(body, text=f"account: {app.cloud_user or '(not signed in)'}",
|
|
504
|
+
style="PanelDim.TLabel").pack(anchor="w", pady=(4, 0))
|
|
505
|
+
|
|
506
|
+
row = tk.Frame(pad, bg=BG_PANEL)
|
|
507
|
+
row.pack(fill="x", padx=24, pady=(14, 20))
|
|
508
|
+
ttk.Button(row, text="Cancel", command=self.destroy).pack(side="right")
|
|
509
|
+
ttk.Button(row, text="Save", style="Accent.TButton",
|
|
510
|
+
command=self._save).pack(side="right", padx=(0, 8))
|
|
511
|
+
|
|
512
|
+
def _save(self):
|
|
513
|
+
keys = [l.strip() for l in self.keys_text.get("1.0", "end").splitlines() if l.strip()]
|
|
514
|
+
base = self.base_entry.get().strip() or "https://api.openai.com/v1"
|
|
515
|
+
model = self.model_entry.get().strip() or "gpt-4o"
|
|
516
|
+
cloud = bool(self.cloud_var.get())
|
|
517
|
+
if not keys:
|
|
518
|
+
messagebox.showerror("Settings", "At least one API key required", parent=self)
|
|
519
|
+
return
|
|
520
|
+
cfg = load_agent_config()
|
|
521
|
+
cfg["openai_api_keys"] = keys
|
|
522
|
+
cfg["openai_base_url"] = base
|
|
523
|
+
cfg["openai_model"] = model
|
|
524
|
+
cfg["cloud_sync"] = cloud
|
|
525
|
+
cfg.setdefault("headless", True)
|
|
526
|
+
cfg.setdefault("disabled_skills", [])
|
|
527
|
+
try:
|
|
528
|
+
save_agent_config(cfg)
|
|
529
|
+
except Exception as e:
|
|
530
|
+
messagebox.showerror("Settings", f"save failed: {e}", parent=self)
|
|
531
|
+
return
|
|
532
|
+
self.app.api_keys = keys
|
|
533
|
+
self.app.base_url = base
|
|
534
|
+
self.app.model = model
|
|
535
|
+
self.app.cloud_sync = cloud
|
|
536
|
+
self.app.client = make_client(keys, base)
|
|
537
|
+
self.app._update_header()
|
|
538
|
+
self.destroy()
|
|
539
|
+
self.app.log_line(f"[settings] saved — model={model} cloud={cloud}", "dim")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ===========================================================================
|
|
543
|
+
# Skills dialog
|
|
544
|
+
# ===========================================================================
|
|
545
|
+
|
|
546
|
+
class SkillsDialog(tk.Toplevel):
|
|
547
|
+
def __init__(self, parent, app):
|
|
548
|
+
super().__init__(parent)
|
|
549
|
+
self.app = app
|
|
550
|
+
self.title("Skills")
|
|
551
|
+
self.configure(bg=BG)
|
|
552
|
+
self.geometry("640x540")
|
|
553
|
+
self.resizable(True, True)
|
|
554
|
+
self.transient(parent)
|
|
555
|
+
self.grab_set()
|
|
556
|
+
apply_theme(self)
|
|
557
|
+
|
|
558
|
+
pad = ttk.Frame(self, style="Panel.TFrame")
|
|
559
|
+
pad.pack(fill="both", expand=True)
|
|
560
|
+
ttk.Label(pad, text="SKILLS", style="Header.TLabel").pack(
|
|
561
|
+
anchor="w", padx=24, pady=(20, 4))
|
|
562
|
+
ttk.Label(pad, text="Toggle skills on or off. Changes save immediately.",
|
|
563
|
+
style="PanelDim.TLabel").pack(anchor="w", padx=24, pady=(0, 16))
|
|
564
|
+
|
|
565
|
+
list_frame = tk.Frame(pad, bg=BG_PANEL)
|
|
566
|
+
list_frame.pack(fill="both", expand=True, padx=24)
|
|
567
|
+
self.listbox = tk.Listbox(list_frame, bg=BG_CARD, fg=FG,
|
|
568
|
+
selectbackground=ACCENT, selectforeground="#ffffff",
|
|
569
|
+
relief="flat", font=("Consolas", 10),
|
|
570
|
+
activestyle="none", borderwidth=0,
|
|
571
|
+
highlightthickness=0, height=10)
|
|
572
|
+
self.listbox.pack(side="left", fill="both", expand=True)
|
|
573
|
+
vsb = ttk.Scrollbar(list_frame, orient="vertical", command=self.listbox.yview)
|
|
574
|
+
self.listbox.configure(yscrollcommand=vsb.set)
|
|
575
|
+
vsb.pack(side="right", fill="y")
|
|
576
|
+
self.listbox.bind("<<ListboxSelect>>", self._on_select)
|
|
577
|
+
self.listbox.bind("<Double-1>", lambda e: self._toggle_selected())
|
|
578
|
+
|
|
579
|
+
desc_frame = tk.Frame(pad, bg=BG_PANEL)
|
|
580
|
+
desc_frame.pack(fill="x", padx=24, pady=(12, 6))
|
|
581
|
+
ttk.Label(desc_frame, text="Description", style="PanelDim.TLabel").pack(anchor="w")
|
|
582
|
+
self.desc = tk.Text(desc_frame, height=4, bg=BG_CARD, fg=FG,
|
|
583
|
+
relief="flat", padx=8, pady=6,
|
|
584
|
+
font=("Segoe UI", 9), wrap="word")
|
|
585
|
+
self.desc.pack(fill="x")
|
|
586
|
+
self.desc.configure(state="disabled")
|
|
587
|
+
|
|
588
|
+
row = tk.Frame(pad, bg=BG_PANEL)
|
|
589
|
+
row.pack(fill="x", padx=24, pady=(12, 6))
|
|
590
|
+
ttk.Button(row, text="Toggle", style="Accent.TButton",
|
|
591
|
+
command=self._toggle_selected).pack(side="left")
|
|
592
|
+
ttk.Button(row, text="Enable all", command=self._enable_all).pack(side="left", padx=(6, 0))
|
|
593
|
+
ttk.Button(row, text="Disable all", command=self._disable_all).pack(side="left", padx=(6, 0))
|
|
594
|
+
ttk.Button(row, text="Close", command=self.destroy).pack(side="right")
|
|
595
|
+
|
|
596
|
+
self.status = ttk.Label(pad, text="", style="PanelDim.TLabel")
|
|
597
|
+
self.status.pack(anchor="w", padx=24, pady=(0, 20))
|
|
598
|
+
|
|
599
|
+
self.skills = []
|
|
600
|
+
self._refresh()
|
|
601
|
+
|
|
602
|
+
def _refresh(self):
|
|
603
|
+
self.listbox.delete(0, "end")
|
|
604
|
+
self.skills = loader_discover(SKILLS_DIR)
|
|
605
|
+
for s in self.skills:
|
|
606
|
+
name = s["name"]
|
|
607
|
+
enabled = name not in self.app.disabled_skills
|
|
608
|
+
mark = "[✓]" if enabled else "[ ]"
|
|
609
|
+
self.listbox.insert("end", f" {mark} {name:<24} v{s.get('version','?')}")
|
|
610
|
+
if not enabled:
|
|
611
|
+
self.listbox.itemconfig("end", foreground=FG_DIM)
|
|
612
|
+
self.status.configure(
|
|
613
|
+
text=f"{len(self.skills)} skill(s) found · {len(self.app.disabled_skills)} disabled")
|
|
614
|
+
|
|
615
|
+
def _selected_name(self):
|
|
616
|
+
sel = self.listbox.curselection()
|
|
617
|
+
if not sel:
|
|
618
|
+
return None
|
|
619
|
+
return self.skills[sel[0]]["name"]
|
|
620
|
+
|
|
621
|
+
def _on_select(self, event):
|
|
622
|
+
name = self._selected_name()
|
|
623
|
+
if not name:
|
|
624
|
+
return
|
|
625
|
+
meta = next((s for s in self.skills if s["name"] == name), None)
|
|
626
|
+
if not meta:
|
|
627
|
+
return
|
|
628
|
+
self.desc.configure(state="normal")
|
|
629
|
+
self.desc.delete("1.0", "end")
|
|
630
|
+
self.desc.insert("1.0", meta.get("description", "(no description)") +
|
|
631
|
+
f"\n\nauthor: {meta.get('author', '?')}")
|
|
632
|
+
self.desc.configure(state="disabled")
|
|
633
|
+
|
|
634
|
+
def _toggle_selected(self):
|
|
635
|
+
name = self._selected_name()
|
|
636
|
+
if not name:
|
|
637
|
+
return
|
|
638
|
+
if name in self.app.disabled_skills:
|
|
639
|
+
self.app.disabled_skills.remove(name)
|
|
640
|
+
else:
|
|
641
|
+
self.app.disabled_skills.append(name)
|
|
642
|
+
self.app._save_disabled_skills()
|
|
643
|
+
self.app._reload_tools()
|
|
644
|
+
self._refresh()
|
|
645
|
+
|
|
646
|
+
def _enable_all(self):
|
|
647
|
+
self.app.disabled_skills = []
|
|
648
|
+
self.app._save_disabled_skills()
|
|
649
|
+
self.app._reload_tools()
|
|
650
|
+
self._refresh()
|
|
651
|
+
|
|
652
|
+
def _disable_all(self):
|
|
653
|
+
self.app.disabled_skills = [s["name"] for s in self.skills]
|
|
654
|
+
self.app._save_disabled_skills()
|
|
655
|
+
self.app._reload_tools()
|
|
656
|
+
self._refresh()
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# ===========================================================================
|
|
660
|
+
# Help dialog — two-pane with clickable skills
|
|
661
|
+
# ===========================================================================
|
|
662
|
+
|
|
663
|
+
class HelpDialog(tk.Toplevel):
|
|
664
|
+
"""Two-pane help browser. Left: sections + skills. Right: content."""
|
|
665
|
+
|
|
666
|
+
def __init__(self, parent, app):
|
|
667
|
+
super().__init__(parent)
|
|
668
|
+
self.app = app
|
|
669
|
+
self.title("Help")
|
|
670
|
+
self.configure(bg=BG)
|
|
671
|
+
self.geometry("1020x720")
|
|
672
|
+
self.minsize(820, 540)
|
|
673
|
+
self.transient(parent)
|
|
674
|
+
apply_theme(self)
|
|
675
|
+
|
|
676
|
+
pad = ttk.Frame(self, style="Panel.TFrame")
|
|
677
|
+
pad.pack(fill="both", expand=True)
|
|
678
|
+
|
|
679
|
+
# header
|
|
680
|
+
header = tk.Frame(pad, bg=BG_PANEL)
|
|
681
|
+
header.pack(fill="x", padx=20, pady=(16, 10))
|
|
682
|
+
ttk.Label(header, text="HELP", style="Header.TLabel").pack(side="left")
|
|
683
|
+
ttk.Label(header, text="click a section or a skill",
|
|
684
|
+
style="PanelDim.TLabel").pack(side="left", padx=(12, 0))
|
|
685
|
+
|
|
686
|
+
# split body
|
|
687
|
+
body = tk.Frame(pad, bg=BG_PANEL)
|
|
688
|
+
body.pack(fill="both", expand=True, padx=20, pady=(0, 12))
|
|
689
|
+
|
|
690
|
+
# ---- LEFT: tree ----
|
|
691
|
+
left_wrap = tk.Frame(body, bg=BG_CARD, width=250,
|
|
692
|
+
highlightbackground=BORDER, highlightthickness=1)
|
|
693
|
+
left_wrap.pack(side="left", fill="y")
|
|
694
|
+
left_wrap.pack_propagate(False)
|
|
695
|
+
|
|
696
|
+
style = ttk.Style(self)
|
|
697
|
+
style.configure("Help.Treeview",
|
|
698
|
+
background=BG_CARD, fieldbackground=BG_CARD,
|
|
699
|
+
foreground=FG, bordercolor=BORDER,
|
|
700
|
+
rowheight=26, font=("Segoe UI", 10))
|
|
701
|
+
style.map("Help.Treeview",
|
|
702
|
+
background=[("selected", ACCENT)],
|
|
703
|
+
foreground=[("selected", "#ffffff")])
|
|
704
|
+
|
|
705
|
+
self.tree = ttk.Treeview(left_wrap, show="tree",
|
|
706
|
+
selectmode="browse", style="Help.Treeview")
|
|
707
|
+
self.tree.column("#0", width=230, stretch=True)
|
|
708
|
+
self.tree.tag_configure("group",
|
|
709
|
+
foreground=ACCENT,
|
|
710
|
+
font=("Segoe UI", 9, "bold"))
|
|
711
|
+
self.tree.tag_configure("item",
|
|
712
|
+
foreground=FG,
|
|
713
|
+
font=("Segoe UI", 10))
|
|
714
|
+
|
|
715
|
+
vsb = ttk.Scrollbar(left_wrap, orient="vertical", command=self.tree.yview)
|
|
716
|
+
self.tree.configure(yscrollcommand=vsb.set)
|
|
717
|
+
self.tree.pack(side="left", fill="both", expand=True)
|
|
718
|
+
vsb.pack(side="right", fill="y")
|
|
719
|
+
|
|
720
|
+
# ---- RIGHT: content ----
|
|
721
|
+
right_wrap = tk.Frame(body, bg=BG_CARD,
|
|
722
|
+
highlightbackground=BORDER, highlightthickness=1)
|
|
723
|
+
right_wrap.pack(side="left", fill="both", expand=True, padx=(10, 0))
|
|
724
|
+
|
|
725
|
+
self.content = tk.Text(right_wrap, wrap="word", bg=BG_CARD, fg=FG,
|
|
726
|
+
relief="flat", padx=18, pady=14,
|
|
727
|
+
font=("Consolas", 10), borderwidth=0,
|
|
728
|
+
spacing1=2, spacing3=5)
|
|
729
|
+
self.content.pack(side="left", fill="both", expand=True)
|
|
730
|
+
csb = ttk.Scrollbar(right_wrap, orient="vertical",
|
|
731
|
+
command=self.content.yview)
|
|
732
|
+
self.content.configure(yscrollcommand=csb.set, state="disabled")
|
|
733
|
+
csb.pack(side="right", fill="y")
|
|
734
|
+
|
|
735
|
+
# ---- footer ----
|
|
736
|
+
footer = tk.Frame(pad, bg=BG_PANEL)
|
|
737
|
+
footer.pack(fill="x", padx=20, pady=(0, 16))
|
|
738
|
+
ttk.Button(footer, text="Close", style="Accent.TButton",
|
|
739
|
+
command=self.destroy).pack(side="right")
|
|
740
|
+
|
|
741
|
+
# build content
|
|
742
|
+
self._sections = self._build_sections()
|
|
743
|
+
self._populate_tree()
|
|
744
|
+
|
|
745
|
+
self.tree.bind("<<TreeviewSelect>>", self._on_select)
|
|
746
|
+
self.tree.selection_set("sec:quick_start")
|
|
747
|
+
self._show_section("quick_start")
|
|
748
|
+
|
|
749
|
+
# ---- content builders ----
|
|
750
|
+
|
|
751
|
+
def _build_sections(self) -> dict:
|
|
752
|
+
import getpass
|
|
753
|
+
import platform
|
|
754
|
+
|
|
755
|
+
try:
|
|
756
|
+
username = getpass.getuser()
|
|
757
|
+
except Exception:
|
|
758
|
+
username = (os.environ.get("USERNAME")
|
|
759
|
+
or os.environ.get("USER")
|
|
760
|
+
or "user")
|
|
761
|
+
|
|
762
|
+
platform_str = f"{platform.system()} {platform.release()}"
|
|
763
|
+
py_ver = (f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
764
|
+
f".{sys.version_info.micro}")
|
|
765
|
+
|
|
766
|
+
workspace_p = str(WORKSPACE)
|
|
767
|
+
chat_p = str(CHAT_FILE)
|
|
768
|
+
config_p = str(CONFIG_FILE)
|
|
769
|
+
skills_p = str(SKILLS_DIR)
|
|
770
|
+
root_p = str(ROOT)
|
|
771
|
+
|
|
772
|
+
cmd_lines = []
|
|
773
|
+
for cname, cdef in sorted(self.app.skill_commands.items()):
|
|
774
|
+
cmd_lines.append(f" /{cname:<14} {cdef.get('description', '')}")
|
|
775
|
+
skill_cmds = "\n".join(cmd_lines) if cmd_lines else " (none)"
|
|
776
|
+
|
|
777
|
+
skill_lines = []
|
|
778
|
+
for s in self.app.skill_meta:
|
|
779
|
+
n = s.get("name", "?")
|
|
780
|
+
d = (s.get("description", "") or "")[:70]
|
|
781
|
+
skill_lines.append(f" {n:<18} {d}")
|
|
782
|
+
skills_list = "\n".join(skill_lines) if skill_lines else " (none loaded)"
|
|
783
|
+
|
|
784
|
+
servers_str = "\n".join(f" {s}" for s in self.app.cloud_servers)
|
|
785
|
+
|
|
786
|
+
sections = {}
|
|
787
|
+
|
|
788
|
+
sections["quick_start"] = ("Quick Start", f"""Type a task in the input box and press Enter.
|
|
789
|
+
The agent thinks, calls tools, and returns an answer.
|
|
790
|
+
|
|
791
|
+
Buttons
|
|
792
|
+
Send start a new task
|
|
793
|
+
Stop halt the running agent
|
|
794
|
+
Continue resume from the last tool result
|
|
795
|
+
Save save chat locally + cloud (if enabled)
|
|
796
|
+
Settings edit API key, base URL, model, cloud sync
|
|
797
|
+
Skills toggle installed skills on/off
|
|
798
|
+
Help this window
|
|
799
|
+
|
|
800
|
+
First run
|
|
801
|
+
The agent does NOT auto-install skills. Use skill-manager.py
|
|
802
|
+
to install what you want, then restart the agent.
|
|
803
|
+
""")
|
|
804
|
+
|
|
805
|
+
sections["commands"] = ("Commands", f"""Built-in (always available):
|
|
806
|
+
|
|
807
|
+
/reset clear conversation memory
|
|
808
|
+
/history print recent messages
|
|
809
|
+
/skills list loaded + disabled skills + commands
|
|
810
|
+
/save force save chat (local + cloud)
|
|
811
|
+
/open <file> open a file from the workspace
|
|
812
|
+
e.g. /open gta.html
|
|
813
|
+
|
|
814
|
+
Skill commands (loaded from skills):
|
|
815
|
+
|
|
816
|
+
{skill_cmds}
|
|
817
|
+
|
|
818
|
+
Type /skills in the chat to see this list live.
|
|
819
|
+
""")
|
|
820
|
+
|
|
821
|
+
sections["skills"] = ("Skills", f"""Skills are folders under:
|
|
822
|
+
|
|
823
|
+
{skills_p}
|
|
824
|
+
|
|
825
|
+
Each skill is a folder containing skill.py (code) and skill.md (docs).
|
|
826
|
+
They add tools the LLM can call and /commands you can type.
|
|
827
|
+
|
|
828
|
+
Loaded in this session:
|
|
829
|
+
|
|
830
|
+
{skills_list}
|
|
831
|
+
|
|
832
|
+
Install new skills:
|
|
833
|
+
1. python skill-manager.py
|
|
834
|
+
2. Sign in, browse, click Install
|
|
835
|
+
3. Restart this agent
|
|
836
|
+
|
|
837
|
+
Toggle skills on/off with the Skills button. Changes save
|
|
838
|
+
immediately to agent_config.json.
|
|
839
|
+
|
|
840
|
+
To write your own skill, open docs/skills.html in a browser.
|
|
841
|
+
Click a skill in the left panel to see its own help page.
|
|
842
|
+
""")
|
|
843
|
+
|
|
844
|
+
sections["workspace"] = ("Workspace & Sandbox", f"""Everything the agent creates goes here:
|
|
845
|
+
|
|
846
|
+
{workspace_p}
|
|
847
|
+
|
|
848
|
+
Shell rules
|
|
849
|
+
• Commands must be in the allowlist (ls, cat, git, python, curl, …)
|
|
850
|
+
• CWD is forced to the workspace
|
|
851
|
+
• Absolute paths outside it are rejected
|
|
852
|
+
• No pipes, redirection, ;, &, &&
|
|
853
|
+
|
|
854
|
+
If the agent tries something blocked, you'll see:
|
|
855
|
+
|
|
856
|
+
BLOCKED: <reason>
|
|
857
|
+
|
|
858
|
+
To let it use a new command, add it to ALLOWED_CMDS in agent.py.
|
|
859
|
+
""")
|
|
860
|
+
|
|
861
|
+
sections["cloud"] = ("Chat Save & Cloud", f"""Local save
|
|
862
|
+
{chat_p}
|
|
863
|
+
|
|
864
|
+
Auto-saved after every turn and on window close.
|
|
865
|
+
Loaded on startup if it exists.
|
|
866
|
+
|
|
867
|
+
Cloud sync (optional)
|
|
868
|
+
Servers tried in order:
|
|
869
|
+
{servers_str}
|
|
870
|
+
|
|
871
|
+
account: {self.app.cloud_user or "(not signed in)"}
|
|
872
|
+
|
|
873
|
+
Enable in Settings → CLOUD SYNC. Uses the same account as
|
|
874
|
+
skill-manager.py. Local file wins if both exist — cloud is
|
|
875
|
+
loaded on startup only if the local file is missing.
|
|
876
|
+
""")
|
|
877
|
+
|
|
878
|
+
sections["troubleshooting"] = ("Troubleshooting", """browser: FAILED
|
|
879
|
+
Install WebView2 (Windows) or verify Chromium is on PATH.
|
|
880
|
+
|
|
881
|
+
LLM ERROR: 401
|
|
882
|
+
Wrong API key, or stray quotes around it in agent_config.json.
|
|
883
|
+
|
|
884
|
+
LLM ERROR: 404
|
|
885
|
+
Model name doesn't match the provider. Check the provider docs.
|
|
886
|
+
|
|
887
|
+
BLOCKED: <cmd> not allowed
|
|
888
|
+
Add it to ALLOWED_CMDS in agent.py, or use a different approach.
|
|
889
|
+
|
|
890
|
+
NOT_FOUND: <selector>
|
|
891
|
+
CSS selector didn't match. Call browser_snapshot first.
|
|
892
|
+
|
|
893
|
+
email: not configured
|
|
894
|
+
Run /email to see setup. Resend / Brevo / Mailjet, all free.
|
|
895
|
+
|
|
896
|
+
cloud: not synced
|
|
897
|
+
Settings → CLOUD SYNC on. Then click Save.
|
|
898
|
+
""")
|
|
899
|
+
|
|
900
|
+
sections["about"] = ("System Info", f"""OS {platform_str}
|
|
901
|
+
Python {py_ver}
|
|
902
|
+
User {username}
|
|
903
|
+
|
|
904
|
+
Paths
|
|
905
|
+
root {root_p}
|
|
906
|
+
config {config_p}
|
|
907
|
+
chat {chat_p}
|
|
908
|
+
workspace {workspace_p}
|
|
909
|
+
skills {skills_p}
|
|
910
|
+
|
|
911
|
+
LLM
|
|
912
|
+
model {self.app.model}
|
|
913
|
+
base {self.app.base_url}
|
|
914
|
+
keys {len(self.app.api_keys)}
|
|
915
|
+
|
|
916
|
+
Skills {len(self.app.skill_names)} loaded, {len(self.app.disabled_skills)} disabled
|
|
917
|
+
Cloud sync {"on" if self.app.cloud_sync else "off"}
|
|
918
|
+
Cloud acct {self.app.cloud_user or "(not signed in)"}
|
|
919
|
+
Cloud srv {self.app.cloud_servers[0]}
|
|
920
|
+
""")
|
|
921
|
+
|
|
922
|
+
return sections
|
|
923
|
+
|
|
924
|
+
# ---- tree ----
|
|
925
|
+
|
|
926
|
+
def _populate_tree(self):
|
|
927
|
+
self.tree.insert("", "end", iid="grp:general",
|
|
928
|
+
text=" GENERAL", open=True, tags=("group",))
|
|
929
|
+
for key, (title, _) in self._sections.items():
|
|
930
|
+
self.tree.insert("grp:general", "end", iid=f"sec:{key}",
|
|
931
|
+
text=f" {title}", tags=("item",))
|
|
932
|
+
|
|
933
|
+
self.tree.insert("", "end", iid="grp:skills",
|
|
934
|
+
text=" SKILLS", open=True, tags=("group",))
|
|
935
|
+
if self.app.skill_meta:
|
|
936
|
+
for s in self.app.skill_meta:
|
|
937
|
+
name = s.get("name", "?")
|
|
938
|
+
self.tree.insert("grp:skills", "end",
|
|
939
|
+
iid=f"skill:{name}",
|
|
940
|
+
text=f" {name}", tags=("item",))
|
|
941
|
+
else:
|
|
942
|
+
self.tree.insert("grp:skills", "end", iid="skill_none",
|
|
943
|
+
text=" (none loaded)", tags=("item",))
|
|
944
|
+
|
|
945
|
+
def _on_select(self, event):
|
|
946
|
+
sel = self.tree.selection()
|
|
947
|
+
if not sel:
|
|
948
|
+
return
|
|
949
|
+
iid = sel[0]
|
|
950
|
+
if iid.startswith("sec:"):
|
|
951
|
+
self._show_section(iid[4:])
|
|
952
|
+
elif iid.startswith("skill:"):
|
|
953
|
+
self._show_skill(iid[6:])
|
|
954
|
+
|
|
955
|
+
# ---- content renderer ----
|
|
956
|
+
|
|
957
|
+
def _show_section(self, key: str):
|
|
958
|
+
title, body = self._sections.get(key, ("?", "(not found)"))
|
|
959
|
+
header = f"{title}\n{'─' * len(title)}\n\n"
|
|
960
|
+
self._set_content(header + body)
|
|
961
|
+
|
|
962
|
+
def _show_skill(self, name: str):
|
|
963
|
+
meta = next((s for s in self.app.skill_meta
|
|
964
|
+
if s.get("name") == name), {}) or {}
|
|
965
|
+
body = loader_get_help(SKILLS_DIR, name)
|
|
966
|
+
header = (
|
|
967
|
+
f"{name}\n"
|
|
968
|
+
f"{'─' * len(name)}\n"
|
|
969
|
+
f"version: {meta.get('version', '?')} "
|
|
970
|
+
f"author: {meta.get('author', '?')}\n\n"
|
|
971
|
+
)
|
|
972
|
+
self._set_content(header + body)
|
|
973
|
+
|
|
974
|
+
def _set_content(self, text: str):
|
|
975
|
+
self.content.configure(state="normal")
|
|
976
|
+
self.content.delete("1.0", "end")
|
|
977
|
+
self.content.insert("1.0", text)
|
|
978
|
+
self.content.yview_moveto(0)
|
|
979
|
+
self.content.configure(state="disabled")
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
# ===========================================================================
|
|
983
|
+
# App
|
|
984
|
+
# ===========================================================================
|
|
985
|
+
|
|
986
|
+
class App:
|
|
987
|
+
def __init__(self, root, browser):
|
|
988
|
+
self.root = root
|
|
989
|
+
self.browser = browser
|
|
990
|
+
self._log_q = queue.Queue()
|
|
991
|
+
self._log_history = []
|
|
992
|
+
self._log_lock = threading.Lock()
|
|
993
|
+
|
|
994
|
+
self._stop_event = threading.Event()
|
|
995
|
+
self._worker = None
|
|
996
|
+
self._stopped_last = False
|
|
997
|
+
|
|
998
|
+
cfg = load_agent_config()
|
|
999
|
+
self.api_keys = normalize_keys(cfg.get("openai_api_keys"))
|
|
1000
|
+
self.base_url = (cfg.get("openai_base_url")
|
|
1001
|
+
or os.getenv("OPENAI_BASE_URL")
|
|
1002
|
+
or "https://api.openai.com/v1")
|
|
1003
|
+
self.model = (cfg.get("openai_model")
|
|
1004
|
+
or os.getenv("OPENAI_MODEL")
|
|
1005
|
+
or "gpt-4o")
|
|
1006
|
+
self.disabled_skills = list(cfg.get("disabled_skills") or [])
|
|
1007
|
+
self.cloud_sync = bool(cfg.get("cloud_sync", False))
|
|
1008
|
+
|
|
1009
|
+
if not self.api_keys:
|
|
1010
|
+
self.api_keys = normalize_keys(
|
|
1011
|
+
[k.strip() for k in os.getenv("OPENAI_API_KEYS", "").split(",")])
|
|
1012
|
+
|
|
1013
|
+
self.client = make_client(self.api_keys, self.base_url)
|
|
1014
|
+
|
|
1015
|
+
sm = _sm_cfg()
|
|
1016
|
+
self.cloud_servers = _sm_server_list()
|
|
1017
|
+
self.cloud_server = self.cloud_servers[0]
|
|
1018
|
+
self.cloud_token = sm.get("token")
|
|
1019
|
+
self.cloud_user = sm.get("nickname")
|
|
1020
|
+
|
|
1021
|
+
print(f"[config] model={self.model} base={self.base_url} keys={len(self.api_keys)}")
|
|
1022
|
+
print(f"[config] cloud={self.cloud_sync} servers={self.cloud_servers} user={self.cloud_user}")
|
|
1023
|
+
|
|
1024
|
+
apply_theme(root)
|
|
1025
|
+
self._build_toolset()
|
|
1026
|
+
self.messages = [{"role": "system", "content": self.system_prompt}]
|
|
1027
|
+
|
|
1028
|
+
root.title("AI Agent")
|
|
1029
|
+
root.geometry("1020x760")
|
|
1030
|
+
root.minsize(780, 520)
|
|
1031
|
+
|
|
1032
|
+
header = ttk.Frame(root, style="Panel.TFrame")
|
|
1033
|
+
header.pack(fill="x", side="top")
|
|
1034
|
+
ttk.Label(header, text="AI AGENT", style="Header.TLabel").pack(
|
|
1035
|
+
side="left", padx=16, pady=10)
|
|
1036
|
+
ttk.Button(header, text="Help", command=self._open_help).pack(
|
|
1037
|
+
side="right", padx=(6, 16), pady=10)
|
|
1038
|
+
ttk.Button(header, text="Skills", command=self._open_skills).pack(
|
|
1039
|
+
side="right", padx=(6, 0), pady=10)
|
|
1040
|
+
ttk.Button(header, text="Settings", command=self._open_settings).pack(
|
|
1041
|
+
side="right", padx=(6, 0), pady=10)
|
|
1042
|
+
ttk.Button(header, text="Save", command=self._save_chat_manual).pack(
|
|
1043
|
+
side="right", padx=(6, 0), pady=10)
|
|
1044
|
+
self.browser_lbl = ttk.Label(header, text="browser: starting…",
|
|
1045
|
+
style="Status.TLabel")
|
|
1046
|
+
self.browser_lbl.pack(side="right", padx=16)
|
|
1047
|
+
self.status_lbl = ttk.Label(header, text="", style="Status.TLabel")
|
|
1048
|
+
self.status_lbl.pack(side="right", padx=16)
|
|
1049
|
+
self._update_header()
|
|
1050
|
+
|
|
1051
|
+
log_wrap = tk.Frame(root, bg=BG)
|
|
1052
|
+
log_wrap.pack(fill="both", expand=True, padx=12, pady=(10, 6))
|
|
1053
|
+
ff = "Cascadia Mono" if self._has_font("Cascadia Mono") else "Consolas"
|
|
1054
|
+
self.log = tk.Text(log_wrap, wrap="word", bg=BG_PANEL, fg=FG,
|
|
1055
|
+
insertbackground=FG, selectbackground="#264f78",
|
|
1056
|
+
relief="flat", borderwidth=0, padx=14, pady=12,
|
|
1057
|
+
font=(ff, 10), spacing1=2, spacing3=4)
|
|
1058
|
+
self.log.pack(side="left", fill="both", expand=True)
|
|
1059
|
+
sb = ttk.Scrollbar(log_wrap, orient="vertical", command=self.log.yview)
|
|
1060
|
+
sb.pack(side="right", fill="y")
|
|
1061
|
+
self.log.configure(yscrollcommand=sb.set, state="disabled")
|
|
1062
|
+
|
|
1063
|
+
self.log.tag_configure("user", foreground=YELLOW, font=(ff, 10, "bold"))
|
|
1064
|
+
self.log.tag_configure("agent", foreground=GREEN, font=(ff, 10, "bold"))
|
|
1065
|
+
self.log.tag_configure("tool", foreground=ACCENT)
|
|
1066
|
+
self.log.tag_configure("result", foreground=PURPLE)
|
|
1067
|
+
self.log.tag_configure("error", foreground=RED)
|
|
1068
|
+
self.log.tag_configure("dim", foreground=FG_DIM)
|
|
1069
|
+
self.log.tag_configure("turn", foreground=FG_DIM, font=(ff, 9, "italic"))
|
|
1070
|
+
|
|
1071
|
+
bottom = ttk.Frame(root, style="Panel.TFrame")
|
|
1072
|
+
bottom.pack(fill="x", side="bottom")
|
|
1073
|
+
inner = tk.Frame(bottom, bg=BG_PANEL)
|
|
1074
|
+
inner.pack(fill="x", padx=12, pady=10)
|
|
1075
|
+
self.stop_btn = ttk.Button(inner, text="Stop", style="Stop.TButton",
|
|
1076
|
+
command=self._on_stop, state="disabled")
|
|
1077
|
+
self.stop_btn.pack(side="left", padx=(0, 6))
|
|
1078
|
+
self.continue_btn = ttk.Button(inner, text="Continue",
|
|
1079
|
+
command=self._on_continue, state="disabled")
|
|
1080
|
+
self.continue_btn.pack(side="left", padx=(0, 10))
|
|
1081
|
+
self.entry = ttk.Entry(inner, font=("Segoe UI", 11))
|
|
1082
|
+
self.entry.pack(side="left", fill="x", expand=True, ipady=2)
|
|
1083
|
+
self.entry.bind("<Return>", lambda e: self.on_send())
|
|
1084
|
+
self.send_btn = ttk.Button(inner, text="Send", style="Send.TButton",
|
|
1085
|
+
command=self.on_send)
|
|
1086
|
+
self.send_btn.pack(side="right", padx=(10, 0))
|
|
1087
|
+
|
|
1088
|
+
self._load_local_chat()
|
|
1089
|
+
self._try_load_cloud_chat()
|
|
1090
|
+
|
|
1091
|
+
self.log_line(f"Model : {self.model}", "dim")
|
|
1092
|
+
self.log_line(f"Base : {self.base_url}", "dim")
|
|
1093
|
+
self.log_line(f"Keys : {len(self.api_keys)}", "dim")
|
|
1094
|
+
self.log_line(f"Workspace: {WORKSPACE}", "dim")
|
|
1095
|
+
self.log_line(f"Chat file: {CHAT_FILE}", "dim")
|
|
1096
|
+
cloud_status = "on" if (self.cloud_sync and self.cloud_token) else "off"
|
|
1097
|
+
self.log_line(f"Cloud : {cloud_status} ({' → '.join(self.cloud_servers)})", "dim")
|
|
1098
|
+
self.log_line(f"Skills : {', '.join(self.skill_names) or '(none)'}", "dim")
|
|
1099
|
+
if self.disabled_skills:
|
|
1100
|
+
self.log_line(f"Disabled : {', '.join(self.disabled_skills)}", "dim")
|
|
1101
|
+
if self.skill_commands:
|
|
1102
|
+
self.log_line("Commands : " + ", ".join(f"/{c}" for c in sorted(self.skill_commands)),
|
|
1103
|
+
"dim")
|
|
1104
|
+
self.log_line("Buttons : Stop · Continue · Send · Save · Settings · Skills · Help", "dim")
|
|
1105
|
+
self.log_line("─" * 70, "dim")
|
|
1106
|
+
|
|
1107
|
+
threading.Thread(target=self._browser_selftest, daemon=True).start()
|
|
1108
|
+
self._poll_log()
|
|
1109
|
+
self.entry.focus_set()
|
|
1110
|
+
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
1111
|
+
|
|
1112
|
+
# ---- header ----
|
|
1113
|
+
|
|
1114
|
+
def _update_header(self):
|
|
1115
|
+
enabled = len([s for s in loader_discover(SKILLS_DIR)
|
|
1116
|
+
if s["name"] not in self.disabled_skills])
|
|
1117
|
+
cloud = "☁" if (self.cloud_sync and self.cloud_token) else ""
|
|
1118
|
+
self.status_lbl.configure(
|
|
1119
|
+
text=f"{self.model} · {len(self.api_keys)} key · {enabled} skill {cloud}")
|
|
1120
|
+
|
|
1121
|
+
# ---- skills ----
|
|
1122
|
+
|
|
1123
|
+
def _build_toolset(self):
|
|
1124
|
+
self.all_schemas = list(CORE_SCHEMAS)
|
|
1125
|
+
self.all_callables = {
|
|
1126
|
+
"browser_navigate": self.browser.navigate,
|
|
1127
|
+
"browser_snapshot": self.browser.snapshot,
|
|
1128
|
+
"browser_links": self.browser.links,
|
|
1129
|
+
"browser_click": self.browser.click,
|
|
1130
|
+
"browser_type": self.browser.type_text,
|
|
1131
|
+
"browser_scroll": self.browser.scroll,
|
|
1132
|
+
"shell_run": shell_run,
|
|
1133
|
+
}
|
|
1134
|
+
skill_schemas, skill_callables, skill_meta, skill_commands = \
|
|
1135
|
+
loader_load(SKILLS_DIR, self.disabled_skills)
|
|
1136
|
+
|
|
1137
|
+
self.all_schemas.extend(skill_schemas)
|
|
1138
|
+
self.all_callables.update(skill_callables)
|
|
1139
|
+
self.skill_names = [m.get("name", "?") for m in skill_meta]
|
|
1140
|
+
self.skill_meta = skill_meta
|
|
1141
|
+
self.skill_commands = skill_commands
|
|
1142
|
+
|
|
1143
|
+
if skill_meta:
|
|
1144
|
+
lines = "\n".join(f"- {m['name']}: {m.get('description', '')}"
|
|
1145
|
+
for m in skill_meta)
|
|
1146
|
+
self.system_prompt = CORE_PROMPT + "\nSKILLS (loaded):\n" + lines + "\n"
|
|
1147
|
+
else:
|
|
1148
|
+
self.system_prompt = CORE_PROMPT
|
|
1149
|
+
|
|
1150
|
+
def _reload_tools(self):
|
|
1151
|
+
self._build_toolset()
|
|
1152
|
+
if hasattr(self, "messages") and self.messages:
|
|
1153
|
+
self.messages[0] = {"role": "system", "content": self.system_prompt}
|
|
1154
|
+
if hasattr(self, "status_lbl"):
|
|
1155
|
+
self._update_header()
|
|
1156
|
+
self.log_line(f"[skills] reloaded — active: {', '.join(self.skill_names) or '(none)'}", "dim")
|
|
1157
|
+
|
|
1158
|
+
def _save_disabled_skills(self):
|
|
1159
|
+
cfg = load_agent_config()
|
|
1160
|
+
cfg["disabled_skills"] = list(self.disabled_skills)
|
|
1161
|
+
cfg.setdefault("openai_api_keys", self.api_keys)
|
|
1162
|
+
cfg.setdefault("openai_base_url", self.base_url)
|
|
1163
|
+
cfg.setdefault("openai_model", self.model)
|
|
1164
|
+
cfg.setdefault("headless", True)
|
|
1165
|
+
cfg.setdefault("cloud_sync", self.cloud_sync)
|
|
1166
|
+
try:
|
|
1167
|
+
save_agent_config(cfg)
|
|
1168
|
+
except Exception as e:
|
|
1169
|
+
print(f"[config] save failed: {e}")
|
|
1170
|
+
|
|
1171
|
+
# ---- dialogs ----
|
|
1172
|
+
|
|
1173
|
+
def _open_settings(self):
|
|
1174
|
+
SettingsDialog(self.root, self)
|
|
1175
|
+
|
|
1176
|
+
def _open_skills(self):
|
|
1177
|
+
SkillsDialog(self.root, self)
|
|
1178
|
+
|
|
1179
|
+
def _open_help(self):
|
|
1180
|
+
HelpDialog(self.root, self)
|
|
1181
|
+
|
|
1182
|
+
# ---- logging ----
|
|
1183
|
+
|
|
1184
|
+
def _has_font(self, name):
|
|
1185
|
+
try:
|
|
1186
|
+
return name in tkfont.families()
|
|
1187
|
+
except Exception:
|
|
1188
|
+
return False
|
|
1189
|
+
|
|
1190
|
+
def _browser_selftest(self):
|
|
1191
|
+
result = self.browser.self_test()
|
|
1192
|
+
ok = result.startswith("OK")
|
|
1193
|
+
self.root.after(0, lambda: self.browser_lbl.configure(
|
|
1194
|
+
text="browser: ready" if ok else "browser: FAILED",
|
|
1195
|
+
foreground=GREEN if ok else RED))
|
|
1196
|
+
self.log_line(f"browser self-test: {result}", "dim" if ok else "error")
|
|
1197
|
+
|
|
1198
|
+
def log_line(self, text, tag=None):
|
|
1199
|
+
with self._log_lock:
|
|
1200
|
+
self._log_history.append((text, tag))
|
|
1201
|
+
self._log_q.put((text, tag))
|
|
1202
|
+
|
|
1203
|
+
def _poll_log(self):
|
|
1204
|
+
try:
|
|
1205
|
+
while True:
|
|
1206
|
+
text, tag = self._log_q.get_nowait()
|
|
1207
|
+
self.log.configure(state="normal")
|
|
1208
|
+
if tag:
|
|
1209
|
+
self.log.insert("end", text + "\n", tag)
|
|
1210
|
+
else:
|
|
1211
|
+
self.log.insert("end", text + "\n")
|
|
1212
|
+
self.log.see("end")
|
|
1213
|
+
self.log.configure(state="disabled")
|
|
1214
|
+
except queue.Empty:
|
|
1215
|
+
pass
|
|
1216
|
+
self.root.after(60, self._poll_log)
|
|
1217
|
+
|
|
1218
|
+
# ---- local chat ----
|
|
1219
|
+
|
|
1220
|
+
def _save_local_chat(self):
|
|
1221
|
+
try:
|
|
1222
|
+
data = {
|
|
1223
|
+
"messages": self.messages,
|
|
1224
|
+
"log": self._log_history[-500:],
|
|
1225
|
+
"model": self.model,
|
|
1226
|
+
"saved_at": time.time(),
|
|
1227
|
+
}
|
|
1228
|
+
tmp = CHAT_FILE.with_suffix(".json.tmp")
|
|
1229
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
1230
|
+
tmp.replace(CHAT_FILE)
|
|
1231
|
+
except Exception as e:
|
|
1232
|
+
print(f"[chat] save failed: {e}")
|
|
1233
|
+
|
|
1234
|
+
def _load_local_chat(self):
|
|
1235
|
+
if not CHAT_FILE.exists():
|
|
1236
|
+
return
|
|
1237
|
+
try:
|
|
1238
|
+
data = json.loads(CHAT_FILE.read_text(encoding="utf-8"))
|
|
1239
|
+
msgs = data.get("messages") or []
|
|
1240
|
+
if msgs and msgs[0].get("role") == "system":
|
|
1241
|
+
msgs[0] = {"role": "system", "content": self.system_prompt}
|
|
1242
|
+
if msgs:
|
|
1243
|
+
self.messages = msgs
|
|
1244
|
+
for text, tag in (data.get("log") or [])[-200:]:
|
|
1245
|
+
with self._log_lock:
|
|
1246
|
+
self._log_history.append((text, tag))
|
|
1247
|
+
self._log_q.put((text, tag))
|
|
1248
|
+
print(f"[chat] loaded {len(msgs)} messages from {CHAT_FILE}")
|
|
1249
|
+
except Exception as e:
|
|
1250
|
+
print(f"[chat] load failed: {e}")
|
|
1251
|
+
|
|
1252
|
+
# ---- cloud ----
|
|
1253
|
+
|
|
1254
|
+
def _cloud_headers(self):
|
|
1255
|
+
return {
|
|
1256
|
+
"Accept": "application/json",
|
|
1257
|
+
"User-Agent": SKILL_UA,
|
|
1258
|
+
"Content-Type": "application/json",
|
|
1259
|
+
"Authorization": f"Bearer {self.cloud_token}",
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
def _save_cloud_chat(self):
|
|
1263
|
+
if not (self.cloud_sync and self.cloud_token):
|
|
1264
|
+
return
|
|
1265
|
+
|
|
1266
|
+
payload = json.dumps({
|
|
1267
|
+
"messages": self.messages,
|
|
1268
|
+
"log": self._log_history[-500:],
|
|
1269
|
+
"model": self.model,
|
|
1270
|
+
}, ensure_ascii=False)
|
|
1271
|
+
body = json.dumps({"state": payload}).encode("utf-8")
|
|
1272
|
+
|
|
1273
|
+
for server in self.cloud_servers:
|
|
1274
|
+
try:
|
|
1275
|
+
req = urllib.request.Request(
|
|
1276
|
+
server.rstrip("/") + "/state",
|
|
1277
|
+
data=body, method="POST", headers=self._cloud_headers())
|
|
1278
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
1279
|
+
r.read()
|
|
1280
|
+
if server != self.cloud_servers[0]:
|
|
1281
|
+
print(f"[cloud] saved via fallback: {server}")
|
|
1282
|
+
return
|
|
1283
|
+
except Exception as e:
|
|
1284
|
+
print(f"[cloud] {server} save failed: {e}")
|
|
1285
|
+
print("[cloud] all servers failed on save")
|
|
1286
|
+
|
|
1287
|
+
def _try_load_cloud_chat(self):
|
|
1288
|
+
if not (self.cloud_sync and self.cloud_token):
|
|
1289
|
+
return
|
|
1290
|
+
if CHAT_FILE.exists():
|
|
1291
|
+
return
|
|
1292
|
+
|
|
1293
|
+
for server in self.cloud_servers:
|
|
1294
|
+
try:
|
|
1295
|
+
req = urllib.request.Request(
|
|
1296
|
+
server.rstrip("/") + "/state",
|
|
1297
|
+
method="GET", headers=self._cloud_headers())
|
|
1298
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
1299
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
1300
|
+
except Exception as e:
|
|
1301
|
+
print(f"[cloud] {server} load failed: {e}")
|
|
1302
|
+
continue
|
|
1303
|
+
|
|
1304
|
+
blob = data.get("state")
|
|
1305
|
+
if not blob:
|
|
1306
|
+
return
|
|
1307
|
+
try:
|
|
1308
|
+
state = json.loads(blob)
|
|
1309
|
+
except Exception as e:
|
|
1310
|
+
print(f"[cloud] bad state blob from {server}: {e}")
|
|
1311
|
+
return
|
|
1312
|
+
|
|
1313
|
+
msgs = state.get("messages") or []
|
|
1314
|
+
if msgs and msgs[0].get("role") == "system":
|
|
1315
|
+
msgs[0] = {"role": "system", "content": self.system_prompt}
|
|
1316
|
+
if msgs:
|
|
1317
|
+
self.messages = msgs
|
|
1318
|
+
for text, tag in (state.get("log") or [])[-200:]:
|
|
1319
|
+
with self._log_lock:
|
|
1320
|
+
self._log_history.append((text, tag))
|
|
1321
|
+
self._log_q.put((text, tag))
|
|
1322
|
+
print(f"[cloud] loaded {len(msgs)} messages from {server}")
|
|
1323
|
+
return
|
|
1324
|
+
|
|
1325
|
+
def _save_chat_manual(self):
|
|
1326
|
+
self._save_local_chat()
|
|
1327
|
+
self._save_cloud_chat()
|
|
1328
|
+
cloud = " + cloud" if (self.cloud_sync and self.cloud_token) else ""
|
|
1329
|
+
self.log_line(f"[save] chat saved{cloud} → {CHAT_FILE}", "dim")
|
|
1330
|
+
|
|
1331
|
+
# ---- run control ----
|
|
1332
|
+
|
|
1333
|
+
def _set_running(self, running):
|
|
1334
|
+
if running:
|
|
1335
|
+
self.send_btn.configure(state="disabled")
|
|
1336
|
+
self.stop_btn.configure(state="normal")
|
|
1337
|
+
self.continue_btn.configure(state="disabled")
|
|
1338
|
+
self.status_lbl.configure(text="running…")
|
|
1339
|
+
else:
|
|
1340
|
+
self.send_btn.configure(state="normal")
|
|
1341
|
+
self.stop_btn.configure(state="disabled")
|
|
1342
|
+
self.continue_btn.configure(
|
|
1343
|
+
state="normal" if self._stopped_last else "disabled")
|
|
1344
|
+
self._update_header()
|
|
1345
|
+
|
|
1346
|
+
def _on_stop(self):
|
|
1347
|
+
self._stop_event.set()
|
|
1348
|
+
self.log_line("[stop] requested", "error")
|
|
1349
|
+
self.stop_btn.configure(state="disabled")
|
|
1350
|
+
|
|
1351
|
+
def _on_continue(self):
|
|
1352
|
+
if self._worker and self._worker.is_alive():
|
|
1353
|
+
return
|
|
1354
|
+
self.log_line("\n▸ continue", "user")
|
|
1355
|
+
self._stopped_last = False
|
|
1356
|
+
self._stop_event.clear()
|
|
1357
|
+
self._set_running(True)
|
|
1358
|
+
self._worker = threading.Thread(target=self._run_agent, args=(None,), daemon=True)
|
|
1359
|
+
self._worker.start()
|
|
1360
|
+
|
|
1361
|
+
# ---- input ----
|
|
1362
|
+
|
|
1363
|
+
def on_send(self):
|
|
1364
|
+
task = self.entry.get().strip()
|
|
1365
|
+
if not task:
|
|
1366
|
+
return
|
|
1367
|
+
self.entry.delete(0, "end")
|
|
1368
|
+
low = task.lower()
|
|
1369
|
+
|
|
1370
|
+
# ---- built-in commands ----
|
|
1371
|
+
if low in ("/reset", "/clear"):
|
|
1372
|
+
self.messages = [{"role": "system", "content": self.system_prompt}]
|
|
1373
|
+
self._log_history = []
|
|
1374
|
+
self.log.configure(state="normal")
|
|
1375
|
+
self.log.delete("1.0", "end")
|
|
1376
|
+
self.log.configure(state="disabled")
|
|
1377
|
+
self.log_line("── conversation cleared ──", "dim")
|
|
1378
|
+
self._save_local_chat()
|
|
1379
|
+
return
|
|
1380
|
+
if low == "/history":
|
|
1381
|
+
for m in self.messages[1:]:
|
|
1382
|
+
role = m.get("role", "?")
|
|
1383
|
+
content = (m.get("content") or "")[:140].replace("\n", " ")
|
|
1384
|
+
self.log_line(f" [{role}] {content}", "dim")
|
|
1385
|
+
return
|
|
1386
|
+
if low == "/skills":
|
|
1387
|
+
self.log_line(f"active: {', '.join(self.skill_names) or '(none)'}", "dim")
|
|
1388
|
+
for m in self.skill_meta:
|
|
1389
|
+
self.log_line(f" - {m['name']}: {m.get('description', '')[:100]}", "dim")
|
|
1390
|
+
if self.disabled_skills:
|
|
1391
|
+
self.log_line(f"disabled: {', '.join(self.disabled_skills)}", "dim")
|
|
1392
|
+
if self.skill_commands:
|
|
1393
|
+
self.log_line("commands:", "dim")
|
|
1394
|
+
for cname, cdef in sorted(self.skill_commands.items()):
|
|
1395
|
+
self.log_line(f" /{cname:<14} {cdef.get('description','')}", "dim")
|
|
1396
|
+
return
|
|
1397
|
+
if low == "/save":
|
|
1398
|
+
self._save_chat_manual()
|
|
1399
|
+
return
|
|
1400
|
+
if low.startswith("/open "):
|
|
1401
|
+
rel = task[6:].strip()
|
|
1402
|
+
p = (WORKSPACE / rel).resolve()
|
|
1403
|
+
if not str(p).startswith(str(WORKSPACE)) or not p.exists():
|
|
1404
|
+
self.log_line(f"ERROR: not in workspace: {rel}", "error")
|
|
1405
|
+
return
|
|
1406
|
+
try:
|
|
1407
|
+
os.startfile(str(p))
|
|
1408
|
+
self.log_line(f"opened {p}", "dim")
|
|
1409
|
+
except Exception as e:
|
|
1410
|
+
self.log_line(f"ERROR: {e}", "error")
|
|
1411
|
+
return
|
|
1412
|
+
|
|
1413
|
+
# ---- dispatch to skill commands ----
|
|
1414
|
+
if task.startswith("/"):
|
|
1415
|
+
parts = task[1:].split()
|
|
1416
|
+
cmd_name = parts[0].lower() if parts else ""
|
|
1417
|
+
args = parts[1:]
|
|
1418
|
+
if cmd_name in self.skill_commands:
|
|
1419
|
+
handler = self.skill_commands[cmd_name]["handler"]
|
|
1420
|
+
try:
|
|
1421
|
+
handler(args, self.log_line)
|
|
1422
|
+
except Exception as e:
|
|
1423
|
+
self.log_line(f"[{cmd_name}] error: {e}", "error")
|
|
1424
|
+
return
|
|
1425
|
+
self.log_line(f"unknown command: /{cmd_name}", "error")
|
|
1426
|
+
return
|
|
1427
|
+
|
|
1428
|
+
# ---- normal task ----
|
|
1429
|
+
self.log_line(f"\n▸ {task}", "user")
|
|
1430
|
+
self._stopped_last = False
|
|
1431
|
+
self._stop_event.clear()
|
|
1432
|
+
self._set_running(True)
|
|
1433
|
+
self._worker = threading.Thread(target=self._run_agent, args=(task,), daemon=True)
|
|
1434
|
+
self._worker.start()
|
|
1435
|
+
|
|
1436
|
+
def _run_agent(self, task):
|
|
1437
|
+
self._stopped_last = False
|
|
1438
|
+
try:
|
|
1439
|
+
self._agent_loop(task)
|
|
1440
|
+
finally:
|
|
1441
|
+
self._save_local_chat()
|
|
1442
|
+
self._save_cloud_chat()
|
|
1443
|
+
self.root.after(0, self._set_running, False)
|
|
1444
|
+
|
|
1445
|
+
def _agent_loop(self, task):
|
|
1446
|
+
if task is not None:
|
|
1447
|
+
self.messages.append({"role": "user", "content": task})
|
|
1448
|
+
if len(self.messages) > 42:
|
|
1449
|
+
self.messages = [self.messages[0]] + self.messages[-40:]
|
|
1450
|
+
|
|
1451
|
+
for turn in range(1, MAX_TURNS + 1):
|
|
1452
|
+
if self._stop_event.is_set():
|
|
1453
|
+
self.log_line("[stopped]", "error")
|
|
1454
|
+
self._stopped_last = True
|
|
1455
|
+
return
|
|
1456
|
+
|
|
1457
|
+
self.log_line(f"[turn {turn}] thinking…", "turn")
|
|
1458
|
+
try:
|
|
1459
|
+
resp = self.client.chat.completions.create(
|
|
1460
|
+
model=self.model, messages=self.messages,
|
|
1461
|
+
tools=self.all_schemas, tool_choice="auto",
|
|
1462
|
+
temperature=0.2)
|
|
1463
|
+
except Exception as e:
|
|
1464
|
+
self.log_line(f"LLM ERROR: {e}", "error")
|
|
1465
|
+
return
|
|
1466
|
+
|
|
1467
|
+
msg = resp.choices[0].message
|
|
1468
|
+
assistant = {"role": "assistant", "content": msg.content or ""}
|
|
1469
|
+
if msg.tool_calls:
|
|
1470
|
+
assistant["tool_calls"] = [
|
|
1471
|
+
{"id": tc.id, "type": "function",
|
|
1472
|
+
"function": {"name": tc.function.name,
|
|
1473
|
+
"arguments": tc.function.arguments}}
|
|
1474
|
+
for tc in msg.tool_calls
|
|
1475
|
+
]
|
|
1476
|
+
self.messages.append(assistant)
|
|
1477
|
+
|
|
1478
|
+
if not msg.tool_calls:
|
|
1479
|
+
self.log_line("AGENT ▸ " + (msg.content or "(done)"), "agent")
|
|
1480
|
+
return
|
|
1481
|
+
|
|
1482
|
+
for tc in msg.tool_calls:
|
|
1483
|
+
if self._stop_event.is_set():
|
|
1484
|
+
self.log_line("[stopped before tool call]", "error")
|
|
1485
|
+
self._stopped_last = True
|
|
1486
|
+
return
|
|
1487
|
+
|
|
1488
|
+
name = tc.function.name
|
|
1489
|
+
try:
|
|
1490
|
+
args = json.loads(tc.function.arguments or "{}")
|
|
1491
|
+
except json.JSONDecodeError:
|
|
1492
|
+
args = {}
|
|
1493
|
+
preview = ", ".join(f"{k}={v!r}" for k, v in args.items())
|
|
1494
|
+
self.log_line(f"→ {name}({preview[:200]})", "tool")
|
|
1495
|
+
|
|
1496
|
+
fn = self.all_callables.get(name)
|
|
1497
|
+
try:
|
|
1498
|
+
result = fn(**args) if fn else f"unknown tool: {name}"
|
|
1499
|
+
except Exception as e:
|
|
1500
|
+
result = f"ERROR: {e}"
|
|
1501
|
+
|
|
1502
|
+
tag = "error" if str(result).startswith(
|
|
1503
|
+
("ERROR", "BLOCKED", "NOT_FOUND")) else "result"
|
|
1504
|
+
self.log_line(f"← {str(result)[:400].replace(chr(10), ' ⏎ ')}", tag)
|
|
1505
|
+
|
|
1506
|
+
self.messages.append({
|
|
1507
|
+
"role": "tool",
|
|
1508
|
+
"tool_call_id": tc.id,
|
|
1509
|
+
"content": str(result)[:4000],
|
|
1510
|
+
})
|
|
1511
|
+
self._save_local_chat()
|
|
1512
|
+
|
|
1513
|
+
self.log_line("stopped: max turns reached", "error")
|
|
1514
|
+
|
|
1515
|
+
def _on_close(self):
|
|
1516
|
+
try:
|
|
1517
|
+
self._stop_event.set()
|
|
1518
|
+
self._save_local_chat()
|
|
1519
|
+
self._save_cloud_chat()
|
|
1520
|
+
finally:
|
|
1521
|
+
try:
|
|
1522
|
+
self.browser.close()
|
|
1523
|
+
finally:
|
|
1524
|
+
self.root.destroy()
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
# ===========================================================================
|
|
1528
|
+
# Entry
|
|
1529
|
+
# ===========================================================================
|
|
1530
|
+
|
|
1531
|
+
def main():
|
|
1532
|
+
if not load_agent_config().get("openai_api_keys"):
|
|
1533
|
+
print("[config] no API keys — open Settings in the GUI to add one")
|
|
1534
|
+
|
|
1535
|
+
browser = Browser(headless=True)
|
|
1536
|
+
root = tk.Tk()
|
|
1537
|
+
app = App(root, browser)
|
|
1538
|
+
root.mainloop()
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
if __name__ == "__main__":
|
|
1542
|
+
main()
|