pythonclaw 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.
Files changed (112) hide show
  1. pythonclaw/__init__.py +17 -0
  2. pythonclaw/__main__.py +6 -0
  3. pythonclaw/channels/discord_bot.py +231 -0
  4. pythonclaw/channels/telegram_bot.py +236 -0
  5. pythonclaw/config.py +190 -0
  6. pythonclaw/core/__init__.py +25 -0
  7. pythonclaw/core/agent.py +773 -0
  8. pythonclaw/core/compaction.py +220 -0
  9. pythonclaw/core/knowledge/rag.py +93 -0
  10. pythonclaw/core/llm/anthropic_client.py +107 -0
  11. pythonclaw/core/llm/base.py +26 -0
  12. pythonclaw/core/llm/gemini_client.py +139 -0
  13. pythonclaw/core/llm/openai_compatible.py +39 -0
  14. pythonclaw/core/llm/response.py +57 -0
  15. pythonclaw/core/memory/manager.py +120 -0
  16. pythonclaw/core/memory/storage.py +164 -0
  17. pythonclaw/core/persistent_agent.py +103 -0
  18. pythonclaw/core/retrieval/__init__.py +6 -0
  19. pythonclaw/core/retrieval/chunker.py +78 -0
  20. pythonclaw/core/retrieval/dense.py +152 -0
  21. pythonclaw/core/retrieval/fusion.py +51 -0
  22. pythonclaw/core/retrieval/reranker.py +112 -0
  23. pythonclaw/core/retrieval/retriever.py +166 -0
  24. pythonclaw/core/retrieval/sparse.py +69 -0
  25. pythonclaw/core/session_store.py +269 -0
  26. pythonclaw/core/skill_loader.py +322 -0
  27. pythonclaw/core/skillhub.py +290 -0
  28. pythonclaw/core/tools.py +622 -0
  29. pythonclaw/core/utils.py +64 -0
  30. pythonclaw/daemon.py +221 -0
  31. pythonclaw/init.py +61 -0
  32. pythonclaw/main.py +489 -0
  33. pythonclaw/onboard.py +290 -0
  34. pythonclaw/scheduler/cron.py +310 -0
  35. pythonclaw/scheduler/heartbeat.py +178 -0
  36. pythonclaw/server.py +145 -0
  37. pythonclaw/session_manager.py +104 -0
  38. pythonclaw/templates/persona/demo_persona.md +2 -0
  39. pythonclaw/templates/skills/communication/CATEGORY.md +4 -0
  40. pythonclaw/templates/skills/communication/email/SKILL.md +54 -0
  41. pythonclaw/templates/skills/communication/email/__pycache__/send_email.cpython-311.pyc +0 -0
  42. pythonclaw/templates/skills/communication/email/send_email.py +88 -0
  43. pythonclaw/templates/skills/data/CATEGORY.md +4 -0
  44. pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +51 -0
  45. pythonclaw/templates/skills/data/csv_analyzer/__pycache__/analyze.cpython-311.pyc +0 -0
  46. pythonclaw/templates/skills/data/csv_analyzer/analyze.py +138 -0
  47. pythonclaw/templates/skills/data/finance/SKILL.md +41 -0
  48. pythonclaw/templates/skills/data/finance/__pycache__/fetch_quote.cpython-311.pyc +0 -0
  49. pythonclaw/templates/skills/data/finance/fetch_quote.py +118 -0
  50. pythonclaw/templates/skills/data/news/SKILL.md +39 -0
  51. pythonclaw/templates/skills/data/news/__pycache__/search_news.cpython-311.pyc +0 -0
  52. pythonclaw/templates/skills/data/news/search_news.py +57 -0
  53. pythonclaw/templates/skills/data/pdf_reader/SKILL.md +40 -0
  54. pythonclaw/templates/skills/data/pdf_reader/__pycache__/read_pdf.cpython-311.pyc +0 -0
  55. pythonclaw/templates/skills/data/pdf_reader/read_pdf.py +113 -0
  56. pythonclaw/templates/skills/data/scraper/SKILL.md +39 -0
  57. pythonclaw/templates/skills/data/scraper/__pycache__/scrape.cpython-311.pyc +0 -0
  58. pythonclaw/templates/skills/data/scraper/scrape.py +92 -0
  59. pythonclaw/templates/skills/data/weather/SKILL.md +42 -0
  60. pythonclaw/templates/skills/data/weather/__pycache__/weather.cpython-311.pyc +0 -0
  61. pythonclaw/templates/skills/data/weather/weather.py +142 -0
  62. pythonclaw/templates/skills/data/youtube/SKILL.md +43 -0
  63. pythonclaw/templates/skills/data/youtube/__pycache__/youtube_info.cpython-311.pyc +0 -0
  64. pythonclaw/templates/skills/data/youtube/youtube_info.py +167 -0
  65. pythonclaw/templates/skills/dev/CATEGORY.md +4 -0
  66. pythonclaw/templates/skills/dev/code_runner/SKILL.md +46 -0
  67. pythonclaw/templates/skills/dev/code_runner/__pycache__/run_code.cpython-311.pyc +0 -0
  68. pythonclaw/templates/skills/dev/code_runner/run_code.py +117 -0
  69. pythonclaw/templates/skills/dev/github/SKILL.md +52 -0
  70. pythonclaw/templates/skills/dev/github/__pycache__/gh.cpython-311.pyc +0 -0
  71. pythonclaw/templates/skills/dev/github/gh.py +165 -0
  72. pythonclaw/templates/skills/dev/http_request/SKILL.md +40 -0
  73. pythonclaw/templates/skills/dev/http_request/__pycache__/request.cpython-311.pyc +0 -0
  74. pythonclaw/templates/skills/dev/http_request/request.py +90 -0
  75. pythonclaw/templates/skills/google/CATEGORY.md +4 -0
  76. pythonclaw/templates/skills/google/workspace/SKILL.md +98 -0
  77. pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
  78. pythonclaw/templates/skills/meta/CATEGORY.md +4 -0
  79. pythonclaw/templates/skills/meta/skill_creator/SKILL.md +151 -0
  80. pythonclaw/templates/skills/system/CATEGORY.md +4 -0
  81. pythonclaw/templates/skills/system/change_persona/SKILL.md +41 -0
  82. pythonclaw/templates/skills/system/change_setting/SKILL.md +65 -0
  83. pythonclaw/templates/skills/system/change_setting/__pycache__/update_config.cpython-311.pyc +0 -0
  84. pythonclaw/templates/skills/system/change_setting/update_config.py +129 -0
  85. pythonclaw/templates/skills/system/change_soul/SKILL.md +41 -0
  86. pythonclaw/templates/skills/system/onboarding/SKILL.md +63 -0
  87. pythonclaw/templates/skills/system/onboarding/__pycache__/write_identity.cpython-311.pyc +0 -0
  88. pythonclaw/templates/skills/system/onboarding/write_identity.py +218 -0
  89. pythonclaw/templates/skills/system/random/SKILL.md +33 -0
  90. pythonclaw/templates/skills/system/random/__pycache__/random_util.cpython-311.pyc +0 -0
  91. pythonclaw/templates/skills/system/random/random_util.py +45 -0
  92. pythonclaw/templates/skills/system/time/SKILL.md +33 -0
  93. pythonclaw/templates/skills/system/time/__pycache__/time_util.cpython-311.pyc +0 -0
  94. pythonclaw/templates/skills/system/time/time_util.py +81 -0
  95. pythonclaw/templates/skills/text/CATEGORY.md +4 -0
  96. pythonclaw/templates/skills/text/translator/SKILL.md +47 -0
  97. pythonclaw/templates/skills/text/translator/__pycache__/translate.cpython-311.pyc +0 -0
  98. pythonclaw/templates/skills/text/translator/translate.py +66 -0
  99. pythonclaw/templates/skills/web/CATEGORY.md +4 -0
  100. pythonclaw/templates/skills/web/tavily/SKILL.md +61 -0
  101. pythonclaw/templates/soul/SOUL.md +54 -0
  102. pythonclaw/web/__init__.py +1 -0
  103. pythonclaw/web/app.py +585 -0
  104. pythonclaw/web/static/favicon.png +0 -0
  105. pythonclaw/web/static/index.html +1318 -0
  106. pythonclaw/web/static/logo.png +0 -0
  107. pythonclaw-0.2.0.dist-info/METADATA +410 -0
  108. pythonclaw-0.2.0.dist-info/RECORD +112 -0
  109. pythonclaw-0.2.0.dist-info/WHEEL +5 -0
  110. pythonclaw-0.2.0.dist-info/entry_points.txt +2 -0
  111. pythonclaw-0.2.0.dist-info/licenses/LICENSE +21 -0
  112. pythonclaw-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1318 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="h-full">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>PythonClaw Dashboard</title>
