dulus 0.2.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.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
gui/personas.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Persona system for Dulus GUI.
|
|
2
|
+
|
|
3
|
+
Loads the canonical persona definitions from .dulus-context/personas.json
|
|
4
|
+
and provides helpers for retrieving persona data and rendering cards in
|
|
5
|
+
customtkinter interfaces.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# ── Paths ───────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
17
|
+
_DEFAULT_JSON_PATH = _REPO_ROOT / ".dulus-context" / "personas.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Cache ───────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
_persona_data: dict[str, Any] | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_json(path: Path | str | None = None) -> dict[str, Any]:
|
|
26
|
+
"""Load and cache personas.json. Raises FileNotFoundError if missing."""
|
|
27
|
+
global _persona_data
|
|
28
|
+
if _persona_data is not None:
|
|
29
|
+
return _persona_data
|
|
30
|
+
|
|
31
|
+
target = Path(path) if path else _DEFAULT_JSON_PATH
|
|
32
|
+
with target.open("r", encoding="utf-8") as fh:
|
|
33
|
+
_persona_data = json.load(fh)
|
|
34
|
+
return _persona_data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def reload() -> dict[str, Any]:
|
|
38
|
+
"""Force reload personas.json from disk and return the raw data."""
|
|
39
|
+
global _persona_data
|
|
40
|
+
_persona_data = None
|
|
41
|
+
return _load_json()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Core API ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def get_all_personas(path: Path | str | None = None) -> list[dict[str, Any]]:
|
|
47
|
+
"""Return all persona definitions as a list of dicts."""
|
|
48
|
+
data = _load_json(path)
|
|
49
|
+
return list(data.get("personas", []))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_persona(persona_id: str, path: Path | str | None = None) -> dict[str, Any] | None:
|
|
53
|
+
"""Return a single persona by its ``id`` (e.g. ``'kevrojo'``)."""
|
|
54
|
+
for p in get_all_personas(path):
|
|
55
|
+
if p.get("id") == persona_id:
|
|
56
|
+
return p
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_color_for_agent(agent_name: str, path: Path | str | None = None) -> str:
|
|
61
|
+
"""Return the hex color for an agent name/id (case-insensitive).
|
|
62
|
+
|
|
63
|
+
Falls back to the default Dulus accent ``#ff6b1f`` if unknown.
|
|
64
|
+
"""
|
|
65
|
+
lookup = agent_name.lower().strip()
|
|
66
|
+
for p in get_all_personas(path):
|
|
67
|
+
if p.get("id", "").lower() == lookup or p.get("name", "").lower() == lookup:
|
|
68
|
+
return p.get("color", "#ff6b1f")
|
|
69
|
+
return "#ff6b1f"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_display_name(agent_name: str, path: Path | str | None = None) -> str:
|
|
73
|
+
"""Return the pretty display name for an agent, or the raw name as fallback."""
|
|
74
|
+
lookup = agent_name.lower().strip()
|
|
75
|
+
for p in get_all_personas(path):
|
|
76
|
+
if p.get("id", "").lower() == lookup or p.get("name", "").lower() == lookup:
|
|
77
|
+
return p.get("display_name") or p.get("name") or agent_name
|
|
78
|
+
return agent_name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── customtkinter Widget (optional) ─────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
import customtkinter as ctk
|
|
85
|
+
_HAS_CTK = True
|
|
86
|
+
except Exception: # pragma: no cover
|
|
87
|
+
_HAS_CTK = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PersonaCard(ctk.CTkFrame if _HAS_CTK else object): # type: ignore[misc]
|
|
91
|
+
"""A small card widget that displays a single persona's identity.
|
|
92
|
+
|
|
93
|
+
Usage::
|
|
94
|
+
|
|
95
|
+
card = PersonaCard(parent, persona=get_persona("kimi-code"))
|
|
96
|
+
card.pack(padx=10, pady=10, fill="both", expand=True)
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
master: Any,
|
|
102
|
+
persona: dict[str, Any],
|
|
103
|
+
width: int = 340,
|
|
104
|
+
height: int = 280,
|
|
105
|
+
**kwargs: Any,
|
|
106
|
+
) -> None:
|
|
107
|
+
if not _HAS_CTK:
|
|
108
|
+
raise RuntimeError("customtkinter is required to use PersonaCard")
|
|
109
|
+
|
|
110
|
+
self._persona = persona
|
|
111
|
+
self._color = persona.get("color", "#ff6b1f")
|
|
112
|
+
self._accent = persona.get("accent_color", self._color)
|
|
113
|
+
|
|
114
|
+
super().__init__(
|
|
115
|
+
master,
|
|
116
|
+
width=width,
|
|
117
|
+
height=height,
|
|
118
|
+
corner_radius=8,
|
|
119
|
+
fg_color=("#f9f9f9", "#15151a"),
|
|
120
|
+
border_width=1,
|
|
121
|
+
border_color=self._color,
|
|
122
|
+
**kwargs,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self._build()
|
|
126
|
+
|
|
127
|
+
def _build(self) -> None:
|
|
128
|
+
# Top accent bar
|
|
129
|
+
self._top = ctk.CTkFrame(self, height=3, fg_color=self._color)
|
|
130
|
+
self._top.pack(fill="x", padx=0, pady=0)
|
|
131
|
+
|
|
132
|
+
# Header row: ASCII avatar + meta
|
|
133
|
+
self._header = ctk.CTkFrame(self, fg_color="transparent")
|
|
134
|
+
self._header.pack(fill="x", padx=12, pady=(12, 8))
|
|
135
|
+
|
|
136
|
+
# Avatar label (monospace)
|
|
137
|
+
avatar_text = self._persona.get("avatar_ascii", "?")
|
|
138
|
+
self._avatar = ctk.CTkLabel(
|
|
139
|
+
self._header,
|
|
140
|
+
text=avatar_text,
|
|
141
|
+
font=ctk.CTkFont(family="Consolas", size=9),
|
|
142
|
+
text_color=self._color,
|
|
143
|
+
width=120,
|
|
144
|
+
height=110,
|
|
145
|
+
fg_color=("#eeeeee", "#0f0f12"),
|
|
146
|
+
corner_radius=6,
|
|
147
|
+
)
|
|
148
|
+
self._avatar.pack(side="left", padx=(0, 12))
|
|
149
|
+
|
|
150
|
+
# Meta column
|
|
151
|
+
self._meta = ctk.CTkFrame(self._header, fg_color="transparent")
|
|
152
|
+
self._meta.pack(side="left", fill="both", expand=True)
|
|
153
|
+
|
|
154
|
+
display = self._persona.get("display_name", self._persona.get("name", "???"))
|
|
155
|
+
self._name_lbl = ctk.CTkLabel(
|
|
156
|
+
self._meta,
|
|
157
|
+
text=display,
|
|
158
|
+
font=ctk.CTkFont(family="JetBrains Mono", size=16, weight="bold"),
|
|
159
|
+
text_color=self._color,
|
|
160
|
+
anchor="w",
|
|
161
|
+
)
|
|
162
|
+
self._name_lbl.pack(fill="x")
|
|
163
|
+
|
|
164
|
+
role = self._persona.get("role", "Agent")
|
|
165
|
+
self._role_lbl = ctk.CTkLabel(
|
|
166
|
+
self._meta,
|
|
167
|
+
text=role,
|
|
168
|
+
font=ctk.CTkFont(family="JetBrains Mono", size=10),
|
|
169
|
+
text_color=self._accent,
|
|
170
|
+
anchor="w",
|
|
171
|
+
)
|
|
172
|
+
self._role_lbl.pack(fill="x", pady=(2, 6))
|
|
173
|
+
|
|
174
|
+
ptype = self._persona.get("type", "unknown")
|
|
175
|
+
self._type_lbl = ctk.CTkLabel(
|
|
176
|
+
self._meta,
|
|
177
|
+
text=f"● {ptype}",
|
|
178
|
+
font=ctk.CTkFont(family="JetBrains Mono", size=10),
|
|
179
|
+
text_color=("#888888", "#6a6470"),
|
|
180
|
+
anchor="w",
|
|
181
|
+
)
|
|
182
|
+
self._type_lbl.pack(fill="x")
|
|
183
|
+
|
|
184
|
+
# Catchphrase
|
|
185
|
+
catch = self._persona.get("catchphrase", "")
|
|
186
|
+
if catch:
|
|
187
|
+
self._catch = ctk.CTkLabel(
|
|
188
|
+
self,
|
|
189
|
+
text=f'"{catch}"',
|
|
190
|
+
font=ctk.CTkFont(family="JetBrains Mono", size=11, slant="italic"),
|
|
191
|
+
text_color=("#555555", "#8a8490"),
|
|
192
|
+
anchor="w",
|
|
193
|
+
wraplength=300,
|
|
194
|
+
)
|
|
195
|
+
self._catch.pack(fill="x", padx=12, pady=(0, 8))
|
|
196
|
+
|
|
197
|
+
# Skills tags
|
|
198
|
+
skills = self._persona.get("skills", [])
|
|
199
|
+
if skills:
|
|
200
|
+
self._skills_frame = ctk.CTkFrame(self, fg_color="transparent")
|
|
201
|
+
self._skills_frame.pack(fill="x", padx=12, pady=(0, 8))
|
|
202
|
+
for skill in skills[:5]:
|
|
203
|
+
tag = ctk.CTkLabel(
|
|
204
|
+
self._skills_frame,
|
|
205
|
+
text=skill,
|
|
206
|
+
font=ctk.CTkFont(family="JetBrains Mono", size=9),
|
|
207
|
+
text_color=self._color,
|
|
208
|
+
fg_color=("#eeeeee", "#0a0a0a"),
|
|
209
|
+
corner_radius=4,
|
|
210
|
+
padx=6,
|
|
211
|
+
pady=2,
|
|
212
|
+
)
|
|
213
|
+
tag.pack(side="left", padx=(0, 4), pady=(0, 4))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ── Quick smoke-test ────────────────────────────────────────────────────────
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
import pprint
|
|
219
|
+
|
|
220
|
+
print("=== All personas ===")
|
|
221
|
+
pprint.pprint(get_all_personas())
|
|
222
|
+
|
|
223
|
+
print("\n=== Get persona 'kevrojo' ===")
|
|
224
|
+
pprint.pprint(get_persona("kevrojo"))
|
|
225
|
+
|
|
226
|
+
print("\n=== Color for 'kimi-code2' ===")
|
|
227
|
+
print(get_color_for_agent("kimi-code2"))
|
|
228
|
+
|
|
229
|
+
print("\n=== Display name for 'dulus' ===")
|
|
230
|
+
print(get_display_name("dulus"))
|
gui/session_utils.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Utility functions for managing Dulus GUI sessions."""
|
|
2
|
+
import json
|
|
3
|
+
import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from config import SESSIONS_DIR, DAILY_DIR, MR_SESSION_DIR, SESSION_HIST_FILE
|
|
7
|
+
|
|
8
|
+
def build_title(messages: list[dict]) -> str:
|
|
9
|
+
"""Generate a descriptive title from the first user message."""
|
|
10
|
+
for m in messages:
|
|
11
|
+
if m.get("role") == "user":
|
|
12
|
+
content = m.get("content", "")
|
|
13
|
+
if isinstance(content, list):
|
|
14
|
+
# Handle multi-modal or list content
|
|
15
|
+
text = " ".join(part.get("text", "") for part in content if isinstance(part, dict))
|
|
16
|
+
else:
|
|
17
|
+
text = str(content)
|
|
18
|
+
|
|
19
|
+
if text.strip():
|
|
20
|
+
clean = text.strip().replace("\n", " ")
|
|
21
|
+
return clean[:40] + ("..." if len(clean) > 40 else "")
|
|
22
|
+
return "Nueva conversación"
|
|
23
|
+
|
|
24
|
+
def scan_sessions() -> list[dict]:
|
|
25
|
+
"""Scan session directories and return sorted list of metadata."""
|
|
26
|
+
sessions: list[dict] = []
|
|
27
|
+
seen: set[str] = set()
|
|
28
|
+
files: list[Path] = []
|
|
29
|
+
|
|
30
|
+
# Daily sessions (newest first)
|
|
31
|
+
if DAILY_DIR.exists():
|
|
32
|
+
for day_dir in sorted(DAILY_DIR.iterdir(), reverse=True):
|
|
33
|
+
if day_dir.is_dir():
|
|
34
|
+
files.extend(sorted(day_dir.glob("session_*.json"), reverse=True))
|
|
35
|
+
|
|
36
|
+
# MR sessions
|
|
37
|
+
if MR_SESSION_DIR.exists():
|
|
38
|
+
files.extend(
|
|
39
|
+
s for s in sorted(MR_SESSION_DIR.glob("*.json"), reverse=True)
|
|
40
|
+
if s.name != "session_latest.json"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Root sessions
|
|
44
|
+
if SESSIONS_DIR.exists():
|
|
45
|
+
files.extend(sorted(SESSIONS_DIR.glob("session_*.json"), reverse=True))
|
|
46
|
+
|
|
47
|
+
for path in files:
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
50
|
+
sid = data.get("session_id", path.stem)
|
|
51
|
+
if sid in seen:
|
|
52
|
+
continue
|
|
53
|
+
seen.add(sid)
|
|
54
|
+
|
|
55
|
+
messages = data.get("messages", [])
|
|
56
|
+
title = build_title(messages)
|
|
57
|
+
|
|
58
|
+
saved_at = data.get("saved_at", "")
|
|
59
|
+
if saved_at and len(saved_at) >= 19:
|
|
60
|
+
# Add time prefix: "HH:MM Title"
|
|
61
|
+
title = f"{saved_at[11:16]} {title}"
|
|
62
|
+
|
|
63
|
+
sessions.append({
|
|
64
|
+
"id": sid,
|
|
65
|
+
"title": title,
|
|
66
|
+
"path": str(path),
|
|
67
|
+
"messages": messages,
|
|
68
|
+
"saved_at": saved_at
|
|
69
|
+
})
|
|
70
|
+
except Exception:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# Sort all found sessions by saved_at DESC
|
|
74
|
+
sessions.sort(key=lambda x: x.get("saved_at", ""), reverse=True)
|
|
75
|
+
return sessions[:50]
|
|
76
|
+
|
|
77
|
+
def save_session(state, config: dict, session_id: str | None = None) -> str:
|
|
78
|
+
"""Save AgentState to disk in standard Dulus format. Returns the session_id."""
|
|
79
|
+
if not state.messages:
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
# User request: Only save if there is at least one user message
|
|
83
|
+
has_user_msg = any(m.get("role") == "user" for m in state.messages)
|
|
84
|
+
if not has_user_msg:
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
sid = session_id or uuid.uuid4().hex[:8]
|
|
88
|
+
now = datetime.datetime.now()
|
|
89
|
+
ts = now.strftime("%H%M%S")
|
|
90
|
+
date_str = now.strftime("%Y-%m-%d")
|
|
91
|
+
|
|
92
|
+
# 1. Build payload
|
|
93
|
+
data = {
|
|
94
|
+
"session_id": sid,
|
|
95
|
+
"saved_at": now.strftime("%Y-%m-%d %H:%M:%S"),
|
|
96
|
+
"messages": state.messages,
|
|
97
|
+
"turn_count": getattr(state, "turn_count", len(state.messages) // 2),
|
|
98
|
+
"total_input_tokens": getattr(state, "total_input_tokens", 0),
|
|
99
|
+
"total_output_tokens": getattr(state, "total_output_tokens", 0),
|
|
100
|
+
}
|
|
101
|
+
payload = json.dumps(data, indent=2, default=str)
|
|
102
|
+
|
|
103
|
+
# 2. Save latest for /resume
|
|
104
|
+
MR_SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
(MR_SESSION_DIR / "session_latest.json").write_text(payload, encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
# 3. Save to daily folder
|
|
108
|
+
day_dir = DAILY_DIR / date_str
|
|
109
|
+
day_dir.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
daily_path = day_dir / f"session_{ts}_{sid}.json"
|
|
111
|
+
daily_path.write_text(payload, encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
# 4. Update history.json
|
|
114
|
+
try:
|
|
115
|
+
hist = {"total_turns": 0, "sessions": []}
|
|
116
|
+
if SESSION_HIST_FILE.exists():
|
|
117
|
+
try:
|
|
118
|
+
hist = json.loads(SESSION_HIST_FILE.read_text())
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
# Update or append
|
|
123
|
+
existing_idx = -1
|
|
124
|
+
for i, s in enumerate(hist.get("sessions", [])):
|
|
125
|
+
if s.get("session_id") == sid:
|
|
126
|
+
existing_idx = i
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
if existing_idx >= 0:
|
|
130
|
+
hist["sessions"][existing_idx] = data
|
|
131
|
+
else:
|
|
132
|
+
hist["sessions"].append(data)
|
|
133
|
+
|
|
134
|
+
# Prune history (keep 200)
|
|
135
|
+
limit = config.get("session_history_limit", 200)
|
|
136
|
+
if len(hist["sessions"]) > limit:
|
|
137
|
+
hist["sessions"] = hist["sessions"][-limit:]
|
|
138
|
+
|
|
139
|
+
SESSION_HIST_FILE.write_text(json.dumps(hist, indent=2, default=str), encoding="utf-8")
|
|
140
|
+
except Exception:
|
|
141
|
+
pass # Don't crash UI if history.json fails
|
|
142
|
+
|
|
143
|
+
return sid
|
|
144
|
+
|
|
145
|
+
def delete_session(session_id: str) -> bool:
|
|
146
|
+
"""Delete all session files related to the given ID. Returns True if any deleted."""
|
|
147
|
+
if not session_id:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
deleted = False
|
|
151
|
+
|
|
152
|
+
# 1. Scan and delete in MR_SESSION_DIR (except latest maybe?)
|
|
153
|
+
if MR_SESSION_DIR.exists():
|
|
154
|
+
for p in MR_SESSION_DIR.glob(f"*{session_id}*"):
|
|
155
|
+
try:
|
|
156
|
+
p.unlink()
|
|
157
|
+
deleted = True
|
|
158
|
+
except: pass
|
|
159
|
+
|
|
160
|
+
# 2. Daily sessions
|
|
161
|
+
if DAILY_DIR.exists():
|
|
162
|
+
for d in DAILY_DIR.iterdir():
|
|
163
|
+
if d.is_dir():
|
|
164
|
+
for p in d.glob(f"*{session_id}*"):
|
|
165
|
+
try:
|
|
166
|
+
p.unlink()
|
|
167
|
+
deleted = True
|
|
168
|
+
except: pass
|
|
169
|
+
|
|
170
|
+
# 3. Root sessions
|
|
171
|
+
if SESSIONS_DIR.exists():
|
|
172
|
+
for p in SESSIONS_DIR.glob(f"*{session_id}*"):
|
|
173
|
+
try:
|
|
174
|
+
p.unlink()
|
|
175
|
+
deleted = True
|
|
176
|
+
except: pass
|
|
177
|
+
|
|
178
|
+
# 4. Update history.json
|
|
179
|
+
if SESSION_HIST_FILE.exists():
|
|
180
|
+
try:
|
|
181
|
+
hist = json.loads(SESSION_HIST_FILE.read_text())
|
|
182
|
+
original_len = len(hist.get("sessions", []))
|
|
183
|
+
hist["sessions"] = [s for s in hist.get("sessions", []) if s.get("session_id") != session_id]
|
|
184
|
+
if len(hist["sessions"]) < original_len:
|
|
185
|
+
SESSION_HIST_FILE.write_text(json.dumps(hist, indent=2, default=str), encoding="utf-8")
|
|
186
|
+
deleted = True
|
|
187
|
+
except: pass
|
|
188
|
+
|
|
189
|
+
return deleted
|
gui/settings_dialog.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Settings popup for Dulus GUI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import customtkinter as ctk
|
|
8
|
+
|
|
9
|
+
from config import save_config
|
|
10
|
+
from gui.themes import list_themes, set_theme
|
|
11
|
+
|
|
12
|
+
THEME = {
|
|
13
|
+
"bg": "#1a1a2e",
|
|
14
|
+
"card": "#16213e",
|
|
15
|
+
"accent": "#00BCD4",
|
|
16
|
+
"accent_hover": "#00acc1",
|
|
17
|
+
"text": "#eaeaea",
|
|
18
|
+
"dim": "#888888",
|
|
19
|
+
"border": "#2a2a4a",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
FONT_FAMILY = "Segoe UI"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_model_list() -> list[str]:
|
|
26
|
+
"""Build list of provider/model strings from PROVIDERS registry."""
|
|
27
|
+
try:
|
|
28
|
+
from providers import PROVIDERS
|
|
29
|
+
models: list[str] = []
|
|
30
|
+
for pname, pmeta in PROVIDERS.items():
|
|
31
|
+
for m in pmeta.get("models", []):
|
|
32
|
+
models.append(f"{pname}/{m}")
|
|
33
|
+
return sorted(models) if models else ["kimi/kimi-k2.5"]
|
|
34
|
+
except Exception:
|
|
35
|
+
return [
|
|
36
|
+
"kimi/kimi-k2.5",
|
|
37
|
+
"openai/gpt-4o",
|
|
38
|
+
"anthropic/claude-3-5-sonnet",
|
|
39
|
+
"deepseek/deepseek-chat",
|
|
40
|
+
"ollama/llama3.3",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SettingsDialog(ctk.CTkToplevel):
|
|
45
|
+
"""Floating settings window."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, master, config: dict) -> None:
|
|
48
|
+
super().__init__(master)
|
|
49
|
+
self.config = config
|
|
50
|
+
self.title("Settings")
|
|
51
|
+
self.geometry("480x520")
|
|
52
|
+
self.configure(fg_color=THEME["bg"])
|
|
53
|
+
self.transient(master)
|
|
54
|
+
self.grab_set()
|
|
55
|
+
|
|
56
|
+
# Center on parent
|
|
57
|
+
self.update_idletasks()
|
|
58
|
+
x = master.winfo_x() + (master.winfo_width() - self.winfo_width()) // 2
|
|
59
|
+
y = master.winfo_y() + (master.winfo_height() - self.winfo_height()) // 2
|
|
60
|
+
self.geometry(f"+{x}+{y}")
|
|
61
|
+
|
|
62
|
+
# Header
|
|
63
|
+
ctk.CTkLabel(
|
|
64
|
+
self,
|
|
65
|
+
text="⚙ Settings",
|
|
66
|
+
font=(FONT_FAMILY, 18, "bold"),
|
|
67
|
+
text_color=THEME["accent"],
|
|
68
|
+
).pack(pady=(20, 15))
|
|
69
|
+
|
|
70
|
+
# Scrollable content
|
|
71
|
+
scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", width=440)
|
|
72
|
+
scroll.pack(fill="both", expand=True, padx=20, pady=5)
|
|
73
|
+
|
|
74
|
+
# Model
|
|
75
|
+
ctk.CTkLabel(scroll, text="Model", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(10, 2))
|
|
76
|
+
self.model_var = ctk.StringVar(value=config.get("model", "kimi/kimi-k2.5"))
|
|
77
|
+
models = _build_model_list()
|
|
78
|
+
ctk.CTkOptionMenu(scroll, values=models, variable=self.model_var, fg_color=THEME["card"]).pack(fill="x", pady=2)
|
|
79
|
+
|
|
80
|
+
# Thinking
|
|
81
|
+
ctk.CTkLabel(scroll, text="Thinking Level", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
|
|
82
|
+
think_val = {0: "off", 1: "min", 2: "med", 3: "max", 4: "raw"}.get(config.get("thinking", 0), "off")
|
|
83
|
+
self.think_var = ctk.StringVar(value=think_val)
|
|
84
|
+
ctk.CTkOptionMenu(scroll, values=["off", "min", "med", "max", "raw"], variable=self.think_var, fg_color=THEME["card"]).pack(fill="x", pady=2)
|
|
85
|
+
|
|
86
|
+
# Verbose
|
|
87
|
+
ctk.CTkLabel(scroll, text="Verbose Mode", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
|
|
88
|
+
self.verbose_var = ctk.BooleanVar(value=config.get("verbose", False))
|
|
89
|
+
ctk.CTkSwitch(scroll, text="Enable verbose output", variable=self.verbose_var, progress_color=THEME["accent"]).pack(anchor="w", pady=2)
|
|
90
|
+
|
|
91
|
+
# Appearance mode
|
|
92
|
+
ctk.CTkLabel(scroll, text="Appearance", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
|
|
93
|
+
self.appearance_var = ctk.StringVar(value=config.get("appearance", "Dark"))
|
|
94
|
+
ctk.CTkOptionMenu(scroll, values=["Dark", "Light", "System"], variable=self.appearance_var, fg_color=THEME["card"]).pack(fill="x", pady=2)
|
|
95
|
+
|
|
96
|
+
# Color theme
|
|
97
|
+
ctk.CTkLabel(scroll, text="Color Theme", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
|
|
98
|
+
self.theme_var = ctk.StringVar(value=config.get("theme", "midnight"))
|
|
99
|
+
ctk.CTkOptionMenu(scroll, values=list_themes(), variable=self.theme_var, fg_color=THEME["card"]).pack(fill="x", pady=2)
|
|
100
|
+
|
|
101
|
+
# API Key (masked)
|
|
102
|
+
ctk.CTkLabel(scroll, text="API Key (active provider)", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
|
|
103
|
+
self.api_var = ctk.StringVar()
|
|
104
|
+
ctk.CTkEntry(scroll, textvariable=self.api_var, show="●", fg_color=THEME["card"], text_color=THEME["text"]).pack(fill="x", pady=2)
|
|
105
|
+
|
|
106
|
+
# Buttons
|
|
107
|
+
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
|
108
|
+
btn_frame.pack(fill="x", padx=20, pady=15)
|
|
109
|
+
|
|
110
|
+
ctk.CTkButton(
|
|
111
|
+
btn_frame,
|
|
112
|
+
text="Cancel",
|
|
113
|
+
fg_color=THEME["border"],
|
|
114
|
+
hover_color="red",
|
|
115
|
+
command=self.destroy,
|
|
116
|
+
).pack(side="left", padx=5)
|
|
117
|
+
|
|
118
|
+
ctk.CTkButton(
|
|
119
|
+
btn_frame,
|
|
120
|
+
text="Save",
|
|
121
|
+
fg_color=THEME["accent"],
|
|
122
|
+
hover_color=THEME["accent_hover"],
|
|
123
|
+
command=self._save,
|
|
124
|
+
).pack(side="right", padx=5)
|
|
125
|
+
|
|
126
|
+
def _save(self) -> None:
|
|
127
|
+
self.config["model"] = self.model_var.get()
|
|
128
|
+
think_map = {"off": 0, "min": 1, "med": 2, "max": 3, "raw": 4}
|
|
129
|
+
self.config["thinking"] = think_map.get(self.think_var.get(), 0)
|
|
130
|
+
self.config["verbose"] = self.verbose_var.get()
|
|
131
|
+
self.config["appearance"] = self.appearance_var.get()
|
|
132
|
+
self.config["theme"] = self.theme_var.get()
|
|
133
|
+
ctk.set_appearance_mode(self.appearance_var.get())
|
|
134
|
+
# Notify parent to apply color theme
|
|
135
|
+
if hasattr(self.master, "apply_theme"):
|
|
136
|
+
self.master.apply_theme(self.theme_var.get())
|
|
137
|
+
key = self.api_var.get().strip()
|
|
138
|
+
if key:
|
|
139
|
+
pname = self.config.get("model", "").split("/")[0]
|
|
140
|
+
if pname:
|
|
141
|
+
self.config[f"{pname}_api_key"] = key
|
|
142
|
+
try:
|
|
143
|
+
save_config(self.config)
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
self.destroy()
|