7
+ <link rel="icon" type="image/png" href="/static/favicon.png">
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <script>
10
+ tailwind.config = {
11
+ darkMode: 'class',
12
+ theme: {
13
+ extend: {
14
+ colors: {
15
+ brand: { 50:'#eff6ff', 100:'#dbeafe', 200:'#bfdbfe', 300:'#93c5fd', 400:'#60a5fa', 500:'#3b82f6', 600:'#2563eb', 700:'#1d4ed8', 800:'#1e40af', 900:'#1e3a5f' },
16
+ surface: { 50:'#f8fafc', 100:'#f1f5f9', 200:'#e2e8f0', 300:'#cbd5e1', 700:'#334155', 750:'#283548', 800:'#1e293b', 850:'#172033', 900:'#0f172a', 950:'#080e1b' },
17
+ accent: { green:'#34d399', red:'#f87171', amber:'#fbbf24', purple:'#a78bfa' },
18
+ }
19
+ }
20
+ }
21
+ }
22
+ </script>
23
+ <style>
24
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
25
+ * { box-sizing: border-box; }
26
+ body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
27
+ code, pre, .font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
28
+
29
+ .fade-in { animation: fadeIn .25s ease-out; }
30
+ @keyframes fadeIn { from { opacity:0; transform:translateY(6px) } to { opacity:1; transform:translateY(0) } }
31
+
32
+ .chat-scroll::-webkit-scrollbar { width: 5px; }
33
+ .chat-scroll::-webkit-scrollbar-track { background: transparent; }
34
+ .chat-scroll::-webkit-scrollbar-thumb { background: #334155; border-radius: 99px; }
35
+ .chat-scroll::-webkit-scrollbar-thumb:hover { background: #475569; }
36
+
37
+ .msg-user { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); }
38
+ .msg-bot { background: #1e293b; border: 1px solid rgba(51,65,85,.6); }
39
+
40
+ .skill-card { transition: all .2s ease; }
41
+ .skill-card:hover { border-color: #3b82f6; transform: translateY(-2px); box-shadow: 0 8px 25px -5px rgba(59,130,246,.15); }
42
+
43
+ .stat-card { transition: all .2s ease; }
44
+ .stat-card:hover { border-color: rgba(59,130,246,.3); }
45
+
46
+ .nav-item { transition: all .15s ease; }
47
+ .nav-item:hover { background: rgba(30,41,59,.7); }
48
+ .nav-item.active { background: rgba(37,99,235,.12); color: #60a5fa; border-right: 2px solid #3b82f6; }
49
+
50
+ .pulse-dot { animation: pulseDot 1.4s ease-in-out infinite; }
51
+ @keyframes pulseDot { 0%,100%{opacity:1} 50%{opacity:.3} }
52
+
53
+ .input-field {
54
+ background: #0f172a; border: 1px solid #334155; border-radius: .5rem;
55
+ padding: .5rem .75rem; font-size: .8125rem; color: #e2e8f0;
56
+ transition: border-color .2s; width: 100%; outline: none;
57
+ }
58
+ .input-field:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,.15); }
59
+ .input-field::placeholder { color: #475569; }
60
+
61
+ select.input-field { appearance: none;
62
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23475569' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
63
+ background-repeat: no-repeat; background-position: right .75rem center; padding-right: 2rem;
64
+ }
65
+
66
+ .section-card {
67
+ background: #1e293b; border: 1px solid rgba(51,65,85,.5);
68
+ border-radius: .75rem; padding: 1.25rem 1.5rem;
69
+ }
70
+ .cfg-section {
71
+ padding-bottom: 1.75rem; margin-bottom: 1.75rem;
72
+ border-bottom: 1px solid rgba(51,65,85,.35);
73
+ }
74
+ .cfg-section:last-of-type { border-bottom: none; margin-bottom: 0; }
75
+ .cfg-title {
76
+ font-size: 1rem; font-weight: 600; color: white;
77
+ margin-bottom: 1.25rem; letter-spacing: -.01em;
78
+ }
79
+
80
+ .btn-primary {
81
+ background: linear-gradient(135deg, #2563eb, #1d4ed8); color: white;
82
+ padding: .5rem 1.5rem; border-radius: .5rem; font-size: .8125rem;
83
+ font-weight: 500; transition: all .2s; border: none; cursor: pointer;
84
+ }
85
+ .btn-primary:hover { box-shadow: 0 4px 15px rgba(37,99,235,.35); transform: translateY(-1px); }
86
+ .btn-primary:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; }
87
+
88
+ .btn-ghost {
89
+ background: rgba(15,23,42,.6); border: 1px solid #334155; color: #94a3b8;
90
+ padding: .375rem .75rem; border-radius: .5rem; font-size: .75rem;
91
+ transition: all .15s; cursor: pointer;
92
+ }
93
+ .btn-ghost:hover { background: #1e293b; color: #e2e8f0; border-color: #475569; }
94
+ </style>
95
+ </head>
96
+ <body class="h-full bg-surface-950 text-gray-300 overflow-hidden">
97
+ <div class="flex h-full">
98
+
99
+ <!-- Sidebar -->
100
+ <aside class="w-[220px] bg-surface-900 border-r border-surface-700/50 flex flex-col shrink-0">
101
+ <!-- Logo -->
102
+ <div class="px-5 py-5 border-b border-surface-700/50">
103
+ <div class="flex items-center gap-3">
104
+ <img src="/static/logo.png" alt="PythonClaw" class="w-8 h-8 rounded-lg shadow-lg">
105
+ <div>
106
+ <div class="font-semibold text-white text-[.8125rem] tracking-tight">PythonClaw</div>
107
+ <div class="text-[.625rem] text-gray-500 font-medium tracking-wide uppercase">Agent Dashboard</div>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <!-- Navigation -->
113
+ <nav class="flex-1 py-4 px-3 space-y-0.5">
114
+ <button onclick="switchTab('dashboard')" id="nav-dashboard" class="nav-item w-full text-left px-3 py-2.5 rounded-lg text-sm flex items-center gap-3 text-gray-400 font-medium">
115
+ <svg class="w-[18px] h-[18px] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
116
+ Dashboard
117
+ </button>
118
+ <button onclick="switchTab('chat')" id="nav-chat" class="nav-item w-full text-left px-3 py-2.5 rounded-lg text-sm flex items-center gap-3 text-gray-400 font-medium">
119
+ <svg class="w-[18px] h-[18px] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg>
120
+ Chat
121
+ </button>
122
+ <button onclick="switchTab('skills')" id="nav-skills" class="nav-item w-full text-left px-3 py-2.5 rounded-lg text-sm flex items-center gap-3 text-gray-400 font-medium">
123
+ <svg class="w-[18px] h-[18px] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
124
+ Skills
125
+ </button>
126
+ <button onclick="switchTab('marketplace')" id="nav-marketplace" class="nav-item w-full text-left px-3 py-2.5 rounded-lg text-sm flex items-center gap-3 text-gray-400 font-medium">
127
+ <svg class="w-[18px] h-[18px] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>
128
+ Marketplace
129
+ </button>
130
+ <button onclick="switchTab('config')" id="nav-config" class="nav-item w-full text-left px-3 py-2.5 rounded-lg text-sm flex items-center gap-3 text-gray-400 font-medium">
131
+ <svg class="w-[18px] h-[18px] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
132
+ Config
133
+ </button>
134
+ </nav>
135
+
136
+ <!-- Connection Status -->
137
+ <div class="px-5 py-4 border-t border-surface-700/50">
138
+ <div class="flex items-center gap-2.5">
139
+ <div id="ws-indicator" class="w-2 h-2 rounded-full bg-red-400 shrink-0"></div>
140
+ <span id="ws-status" class="text-[.6875rem] text-gray-500">Disconnected</span>
141
+ </div>
142
+ </div>
143
+ </aside>
144
+
145
+ <!-- Main Content -->
146
+ <main class="flex-1 flex flex-col overflow-hidden bg-surface-950">
147
+
148
+ <!-- Header -->
149
+ <header class="h-[48px] bg-surface-950 border-b border-surface-700/30 flex items-center px-8 shrink-0">
150
+ <h1 id="page-title" class="text-sm font-medium text-gray-400 tracking-tight">Dashboard</h1>
151
+ <div class="ml-auto flex items-center gap-3">
152
+ <span id="header-provider" class="text-[.6875rem] bg-brand-900/30 text-brand-300 px-2.5 py-1 rounded-full font-medium"></span>
153
+ </div>
154
+ </header>
155
+
156
+ <!-- Tab Content -->
157
+ <div class="flex-1 overflow-auto">
158
+
159
+ <!-- ═══ Dashboard Tab ═══ -->
160
+ <div id="tab-dashboard" class="tab-content p-8 fade-in max-w-[1200px]">
161
+ <h2 class="text-xl font-bold text-white tracking-tight mb-6">Dashboard</h2>
162
+ <div id="dash-banner" class="hidden mb-5 bg-amber-500/10 border border-amber-500/20 rounded-lg px-5 py-3 text-sm text-amber-300 fade-in">
163
+ <strong>LLM provider not configured.</strong> Go to <a onclick="switchTab('config')" class="underline cursor-pointer text-amber-200 hover:text-white ml-1">Config</a> to set your API key.
164
+ </div>
165
+
166
+ <!-- Stats Row -->
167
+ <div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
168
+ <div class="stat-card section-card flex items-start gap-3.5">
169
+ <div class="w-9 h-9 rounded-lg bg-brand-600/15 flex items-center justify-center shrink-0 mt-0.5">
170
+ <svg class="w-4.5 h-4.5 text-brand-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/></svg>
171
+ </div>
172
+ <div class="min-w-0">
173
+ <div class="text-[.6875rem] text-gray-500 font-medium uppercase tracking-wider mb-1">Provider</div>
174
+ <div id="stat-provider" class="text-base font-semibold text-white truncate">--</div>
175
+ <div id="stat-provider-class" class="text-[.6875rem] text-gray-500 truncate">--</div>
176
+ </div>
177
+ </div>
178
+ <div class="stat-card section-card flex items-start gap-3.5">
179
+ <div class="w-9 h-9 rounded-lg bg-purple-500/15 flex items-center justify-center shrink-0 mt-0.5">
180
+ <svg class="w-4.5 h-4.5 text-accent-purple" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
181
+ </div>
182
+ <div class="min-w-0">
183
+ <div class="text-[.6875rem] text-gray-500 font-medium uppercase tracking-wider mb-1">Skills</div>
184
+ <div class="flex items-baseline gap-1.5">
185
+ <span id="stat-skills-loaded" class="text-base font-semibold text-white">0</span>
186
+ <span class="text-[.6875rem] text-gray-500">/</span>
187
+ <span id="stat-skills-total" class="text-[.6875rem] text-gray-400">0</span>
188
+ </div>
189
+ <div class="text-[.6875rem] text-gray-500">loaded / total</div>
190
+ </div>
191
+ </div>
192
+ <div class="stat-card section-card flex items-start gap-3.5">
193
+ <div class="w-9 h-9 rounded-lg bg-emerald-500/15 flex items-center justify-center shrink-0 mt-0.5">
194
+ <svg class="w-4.5 h-4.5 text-accent-green" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M12 2a10 10 0 100 20 10 10 0 000-20z"/><path d="M12 6v6l4 2"/></svg>
195
+ </div>
196
+ <div class="min-w-0">
197
+ <div class="text-[.6875rem] text-gray-500 font-medium uppercase tracking-wider mb-1">Memories</div>
198
+ <div id="stat-memories" class="text-base font-semibold text-white">0</div>
199
+ <div class="text-[.6875rem] text-gray-500">entries stored</div>
200
+ </div>
201
+ </div>
202
+ <div class="stat-card section-card flex items-start gap-3.5">
203
+ <div class="w-9 h-9 rounded-lg bg-amber-500/15 flex items-center justify-center shrink-0 mt-0.5">
204
+ <svg class="w-4.5 h-4.5 text-accent-amber" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
205
+ </div>
206
+ <div class="min-w-0">
207
+ <div class="text-[.6875rem] text-gray-500 font-medium uppercase tracking-wider mb-1">Uptime</div>
208
+ <div id="stat-uptime" class="text-base font-semibold text-white">0s</div>
209
+ <div id="stat-history" class="text-[.6875rem] text-gray-500">0 msgs in history</div>
210
+ </div>
211
+ </div>
212
+ </div>
213
+
214
+ <!-- Identity & Status Row -->
215
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
216
+ <!-- Soul -->
217
+ <div class="section-card">
218
+ <h3 class="text-[.8125rem] font-semibold text-white mb-3 flex items-center gap-2">
219
+ <svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
220
+ Soul
221
+ <span id="soul-badge" class="px-2 py-0.5 rounded-full text-[.625rem] font-medium bg-gray-800 text-gray-500">--</span>
222
+ <button onclick="openIdentityEditor('soul')" class="ml-auto btn-ghost !px-2 !py-1 text-[.6875rem]" title="Edit Soul">
223
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
224
+ </button>
225
+ </h3>
226
+ <div id="soul-content" class="text-[.75rem] text-gray-400 leading-relaxed max-h-[120px] overflow-y-auto chat-scroll cursor-pointer" onclick="openIdentityEditor('soul')">
227
+ <span class="text-gray-600 italic">Not configured</span>
228
+ </div>
229
+ </div>
230
+
231
+ <!-- Persona -->
232
+ <div class="section-card">
233
+ <h3 class="text-[.8125rem] font-semibold text-white mb-3 flex items-center gap-2">
234
+ <svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
235
+ Persona
236
+ <span id="persona-badge" class="px-2 py-0.5 rounded-full text-[.625rem] font-medium bg-gray-800 text-gray-500">--</span>
237
+ <button onclick="openIdentityEditor('persona')" class="ml-auto btn-ghost !px-2 !py-1 text-[.6875rem]" title="Edit Persona">
238
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
239
+ </button>
240
+ </h3>
241
+ <div id="persona-content" class="text-[.75rem] text-gray-400 leading-relaxed max-h-[120px] overflow-y-auto chat-scroll cursor-pointer" onclick="openIdentityEditor('persona')">
242
+ <span class="text-gray-600 italic">Not configured</span>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- System Status -->
247
+ <div class="section-card">
248
+ <h3 class="text-[.8125rem] font-semibold text-white mb-3 flex items-center gap-2">
249
+ <svg class="w-4 h-4 text-accent-green" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
250
+ System Status
251
+ </h3>
252
+ <div class="space-y-2.5">
253
+ <div class="flex items-center justify-between">
254
+ <span class="text-[.75rem] text-gray-400">Web Search</span>
255
+ <span id="feat-websearch" class="px-2 py-0.5 rounded-full text-[.625rem] font-medium">--</span>
256
+ </div>
257
+ <div class="flex items-center justify-between">
258
+ <span class="text-[.75rem] text-gray-400">Compactions</span>
259
+ <span id="feat-compactions" class="text-[.75rem] text-gray-200 font-medium">0</span>
260
+ </div>
261
+ <div class="flex items-center justify-between">
262
+ <span class="text-[.75rem] text-gray-400">WebSocket</span>
263
+ <span id="feat-ws-status" class="px-2 py-0.5 rounded-full text-[.625rem] font-medium bg-gray-800 text-gray-500">--</span>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+
269
+ <!-- Tools & Quick Actions Row -->
270
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
271
+ <!-- Tools List -->
272
+ <div class="section-card lg:col-span-2">
273
+ <h3 class="text-[.8125rem] font-semibold text-white mb-3 flex items-center gap-2">
274
+ <svg class="w-4 h-4 text-brand-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
275
+ Available Tools
276
+ <span id="tools-count" class="text-[.625rem] text-gray-500 font-normal ml-1">0</span>
277
+ </h3>
278
+ <div id="tools-list" class="grid grid-cols-1 sm:grid-cols-2 gap-1.5 max-h-[200px] overflow-y-auto chat-scroll">
279
+ </div>
280
+ </div>
281
+
282
+ <!-- Quick Actions -->
283
+ <div class="section-card">
284
+ <h3 class="text-[.8125rem] font-semibold text-white mb-3 flex items-center gap-2">
285
+ <svg class="w-4 h-4 text-accent-amber" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
286
+ Quick Actions
287
+ </h3>
288
+ <div class="grid grid-cols-1 gap-2">
289
+ <button onclick="switchTab('chat')" class="btn-ghost flex items-center gap-2 py-2 px-3 text-left">
290
+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg>
291
+ Open Chat
292
+ </button>
293
+ <button onclick="switchTab('skills')" class="btn-ghost flex items-center gap-2 py-2 px-3 text-left">
294
+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/></svg>
295
+ Browse Skills
296
+ </button>
297
+ <button onclick="switchTab('config')" class="btn-ghost flex items-center gap-2 py-2 px-3 text-left">
298
+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/></svg>
299
+ Settings
300
+ </button>
301
+ <button onclick="refreshDashboard()" class="btn-ghost flex items-center gap-2 py-2 px-3 text-left">
302
+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
303
+ Refresh
304
+ </button>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ </div>
309
+
310
+ <!-- ═══ Chat Tab ═══ -->
311
+ <div id="tab-chat" class="tab-content hidden h-full flex flex-col">
312
+ <div id="chat-messages" class="flex-1 overflow-y-auto chat-scroll px-6 py-6 space-y-4">
313
+ <div class="text-center py-16">
314
+ <img src="/static/logo.png" alt="PythonClaw" class="w-14 h-14 mx-auto mb-4 rounded-xl opacity-50">
315
+ <p class="text-gray-500 text-[.8125rem] mb-1">Send a message to start chatting.</p>
316
+ <p class="text-[.6875rem] text-gray-600">Commands: <code class="text-gray-500">/compact</code> <code class="text-gray-500">/status</code> <code class="text-gray-500">/clear</code></p>
317
+ </div>
318
+ </div>
319
+ <div class="border-t border-surface-700/50 px-6 py-4 bg-surface-900/60 backdrop-blur-sm shrink-0">
320
+ <form id="chat-form" class="flex gap-3 max-w-[900px] mx-auto items-center">
321
+ <input id="chat-input" type="text" placeholder="Type a message..."
322
+ class="input-field flex-1 !rounded-xl !py-3 !px-4 !bg-surface-950 !text-[.8125rem]"
323
+ autocomplete="off">
324
+ <button type="button" id="mic-btn" onclick="toggleMic()" title="Voice input"
325
+ class="w-10 h-10 flex items-center justify-center rounded-xl bg-surface-800 hover:bg-surface-700 text-gray-400 hover:text-white transition-colors shrink-0">
326
+ <svg id="mic-icon" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
327
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
328
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
329
+ <line x1="12" y1="19" x2="12" y2="23"/>
330
+ <line x1="8" y1="23" x2="16" y2="23"/>
331
+ </svg>
332
+ </button>
333
+ <button type="submit" id="chat-send" class="btn-primary !rounded-xl !px-6 !py-3">
334
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg>
335
+ </button>
336
+ </form>
337
+ </div>
338
+ </div>
339
+
340
+ <!-- ═══ Skills Tab ═══ -->
341
+ <div id="tab-skills" class="tab-content hidden p-8 fade-in max-w-[1200px]">
342
+ <div class="flex items-center justify-between mb-6">
343
+ <div>
344
+ <h2 class="text-xl font-bold text-white tracking-tight">Skill Catalog</h2>
345
+ <p id="skills-count" class="text-sm text-gray-500 mt-1">Loading...</p>
346
+ </div>
347
+ <button onclick="loadSkills()" class="btn-ghost">Refresh</button>
348
+ </div>
349
+ <div id="skills-list" class="space-y-8"></div>
350
+ </div>
351
+
352
+ <!-- ═══ Marketplace Tab ═══ -->
353
+ <div id="tab-marketplace" class="tab-content hidden p-8 fade-in max-w-[1200px]">
354
+ <div class="flex items-center justify-between mb-6">
355
+ <div>
356
+ <h2 class="text-xl font-bold text-white tracking-tight">SkillHub Marketplace</h2>
357
+ <p class="text-sm text-gray-500 mt-1">Search and install community skills from <a href="https://www.skillhub.club" target="_blank" class="text-brand-400 hover:underline">skillhub.club</a></p>
358
+ </div>
359
+ </div>
360
+
361
+ <!-- Search Bar -->
362
+ <div class="flex gap-3 mb-6">
363
+ <input id="sh-search-input" type="text" placeholder="Search skills… (e.g. database, frontend, debugging)"
364
+ class="input-field flex-1 !rounded-xl !py-3 !px-4 !bg-surface-950 text-sm"
365
+ onkeydown="if(event.key==='Enter'){event.preventDefault();skillhubSearch();}">
366
+ <button onclick="skillhubSearch()" class="btn-primary !rounded-xl !px-6 !py-3 text-sm font-medium">
367
+ <svg class="w-4 h-4 inline mr-1.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
368
+ Search
369
+ </button>
370
+ <button onclick="skillhubBrowse()" class="btn-ghost !rounded-xl !px-5 !py-3 text-sm">Browse Top</button>
371
+ </div>
372
+
373
+ <!-- Results -->
374
+ <div id="sh-status" class="text-sm text-gray-500 mb-4 hidden"></div>
375
+ <div id="sh-results" class="grid grid-cols-1 md:grid-cols-2 gap-4"></div>
376
+ </div>
377
+
378
+ <!-- ═══ Config Tab ═══ -->
379
+ <div id="tab-config" class="tab-content hidden p-8 fade-in max-w-[900px]">
380
+ <div class="mb-8">
381
+ <h2 class="text-xl font-bold text-white tracking-tight">Configuration</h2>
382
+ <p id="config-path" class="text-sm text-gray-500 mt-1">Loading...</p>
383
+ </div>
384
+
385
+ <div id="config-banner" class="hidden mb-6 rounded-lg px-5 py-3 text-sm"></div>
386
+
387
+ <div id="config-sections">
388
+
389
+ <!-- LLM Provider -->
390
+ <div class="cfg-section">
391
+ <h3 class="cfg-title">LLM Provider</h3>
392
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
393
+ <div>
394
+ <label class="block text-xs text-gray-500 mb-1.5">Provider</label>
395
+ <select id="cfg-provider" class="input-field">
396
+ <option value="deepseek">DeepSeek</option>
397
+ <option value="grok">Grok</option>
398
+ <option value="claude">Claude</option>
399
+ <option value="gemini">Gemini</option>
400
+ <option value="kimi">Kimi (Moonshot)</option>
401
+ <option value="glm">GLM (Zhipu)</option>
402
+ </select>
403
+ </div>
404
+ <div>
405
+ <label class="block text-xs text-gray-500 mb-1.5">API Key</label>
406
+ <input id="cfg-apikey" type="password" placeholder="Enter your API key" class="input-field">
407
+ </div>
408
+ <div>
409
+ <label class="block text-xs text-gray-500 mb-1.5">Model</label>
410
+ <input id="cfg-model" type="text" placeholder="e.g. deepseek-chat" class="input-field">
411
+ </div>
412
+ <div>
413
+ <label class="block text-xs text-gray-500 mb-1.5">Base URL (optional)</label>
414
+ <input id="cfg-baseurl" type="text" placeholder="https://api.deepseek.com/v1" class="input-field">
415
+ </div>
416
+ </div>
417
+ </div>
418
+
419
+ <!-- Tavily -->
420
+ <div class="cfg-section">
421
+ <h3 class="cfg-title">Web Search (Tavily)</h3>
422
+ <div>
423
+ <label class="block text-xs text-gray-500 mb-1.5">Tavily API Key</label>
424
+ <input id="cfg-tavily-key" type="password" placeholder="Get one at app.tavily.com" class="input-field">
425
+ </div>
426
+ </div>
427
+
428
+ <!-- SkillHub -->
429
+ <div class="cfg-section">
430
+ <h3 class="cfg-title">SkillHub Marketplace</h3>
431
+ <div>
432
+ <label class="block text-xs text-gray-500 mb-1.5">SkillHub API Key</label>
433
+ <input id="cfg-skillhub-key" type="password" placeholder="Get one at skillhub.club" class="input-field">
434
+ </div>
435
+ </div>
436
+
437
+ <!-- Deepgram -->
438
+ <div class="cfg-section">
439
+ <h3 class="cfg-title">Voice Input (Deepgram)</h3>
440
+ <div>
441
+ <label class="block text-xs text-gray-500 mb-1.5">Deepgram API Key</label>
442
+ <input id="cfg-deepgram-key" type="password" placeholder="Get one at console.deepgram.com" class="input-field">
443
+ </div>
444
+ </div>
445
+
446
+ <!-- Skills Credentials -->
447
+ <div class="cfg-section">
448
+ <h3 class="cfg-title">Skill Credentials</h3>
449
+ <p class="text-xs text-gray-500 -mt-3 mb-5">API keys used by built-in skills. Leave blank if unused.</p>
450
+
451
+ <div class="mb-6">
452
+ <div class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Email (SMTP)</div>
453
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3">
454
+ <div>
455
+ <label class="block text-xs text-gray-500 mb-1.5">SMTP Server</label>
456
+ <input id="cfg-email-smtp" type="text" placeholder="smtp.gmail.com" class="input-field">
457
+ </div>
458
+ <div>
459
+ <label class="block text-xs text-gray-500 mb-1.5">Port</label>
460
+ <input id="cfg-email-port" type="number" placeholder="587" class="input-field">
461
+ </div>
462
+ <div>
463
+ <label class="block text-xs text-gray-500 mb-1.5">Sender Email</label>
464
+ <input id="cfg-email-sender" type="email" placeholder="you@gmail.com" class="input-field">
465
+ </div>
466
+ <div>
467
+ <label class="block text-xs text-gray-500 mb-1.5">App Password</label>
468
+ <input id="cfg-email-password" type="password" placeholder="Enter app password" class="input-field">
469
+ </div>
470
+ </div>
471
+ </div>
472
+
473
+ <div>
474
+ <div class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">GitHub</div>
475
+ <div>
476
+ <label class="block text-xs text-gray-500 mb-1.5">Personal Access Token</label>
477
+ <input id="cfg-github-token" type="password" placeholder="ghp_xxxxxxxxxx" class="input-field">
478
+ </div>
479
+ </div>
480
+ </div>
481
+
482
+ <!-- Web Dashboard -->
483
+ <div class="cfg-section">
484
+ <h3 class="cfg-title">Web Dashboard</h3>
485
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
486
+ <div>
487
+ <label class="block text-xs text-gray-500 mb-1.5">Host</label>
488
+ <input id="cfg-web-host" type="text" placeholder="0.0.0.0" class="input-field">
489
+ </div>
490
+ <div>
491
+ <label class="block text-xs text-gray-500 mb-1.5">Port</label>
492
+ <input id="cfg-web-port" type="number" placeholder="7788" class="input-field">
493
+ </div>
494
+ </div>
495
+ </div>
496
+
497
+ <!-- Save -->
498
+ <div class="flex items-center gap-4 mb-8">
499
+ <button onclick="saveConfig()" id="config-save-btn" class="btn-primary !py-2.5 !px-8">Save & Apply</button>
500
+ <span id="config-save-status" class="text-sm text-gray-500"></span>
501
+ </div>
502
+
503
+ <!-- Advanced JSON -->
504
+ <details class="cfg-section !border-b-0">
505
+ <summary class="cfg-title cursor-pointer flex items-center gap-2 select-none hover:text-brand-400 transition-colors">
506
+ <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
507
+ Advanced: Full JSON Editor
508
+ </summary>
509
+ <div class="mt-2">
510
+ <textarea id="cfg-json-raw" rows="20"
511
+ class="input-field !rounded-lg font-mono !text-xs !leading-6 resize-y !p-5 !bg-surface-950"></textarea>
512
+ </div>
513
+ </details>
514
+ </div>
515
+ </div>
516
+ </div>
517
+ </main>
518
+ </div>
519
+
520
+ <!-- Identity Editor Modal -->
521
+ <div id="identity-modal" class="fixed inset-0 z-50 hidden items-center justify-center" style="background:rgba(0,0,0,.6); backdrop-filter:blur(4px)">
522
+ <div class="bg-surface-900 border border-surface-700/50 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 flex flex-col max-h-[85vh]">
523
+ <div class="flex items-center justify-between px-6 py-4 border-b border-surface-700/50">
524
+ <h3 id="identity-modal-title" class="text-[.9375rem] font-semibold text-white flex items-center gap-2"></h3>
525
+ <button onclick="closeIdentityEditor()" class="text-gray-500 hover:text-white transition-colors p-1">
526
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
527
+ </button>
528
+ </div>
529
+ <div class="flex-1 overflow-hidden px-6 py-4">
530
+ <textarea id="identity-editor" class="input-field !rounded-xl font-mono !text-[.8125rem] !leading-relaxed resize-none w-full h-full min-h-[300px] !p-4" spellcheck="false"></textarea>
531
+ </div>
532
+ <div class="flex items-center gap-3 px-6 py-4 border-t border-surface-700/50">
533
+ <button onclick="saveIdentity()" id="identity-save-btn" class="btn-primary">Save</button>
534
+ <button onclick="closeIdentityEditor()" class="btn-ghost">Cancel</button>
535
+ <span id="identity-save-status" class="text-[.8125rem] text-gray-500 ml-2"></span>
536
+ </div>
537
+ </div>
538
+ </div>
539
+
540
+ <script>
541
+ // ── State ────────────────────────────────────────────────────────────────────
542
+ let ws = null;
543
+ let currentTab = 'dashboard';
544
+ let chatWaiting = false;
545
+
546
+ // ── Tab Switching ────────────────────────────────────────────────────────────
547
+ function switchTab(tab) {
548
+ currentTab = tab;
549
+ document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
550
+ document.querySelectorAll('.nav-item').forEach(el => {
551
+ el.classList.remove('active');
552
+ });
553
+
554
+ const panel = document.getElementById('tab-' + tab);
555
+ panel.classList.remove('hidden');
556
+ if (tab === 'chat') panel.classList.add('flex', 'flex-col');
557
+
558
+ const nav = document.getElementById('nav-' + tab);
559
+ if (nav) nav.classList.add('active');
560
+
561
+ const titles = { dashboard:'Dashboard', chat:'Chat', skills:'Skill Catalog', marketplace:'SkillHub Marketplace', config:'Configuration' };
562
+ document.getElementById('page-title').textContent = titles[tab] || tab;
563
+
564
+ if (tab === 'dashboard') refreshDashboard();
565
+ if (tab === 'skills') loadSkills();
566
+ if (tab === 'marketplace') skillhubBrowse();
567
+ if (tab === 'config') loadConfig();
568
+ if (tab === 'chat') {
569
+ document.getElementById('chat-input').focus();
570
+ scrollChat();
571
+ }
572
+ }
573
+
574
+ // ── Dashboard ────────────────────────────────────────────────────────────────
575
+ async function refreshDashboard() {
576
+ try {
577
+ const res = await fetch('/api/status');
578
+ const d = await res.json();
579
+ document.getElementById('stat-provider').textContent = d.providerName || '--';
580
+ document.getElementById('stat-provider-class').textContent = d.provider || '--';
581
+ document.getElementById('stat-skills-loaded').textContent = d.skillsLoaded;
582
+ document.getElementById('stat-skills-total').textContent = d.skillsTotal;
583
+ document.getElementById('stat-memories').textContent = d.memoryCount;
584
+ document.getElementById('stat-uptime').textContent = formatUptime(d.uptimeSeconds);
585
+ document.getElementById('stat-history').textContent = d.historyLength + ' msgs in history';
586
+ document.getElementById('header-provider').textContent = d.providerName || '';
587
+ document.getElementById('feat-compactions').textContent = d.compactionCount;
588
+
589
+ const wsEl = document.getElementById('feat-websearch');
590
+ wsEl.textContent = d.webSearchEnabled ? 'Enabled' : 'Disabled';
591
+ wsEl.className = 'px-2 py-0.5 rounded-full text-[.6875rem] font-medium ' +
592
+ (d.webSearchEnabled ? 'bg-emerald-500/15 text-accent-green' : 'bg-gray-800 text-gray-500');
593
+
594
+ const wsStatusEl = document.getElementById('feat-ws-status');
595
+ const connected = ws && ws.readyState === WebSocket.OPEN;
596
+ wsStatusEl.textContent = connected ? 'Connected' : 'Disconnected';
597
+ wsStatusEl.className = 'px-2 py-0.5 rounded-full text-[.6875rem] font-medium ' +
598
+ (connected ? 'bg-emerald-500/15 text-accent-green' : 'bg-red-500/15 text-accent-red');
599
+
600
+ const banner = document.getElementById('dash-banner');
601
+ banner.classList.toggle('hidden', d.providerReady !== false);
602
+ } catch (e) { console.error('Status fetch:', e); }
603
+
604
+ try {
605
+ const res2 = await fetch('/api/identity');
606
+ const id = await res2.json();
607
+
608
+ const soulEl = document.getElementById('soul-content');
609
+ const soulBadge = document.getElementById('soul-badge');
610
+ if (id.soulConfigured && id.soul) {
611
+ const lines = id.soul.split('\n').filter(l => l.trim());
612
+ const preview = lines.slice(0, 6).map(l => escapeHtml(l)).join('<br>');
613
+ soulEl.innerHTML = preview + (lines.length > 6 ? '<br><span class="text-gray-600">...</span>' : '');
614
+ soulBadge.textContent = 'Active';
615
+ soulBadge.className = 'ml-auto px-2 py-0.5 rounded-full text-[.625rem] font-medium bg-emerald-500/15 text-accent-green';
616
+ } else {
617
+ soulEl.innerHTML = '<span class="text-gray-600 italic">Not configured. Chat to start onboarding.</span>';
618
+ soulBadge.textContent = 'Not Set';
619
+ soulBadge.className = 'ml-auto px-2 py-0.5 rounded-full text-[.625rem] font-medium bg-amber-500/15 text-accent-amber';
620
+ }
621
+
622
+ const personaEl = document.getElementById('persona-content');
623
+ const personaBadge = document.getElementById('persona-badge');
624
+ if (id.personaConfigured && id.persona) {
625
+ const lines = id.persona.split('\n').filter(l => l.trim());
626
+ const preview = lines.slice(0, 6).map(l => escapeHtml(l)).join('<br>');
627
+ personaEl.innerHTML = preview + (lines.length > 6 ? '<br><span class="text-gray-600">...</span>' : '');
628
+ personaBadge.textContent = 'Active';
629
+ personaBadge.className = 'ml-auto px-2 py-0.5 rounded-full text-[.625rem] font-medium bg-emerald-500/15 text-accent-green';
630
+ } else {
631
+ personaEl.innerHTML = '<span class="text-gray-600 italic">Not configured. Chat to start onboarding.</span>';
632
+ personaBadge.textContent = 'Not Set';
633
+ personaBadge.className = 'ml-auto px-2 py-0.5 rounded-full text-[.625rem] font-medium bg-amber-500/15 text-accent-amber';
634
+ }
635
+
636
+ const toolsContainer = document.getElementById('tools-list');
637
+ const toolsCount = document.getElementById('tools-count');
638
+ if (id.tools && id.tools.length) {
639
+ toolsCount.textContent = id.tools.length;
640
+ const groupColors = {
641
+ Primitive: 'text-brand-400', Skills: 'text-accent-purple',
642
+ Meta: 'text-rose-400', Memory: 'text-accent-green',
643
+ Search: 'text-accent-amber', Knowledge: 'text-cyan-400', Cron: 'text-orange-400',
644
+ };
645
+ toolsContainer.innerHTML = id.tools.map(t => `
646
+ <div class="flex items-start gap-2 px-2.5 py-1.5 rounded-lg hover:bg-surface-750/50 transition-colors">
647
+ <span class="text-[.625rem] font-medium uppercase tracking-wider ${groupColors[t.group] || 'text-gray-500'} shrink-0 mt-0.5 w-14">${escapeHtml(t.group)}</span>
648
+ <div class="min-w-0">
649
+ <span class="text-[.75rem] text-white font-medium">${escapeHtml(t.name)}</span>
650
+ <p class="text-[.6875rem] text-gray-500 truncate">${escapeHtml(t.description.substring(0, 80))}</p>
651
+ </div>
652
+ </div>
653
+ `).join('');
654
+ }
655
+ } catch (e) { console.error('Identity fetch:', e); }
656
+ }
657
+
658
+ // ── Identity Editor ──────────────────────────────────────────────────────────
659
+ let _identityType = null;
660
+ let _identityRawData = {};
661
+
662
+ async function openIdentityEditor(type) {
663
+ _identityType = type;
664
+ const modal = document.getElementById('identity-modal');
665
+ const title = document.getElementById('identity-modal-title');
666
+ const editor = document.getElementById('identity-editor');
667
+ const statusEl = document.getElementById('identity-save-status');
668
+ statusEl.textContent = '';
669
+
670
+ const icons = {
671
+ soul: '<svg class="w-4 h-4 text-rose-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>',
672
+ persona: '<svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
673
+ };
674
+ title.innerHTML = icons[type] + ' Edit ' + (type === 'soul' ? 'Soul' : 'Persona');
675
+
676
+ try {
677
+ const res = await fetch('/api/identity');
678
+ _identityRawData = await res.json();
679
+ const content = type === 'soul' ? _identityRawData.soul : _identityRawData.persona;
680
+ editor.value = content || (type === 'soul' ? defaultSoulTemplate() : defaultPersonaTemplate());
681
+ } catch (e) {
682
+ editor.value = '';
683
+ }
684
+
685
+ modal.classList.remove('hidden');
686
+ modal.classList.add('flex');
687
+ editor.focus();
688
+ }
689
+
690
+ function closeIdentityEditor() {
691
+ const modal = document.getElementById('identity-modal');
692
+ modal.classList.add('hidden');
693
+ modal.classList.remove('flex');
694
+ _identityType = null;
695
+ }
696
+
697
+ async function saveIdentity() {
698
+ if (!_identityType) return;
699
+ const editor = document.getElementById('identity-editor');
700
+ const statusEl = document.getElementById('identity-save-status');
701
+ const btn = document.getElementById('identity-save-btn');
702
+ const content = editor.value.trim();
703
+
704
+ if (!content) { statusEl.textContent = 'Content cannot be empty.'; statusEl.className = 'text-[.8125rem] text-accent-red ml-2'; return; }
705
+
706
+ btn.disabled = true;
707
+ statusEl.textContent = 'Saving...';
708
+ statusEl.className = 'text-[.8125rem] text-gray-500 ml-2';
709
+
710
+ try {
711
+ const res = await fetch('/api/identity/' + _identityType, {
712
+ method: 'POST',
713
+ headers: { 'Content-Type': 'application/json' },
714
+ body: JSON.stringify({ content }),
715
+ });
716
+ const result = await res.json();
717
+ if (result.ok) {
718
+ statusEl.textContent = 'Saved! Use /clear in chat to apply.';
719
+ statusEl.className = 'text-[.8125rem] text-accent-green ml-2';
720
+ setTimeout(() => { closeIdentityEditor(); refreshDashboard(); }, 1200);
721
+ } else {
722
+ statusEl.textContent = 'Error: ' + (result.error || 'Unknown');
723
+ statusEl.className = 'text-[.8125rem] text-accent-red ml-2';
724
+ }
725
+ } catch (e) {
726
+ statusEl.textContent = 'Error: ' + e.message;
727
+ statusEl.className = 'text-[.8125rem] text-accent-red ml-2';
728
+ } finally { btn.disabled = false; }
729
+ }
730
+
731
+ function defaultSoulTemplate() {
732
+ return `# PythonClaw — Soul
733
+
734
+ You are a PythonClaw agent — an autonomous AI assistant.
735
+
736
+ ## User
737
+ - The user's name is **[Your Name]**.
738
+ - Preferred language: **English**
739
+
740
+ ## Core Values
741
+ - Honesty: Never fabricate facts.
742
+ - Helpfulness: Genuinely help the user.
743
+ - Respect: Treat everyone with dignity.
744
+ - Curiosity: Ask clarifying questions when needed.
745
+ - Responsibility: Think before you act.
746
+
747
+ ## Emotional Character
748
+ You are calm, warm, and approachable.`;
749
+ }
750
+
751
+ function defaultPersonaTemplate() {
752
+ return `# PythonClaw — Persona
753
+
754
+ ## Role
755
+ You are a personal AI assistant.
756
+
757
+ ## Personality
758
+ - Friendly and professional
759
+ - Concise yet thorough
760
+
761
+ ## Focus Area
762
+ General assistant — help with any topic.
763
+
764
+ ## Communication Style
765
+ - Respond clearly and directly
766
+ - Use examples when explaining complex topics`;
767
+ }
768
+
769
+ // Close modal on Escape key
770
+ document.addEventListener('keydown', (e) => {
771
+ if (e.key === 'Escape') closeIdentityEditor();
772
+ });
773
+
774
+ // Close modal on backdrop click
775
+ document.getElementById('identity-modal').addEventListener('click', (e) => {
776
+ if (e.target === e.currentTarget) closeIdentityEditor();
777
+ });
778
+
779
+ function formatUptime(s) {
780
+ if (!s && s !== 0) return '--';
781
+ if (s < 60) return s + 's';
782
+ if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
783
+ return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
784
+ }
785
+
786
+ // ── Skills ───────────────────────────────────────────────────────────────────
787
+ async function loadSkills() {
788
+ try {
789
+ const res = await fetch('/api/skills');
790
+ const data = await res.json();
791
+ document.getElementById('skills-count').textContent = data.total + ' skills installed';
792
+ const container = document.getElementById('skills-list');
793
+ container.innerHTML = '';
794
+
795
+ const categoryIcons = {
796
+ communication: '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>',
797
+ data: '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>',
798
+ dev: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
799
+ google: '<circle cx="12" cy="12" r="10"/><path d="M12 8v8"/><path d="M8 12h8"/>',
800
+ meta: '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
801
+ system: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 0"/>',
802
+ text: '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/>',
803
+ web: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',
804
+ };
805
+ const defaultIcon = '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>';
806
+
807
+ for (const [category, skills] of Object.entries(data.categories)) {
808
+ const catKey = category.toLowerCase();
809
+ const iconPath = categoryIcons[catKey] || defaultIcon;
810
+ const section = document.createElement('div');
811
+ section.innerHTML = `
812
+ <div class="flex items-center gap-2 mb-3">
813
+ <svg class="w-4 h-4 text-brand-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">${iconPath}</svg>
814
+ <h3 class="text-[.75rem] font-semibold text-gray-400 uppercase tracking-wider">${escapeHtml(category)}</h3>
815
+ <span class="text-[.6875rem] text-gray-600">${skills.length}</span>
816
+ </div>
817
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mb-2">
818
+ ${skills.map(s => `
819
+ <div class="skill-card section-card !p-4 cursor-default">
820
+ <div class="flex items-center gap-2 mb-2">
821
+ <h4 class="font-medium text-white text-[.8125rem]">${escapeHtml(s.name)}</h4>
822
+ </div>
823
+ <p class="text-[.75rem] text-gray-500 leading-relaxed line-clamp-2">${escapeHtml(s.description)}</p>
824
+ </div>
825
+ `).join('')}
826
+ </div>
827
+ `;
828
+ container.appendChild(section);
829
+ }
830
+ } catch (e) { console.error('Skills fetch:', e); }
831
+ }
832
+
833
+ // ── SkillHub Marketplace ─────────────────────────────────────────────────────
834
+ let _shLastResults = [];
835
+
836
+ async function skillhubSearch() {
837
+ const query = document.getElementById('sh-search-input').value.trim();
838
+ if (!query) return;
839
+ const status = document.getElementById('sh-status');
840
+ const container = document.getElementById('sh-results');
841
+ status.textContent = 'Searching…';
842
+ status.classList.remove('hidden');
843
+ container.innerHTML = '';
844
+
845
+ try {
846
+ const res = await fetch('/api/skillhub/search', {
847
+ method: 'POST',
848
+ headers: { 'Content-Type': 'application/json' },
849
+ body: JSON.stringify({ query, limit: 20 }),
850
+ });
851
+ const data = await res.json();
852
+ if (!data.ok) { status.textContent = 'Error: ' + (data.error || 'Unknown'); return; }
853
+ _shLastResults = data.results || [];
854
+ renderSkillhubResults(_shLastResults);
855
+ status.textContent = `Found ${_shLastResults.length} skill${_shLastResults.length !== 1 ? 's' : ''}`;
856
+ } catch (e) {
857
+ status.textContent = 'Request failed: ' + e.message;
858
+ }
859
+ }
860
+
861
+ async function skillhubBrowse() {
862
+ const status = document.getElementById('sh-status');
863
+ const container = document.getElementById('sh-results');
864
+ status.textContent = 'Loading top skills…';
865
+ status.classList.remove('hidden');
866
+ container.innerHTML = '';
867
+
868
+ try {
869
+ const res = await fetch('/api/skillhub/browse?limit=20&sort=score');
870
+ const data = await res.json();
871
+ if (!data.ok) { status.textContent = 'Error: ' + (data.error || 'Unknown'); return; }
872
+ _shLastResults = data.results || [];
873
+ renderSkillhubResults(_shLastResults);
874
+ status.textContent = `Showing top ${_shLastResults.length} skills`;
875
+ } catch (e) {
876
+ status.textContent = 'Request failed: ' + e.message;
877
+ }
878
+ }
879
+
880
+ function renderSkillhubResults(results) {
881
+ const container = document.getElementById('sh-results');
882
+ container.innerHTML = '';
883
+ if (!results.length) {
884
+ container.innerHTML = '<p class="text-gray-500 text-sm col-span-2">No results found.</p>';
885
+ return;
886
+ }
887
+ for (const r of results) {
888
+ const name = r.name || r.title || '???';
889
+ const desc = (r.description || '').substring(0, 120);
890
+ const sid = r.id || r.slug || '';
891
+ const score = r.score || r.ai_score || '';
892
+ const stars = r.stars || '';
893
+ const author = r.author || r.owner || '';
894
+ const category = r.category || '';
895
+
896
+ const card = document.createElement('div');
897
+ card.className = 'bg-surface-800/60 border border-surface-700/50 rounded-xl p-5 hover:border-brand-500/40 transition-colors';
898
+ card.innerHTML = `
899
+ <div class="flex items-start justify-between gap-3 mb-2">
900
+ <h3 class="font-semibold text-white text-sm">${_esc(name)}</h3>
901
+ <div class="flex items-center gap-2 shrink-0">
902
+ ${score ? `<span class="text-[.65rem] px-1.5 py-0.5 bg-brand-500/20 text-brand-400 rounded font-medium">${_esc(String(score))}</span>` : ''}
903
+ ${stars ? `<span class="text-[.65rem] text-yellow-400">★${_esc(String(stars))}</span>` : ''}
904
+ </div>
905
+ </div>
906
+ <p class="text-xs text-gray-400 mb-3 line-clamp-2">${_esc(desc)}</p>
907
+ <div class="flex items-center justify-between">
908
+ <div class="flex items-center gap-2 text-[.65rem] text-gray-500">
909
+ ${author ? `<span>by ${_esc(author)}</span>` : ''}
910
+ ${category ? `<span class="px-1.5 py-0.5 bg-surface-700 rounded">${_esc(category)}</span>` : ''}
911
+ </div>
912
+ ${sid ? `<button onclick="skillhubInstall('${_esc(sid)}')" class="text-xs px-3 py-1.5 rounded-lg bg-brand-500/20 text-brand-400 hover:bg-brand-500/30 font-medium transition-colors">Install</button>` : ''}
913
+ </div>
914
+ ${sid ? `<div class="mt-2 text-[.6rem] text-gray-600 truncate">ID: ${_esc(sid)}</div>` : ''}
915
+ `;
916
+ container.appendChild(card);
917
+ }
918
+ }
919
+
920
+ function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
921
+
922
+ async function skillhubInstall(skillId) {
923
+ if (!confirm(`Install skill "${skillId}" from SkillHub?`)) return;
924
+ const status = document.getElementById('sh-status');
925
+ status.textContent = `Installing ${skillId}…`;
926
+ status.classList.remove('hidden');
927
+
928
+ try {
929
+ const res = await fetch('/api/skillhub/install', {
930
+ method: 'POST',
931
+ headers: { 'Content-Type': 'application/json' },
932
+ body: JSON.stringify({ skill_id: skillId }),
933
+ });
934
+ const data = await res.json();
935
+ if (data.ok) {
936
+ status.innerHTML = `<span class="text-green-400">✓ Installed:</span> ${_esc(data.path || skillId)}`;
937
+ } else {
938
+ status.innerHTML = `<span class="text-red-400">✗ Error:</span> ${_esc(data.error || 'Unknown error')}`;
939
+ }
940
+ } catch (e) {
941
+ status.innerHTML = `<span class="text-red-400">✗ Failed:</span> ${_esc(e.message)}`;
942
+ }
943
+ }
944
+
945
+ // ── Config ───────────────────────────────────────────────────────────────────
946
+ let _rawConfig = {};
947
+ let _lastSecretsSet = {};
948
+
949
+ async function loadConfig() {
950
+ try {
951
+ const res = await fetch('/api/config');
952
+ const data = await res.json();
953
+ _rawConfig = data.config || {};
954
+ _lastSecretsSet = data.secretsSet || {};
955
+
956
+ document.getElementById('config-path').textContent =
957
+ data.configPath ? 'Loaded from ' + data.configPath : 'No config file found';
958
+
959
+ const banner = document.getElementById('config-banner');
960
+ if (data.providerReady === false) {
961
+ banner.className = 'mb-6 rounded-lg px-5 py-3 text-sm bg-amber-500/10 border border-amber-500/20 text-amber-300';
962
+ banner.innerHTML = '<strong>LLM provider not configured.</strong> Enter your API key and click Save.';
963
+ } else {
964
+ banner.className = 'mb-6 rounded-lg px-5 py-3 text-sm bg-emerald-500/10 border border-emerald-500/20 text-emerald-300';
965
+ banner.innerHTML = '<strong>Provider active.</strong> Changes take effect after saving.';
966
+ }
967
+ banner.classList.remove('hidden');
968
+
969
+ const secretsSet = data.secretsSet || {};
970
+ const llm = _rawConfig.llm || {};
971
+ const providerName = llm.provider || 'deepseek';
972
+ document.getElementById('cfg-provider').value = providerName;
973
+
974
+ const providerCfg = llm[providerName] || {};
975
+ const apiKeyField = document.getElementById('cfg-apikey');
976
+ const apiKeyPath = 'llm.' + providerName + '.apiKey';
977
+ apiKeyField.value = '';
978
+ apiKeyField.placeholder = secretsSet[apiKeyPath] ? '(key is set — leave blank to keep)' : 'Enter your API key';
979
+
980
+ document.getElementById('cfg-model').value = providerCfg.model || '';
981
+ document.getElementById('cfg-baseurl').value = providerCfg.baseUrl || '';
982
+
983
+ const tavilyField = document.getElementById('cfg-tavily-key');
984
+ tavilyField.value = '';
985
+ tavilyField.placeholder = secretsSet['tavily.apiKey'] ? '(key is set — leave blank to keep)' : 'Get one at app.tavily.com';
986
+
987
+ const shField = document.getElementById('cfg-skillhub-key');
988
+ shField.value = '';
989
+ shField.placeholder = secretsSet['skillhub.apiKey'] ? '(key is set — leave blank to keep)' : 'Get one at skillhub.club';
990
+
991
+ const dgField = document.getElementById('cfg-deepgram-key');
992
+ dgField.value = '';
993
+ dgField.placeholder = secretsSet['deepgram.apiKey'] ? '(key is set — leave blank to keep)' : 'Get one at console.deepgram.com';
994
+
995
+ const skills = _rawConfig.skills || {};
996
+ const emailCfg = skills.email || {};
997
+ document.getElementById('cfg-email-smtp').value = emailCfg.smtpServer || '';
998
+ document.getElementById('cfg-email-port').value = emailCfg.smtpPort || '';
999
+ document.getElementById('cfg-email-sender').value = emailCfg.senderEmail || '';
1000
+
1001
+ const emailPwField = document.getElementById('cfg-email-password');
1002
+ emailPwField.value = '';
1003
+ emailPwField.placeholder = secretsSet['skills.email.senderPassword']
1004
+ ? '(password is set — leave blank to keep)' : 'Enter app password';
1005
+
1006
+ const ghTokenField = document.getElementById('cfg-github-token');
1007
+ ghTokenField.value = '';
1008
+ ghTokenField.placeholder = secretsSet['skills.github.token']
1009
+ ? '(token is set — leave blank to keep)' : 'ghp_xxxxxxxxxx';
1010
+
1011
+ const web = _rawConfig.web || {};
1012
+ document.getElementById('cfg-web-host').value = web.host || '0.0.0.0';
1013
+ document.getElementById('cfg-web-port').value = web.port || 7788;
1014
+
1015
+ document.getElementById('cfg-json-raw').value = JSON.stringify(_rawConfig, null, 2);
1016
+ document.getElementById('config-save-status').textContent = '';
1017
+ } catch (e) { console.error('Config fetch:', e); }
1018
+ }
1019
+
1020
+ document.getElementById('cfg-provider').addEventListener('change', () => {
1021
+ const p = document.getElementById('cfg-provider').value;
1022
+ const defaults = {
1023
+ deepseek: { model:'deepseek-chat', baseUrl:'https://api.deepseek.com/v1' },
1024
+ grok: { model:'grok-3', baseUrl:'https://api.x.ai/v1' },
1025
+ claude: { model:'claude-sonnet-4-20250514', baseUrl:'' },
1026
+ gemini: { model:'gemini-2.0-flash', baseUrl:'' },
1027
+ kimi: { model:'moonshot-v1-128k', baseUrl:'https://api.moonshot.cn/v1' },
1028
+ glm: { model:'glm-4-flash', baseUrl:'https://open.bigmodel.cn/api/paas/v4/' },
1029
+ };
1030
+ const d = defaults[p] || {};
1031
+ const existing = (_rawConfig.llm || {})[p] || {};
1032
+ document.getElementById('cfg-model').value = existing.model || d.model || '';
1033
+ document.getElementById('cfg-baseurl').value = existing.baseUrl || d.baseUrl || '';
1034
+
1035
+ const apiKeyField = document.getElementById('cfg-apikey');
1036
+ const apiKeyPath = 'llm.' + p + '.apiKey';
1037
+ apiKeyField.value = '';
1038
+ apiKeyField.placeholder = (_lastSecretsSet && _lastSecretsSet[apiKeyPath])
1039
+ ? '(key is set — leave blank to keep)' : 'Enter your API key';
1040
+ });
1041
+
1042
+ async function saveConfig() {
1043
+ const statusEl = document.getElementById('config-save-status');
1044
+ const btn = document.getElementById('config-save-btn');
1045
+ btn.disabled = true;
1046
+ statusEl.textContent = 'Saving...';
1047
+ statusEl.className = 'text-[.8125rem] text-gray-500';
1048
+
1049
+ try {
1050
+ const providerName = document.getElementById('cfg-provider').value;
1051
+ const apiKey = document.getElementById('cfg-apikey').value.trim();
1052
+ const model = document.getElementById('cfg-model').value.trim();
1053
+ const baseUrl = document.getElementById('cfg-baseurl').value.trim();
1054
+
1055
+ let cfg;
1056
+ try { cfg = JSON.parse(document.getElementById('cfg-json-raw').value); }
1057
+ catch (e) { cfg = Object.assign({}, _rawConfig); }
1058
+
1059
+ if (!cfg.llm) cfg.llm = {};
1060
+ cfg.llm.provider = providerName;
1061
+ if (!cfg.llm[providerName]) cfg.llm[providerName] = {};
1062
+ if (apiKey) cfg.llm[providerName].apiKey = apiKey;
1063
+ if (model) cfg.llm[providerName].model = model;
1064
+ if (baseUrl) cfg.llm[providerName].baseUrl = baseUrl;
1065
+
1066
+ const tavilyKey = document.getElementById('cfg-tavily-key').value.trim();
1067
+ if (!cfg.tavily) cfg.tavily = {};
1068
+ if (tavilyKey) cfg.tavily.apiKey = tavilyKey;
1069
+
1070
+ const shKey = document.getElementById('cfg-skillhub-key').value.trim();
1071
+ if (!cfg.skillhub) cfg.skillhub = {};
1072
+ if (shKey) cfg.skillhub.apiKey = shKey;
1073
+
1074
+ const dgKey = document.getElementById('cfg-deepgram-key').value.trim();
1075
+ if (!cfg.deepgram) cfg.deepgram = {};
1076
+ if (dgKey) cfg.deepgram.apiKey = dgKey;
1077
+
1078
+ if (!cfg.skills) cfg.skills = {};
1079
+ if (!cfg.skills.email) cfg.skills.email = {};
1080
+ const emailSmtp = document.getElementById('cfg-email-smtp').value.trim();
1081
+ const emailPort = document.getElementById('cfg-email-port').value.trim();
1082
+ const emailSender = document.getElementById('cfg-email-sender').value.trim();
1083
+ const emailPassword = document.getElementById('cfg-email-password').value.trim();
1084
+ if (emailSmtp) cfg.skills.email.smtpServer = emailSmtp;
1085
+ if (emailPort) cfg.skills.email.smtpPort = parseInt(emailPort);
1086
+ if (emailSender) cfg.skills.email.senderEmail = emailSender;
1087
+ if (emailPassword) cfg.skills.email.senderPassword = emailPassword;
1088
+
1089
+ if (!cfg.skills.github) cfg.skills.github = {};
1090
+ const ghToken = document.getElementById('cfg-github-token').value.trim();
1091
+ if (ghToken) cfg.skills.github.token = ghToken;
1092
+
1093
+ if (!cfg.web) cfg.web = {};
1094
+ cfg.web.host = document.getElementById('cfg-web-host').value.trim() || '0.0.0.0';
1095
+ cfg.web.port = parseInt(document.getElementById('cfg-web-port').value) || 7788;
1096
+
1097
+ const res = await fetch('/api/config', {
1098
+ method: 'POST',
1099
+ headers: { 'Content-Type': 'application/json' },
1100
+ body: JSON.stringify({ config: cfg }),
1101
+ });
1102
+ const result = await res.json();
1103
+
1104
+ if (result.ok) {
1105
+ statusEl.textContent = result.providerReady ? 'Saved! Provider is active.' : 'Saved! Provider could not start — check your API key.';
1106
+ statusEl.className = 'text-[.8125rem] ' + (result.providerReady ? 'text-accent-green' : 'text-accent-amber');
1107
+ loadConfig();
1108
+ refreshDashboard();
1109
+ } else {
1110
+ statusEl.textContent = 'Error: ' + (result.error || 'Unknown');
1111
+ statusEl.className = 'text-[.8125rem] text-accent-red';
1112
+ }
1113
+ } catch (e) {
1114
+ statusEl.textContent = 'Error: ' + e.message;
1115
+ statusEl.className = 'text-[.8125rem] text-accent-red';
1116
+ } finally { btn.disabled = false; }
1117
+ }
1118
+
1119
+ // ── Chat ─────────────────────────────────────────────────────────────────────
1120
+ function connectWebSocket() {
1121
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
1122
+ ws = new WebSocket(protocol + '//' + location.host + '/ws/chat');
1123
+
1124
+ ws.onopen = () => {
1125
+ document.getElementById('ws-indicator').className = 'w-2 h-2 rounded-full bg-accent-green shrink-0';
1126
+ document.getElementById('ws-status').textContent = 'Connected';
1127
+ };
1128
+ ws.onclose = () => {
1129
+ document.getElementById('ws-indicator').className = 'w-2 h-2 rounded-full bg-accent-red shrink-0';
1130
+ document.getElementById('ws-status').textContent = 'Disconnected';
1131
+ setTimeout(connectWebSocket, 3000);
1132
+ };
1133
+ ws.onerror = () => { ws.close(); };
1134
+
1135
+ ws.onmessage = (event) => {
1136
+ const data = JSON.parse(event.data);
1137
+ if (data.type === 'thinking') { addThinking(); return; }
1138
+ removeThinking();
1139
+ chatWaiting = false;
1140
+ updateSendButton();
1141
+ if (data.type === 'response') addMessage('bot', data.content);
1142
+ else if (data.type === 'error') addMessage('bot', 'Error: ' + data.content);
1143
+ };
1144
+ }
1145
+
1146
+ function addMessage(role, content) {
1147
+ const container = document.getElementById('chat-messages');
1148
+ const welcome = container.querySelector('.text-center');
1149
+ if (welcome) welcome.remove();
1150
+
1151
+ const wrapper = document.createElement('div');
1152
+ wrapper.className = 'flex fade-in ' + (role === 'user' ? 'justify-end' : 'justify-start');
1153
+
1154
+ const row = document.createElement('div');
1155
+ row.className = 'flex items-end gap-2.5 max-w-[75%]';
1156
+
1157
+ if (role === 'bot') {
1158
+ const avatar = document.createElement('div');
1159
+ avatar.className = 'w-7 h-7 rounded-lg bg-surface-800 border border-surface-700 flex items-center justify-center shrink-0';
1160
+ avatar.innerHTML = '<img src="/static/logo.png" class="w-4 h-4 rounded">';
1161
+ row.appendChild(avatar);
1162
+ }
1163
+
1164
+ const bubble = document.createElement('div');
1165
+ bubble.className = (role === 'user' ? 'msg-user text-white' : 'msg-bot text-gray-200') +
1166
+ ' rounded-2xl px-4 py-3 text-[.8125rem] leading-relaxed';
1167
+ if (role === 'user') bubble.textContent = content;
1168
+ else bubble.innerHTML = renderMarkdown(content);
1169
+
1170
+ row.appendChild(bubble);
1171
+ wrapper.appendChild(row);
1172
+ container.appendChild(wrapper);
1173
+ scrollChat();
1174
+ }
1175
+
1176
+ function addThinking() {
1177
+ removeThinking();
1178
+ const container = document.getElementById('chat-messages');
1179
+ const wrapper = document.createElement('div');
1180
+ wrapper.id = 'thinking-indicator';
1181
+ wrapper.className = 'flex justify-start fade-in';
1182
+ wrapper.innerHTML = `
1183
+ <div class="flex items-end gap-2.5">
1184
+ <div class="w-7 h-7 rounded-lg bg-surface-800 border border-surface-700 flex items-center justify-center shrink-0">
1185
+ <img src="/static/logo.png" class="w-4 h-4 rounded">
1186
+ </div>
1187
+ <div class="msg-bot rounded-2xl px-4 py-3 text-[.8125rem] text-gray-500">
1188
+ <span class="flex items-center gap-1.5">
1189
+ <span class="pulse-dot inline-block w-1.5 h-1.5 rounded-full bg-brand-400" style="animation-delay:0s"></span>
1190
+ <span class="pulse-dot inline-block w-1.5 h-1.5 rounded-full bg-brand-400" style="animation-delay:.2s"></span>
1191
+ <span class="pulse-dot inline-block w-1.5 h-1.5 rounded-full bg-brand-400" style="animation-delay:.4s"></span>
1192
+ </span>
1193
+ </div>
1194
+ </div>
1195
+ `;
1196
+ container.appendChild(wrapper);
1197
+ scrollChat();
1198
+ }
1199
+
1200
+ function removeThinking() {
1201
+ const el = document.getElementById('thinking-indicator');
1202
+ if (el) el.remove();
1203
+ }
1204
+
1205
+ function scrollChat() {
1206
+ const c = document.getElementById('chat-messages');
1207
+ requestAnimationFrame(() => { c.scrollTop = c.scrollHeight; });
1208
+ }
1209
+
1210
+ function updateSendButton() {
1211
+ document.getElementById('chat-send').disabled = chatWaiting;
1212
+ document.getElementById('chat-input').disabled = chatWaiting;
1213
+ }
1214
+
1215
+ // ── Markdown ─────────────────────────────────────────────────────────────────
1216
+ function renderMarkdown(text) {
1217
+ let html = escapeHtml(text);
1218
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
1219
+ '<pre class="bg-surface-950 border border-surface-700/50 rounded-lg p-3.5 my-2.5 overflow-x-auto text-[.75rem] leading-relaxed"><code>' + code.trim() + '</code></pre>');
1220
+ html = html.replace(/`([^`]+)`/g, '<code class="bg-surface-800/80 text-brand-300 px-1.5 py-0.5 rounded text-[.75rem]">$1</code>');
1221
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong class="text-white font-semibold">$1</strong>');
1222
+ html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
1223
+ html = html.replace(/^[-•] (.+)/gm, '<span class="flex gap-2 items-start"><span class="text-brand-400 mt-0.5">&#8226;</span><span>$1</span></span>');
1224
+ html = html.replace(/^\d+\. (.+)/gm, '<span class="flex gap-2 items-start"><span class="text-brand-400 font-medium">$&</span></span>');
1225
+ html = html.replace(/\n/g, '<br>');
1226
+ return html;
1227
+ }
1228
+
1229
+ function escapeHtml(str) {
1230
+ const div = document.createElement('div');
1231
+ div.textContent = str;
1232
+ return div.innerHTML;
1233
+ }
1234
+
1235
+ // ── Voice input (Deepgram) ───────────────────────────────────────────────────
1236
+ let _mediaRecorder = null;
1237
+ let _audioChunks = [];
1238
+ let _micActive = false;
1239
+
1240
+ function toggleMic() {
1241
+ if (_micActive) { stopMic(); return; }
1242
+ navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
1243
+ _micActive = true;
1244
+ const btn = document.getElementById('mic-btn');
1245
+ btn.classList.add('!bg-red-600', '!text-white');
1246
+ btn.title = 'Recording… click to stop';
1247
+ _audioChunks = [];
1248
+ _mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
1249
+ _mediaRecorder.ondataavailable = e => { if (e.data.size > 0) _audioChunks.push(e.data); };
1250
+ _mediaRecorder.onstop = () => {
1251
+ stream.getTracks().forEach(t => t.stop());
1252
+ const blob = new Blob(_audioChunks, { type: 'audio/webm' });
1253
+ transcribeAudio(blob);
1254
+ };
1255
+ _mediaRecorder.start();
1256
+ }).catch(err => {
1257
+ console.error('Mic access denied:', err);
1258
+ alert('Microphone access denied. Please allow microphone access and try again.');
1259
+ });
1260
+ }
1261
+
1262
+ function stopMic() {
1263
+ _micActive = false;
1264
+ const btn = document.getElementById('mic-btn');
1265
+ btn.classList.remove('!bg-red-600', '!text-white');
1266
+ btn.title = 'Voice input';
1267
+ if (_mediaRecorder && _mediaRecorder.state !== 'inactive') {
1268
+ _mediaRecorder.stop();
1269
+ }
1270
+ }
1271
+
1272
+ async function transcribeAudio(blob) {
1273
+ const btn = document.getElementById('mic-btn');
1274
+ btn.classList.add('animate-pulse');
1275
+ btn.title = 'Transcribing…';
1276
+ try {
1277
+ const resp = await fetch('/api/transcribe', {
1278
+ method: 'POST',
1279
+ headers: { 'Content-Type': 'audio/webm' },
1280
+ body: blob,
1281
+ });
1282
+ const data = await resp.json();
1283
+ if (data.ok && data.transcript) {
1284
+ const input = document.getElementById('chat-input');
1285
+ input.value = (input.value ? input.value + ' ' : '') + data.transcript;
1286
+ input.focus();
1287
+ } else {
1288
+ alert(data.error || 'Transcription returned empty result.');
1289
+ }
1290
+ } catch (e) {
1291
+ console.error('Transcribe error:', e);
1292
+ alert('Transcription failed: ' + e.message);
1293
+ } finally {
1294
+ btn.classList.remove('animate-pulse');
1295
+ btn.title = 'Voice input';
1296
+ }
1297
+ }
1298
+
1299
+ // ── Chat form ────────────────────────────────────────────────────────────────
1300
+ document.getElementById('chat-form').addEventListener('submit', (e) => {
1301
+ e.preventDefault();
1302
+ const input = document.getElementById('chat-input');
1303
+ const msg = input.value.trim();
1304
+ if (!msg || chatWaiting || !ws || ws.readyState !== WebSocket.OPEN) return;
1305
+ addMessage('user', msg);
1306
+ ws.send(JSON.stringify({ message: msg }));
1307
+ input.value = '';
1308
+ chatWaiting = true;
1309
+ updateSendButton();
1310
+ });
1311
+
1312
+ // ── Init ─────────────────────────────────────────────────────────────────────
1313
+ switchTab('dashboard');
1314
+ connectWebSocket();
1315
+ refreshDashboard();
1316
+ </script>
1317
+ </body>
1318
+ </html>