@jungjaehoon/mama-os 0.8.3 → 0.9.0
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.
- package/CHANGELOG.md +10 -0
- package/dist/agent/agent-loop.d.ts +1 -8
- package/dist/agent/agent-loop.d.ts.map +1 -1
- package/dist/agent/agent-loop.js +44 -159
- package/dist/agent/agent-loop.js.map +1 -1
- package/dist/agent/claude-cli-wrapper.d.ts +6 -0
- package/dist/agent/claude-cli-wrapper.d.ts.map +1 -1
- package/dist/agent/claude-cli-wrapper.js +6 -0
- package/dist/agent/claude-cli-wrapper.js.map +1 -1
- package/dist/agent/codex-mcp-process.d.ts +85 -0
- package/dist/agent/codex-mcp-process.d.ts.map +1 -0
- package/dist/agent/codex-mcp-process.js +357 -0
- package/dist/agent/codex-mcp-process.js.map +1 -0
- package/dist/agent/session-pool.d.ts +17 -2
- package/dist/agent/session-pool.d.ts.map +1 -1
- package/dist/agent/session-pool.js +51 -26
- package/dist/agent/session-pool.js.map +1 -1
- package/dist/agent/types.d.ts +9 -24
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/api/graph-api.d.ts.map +1 -1
- package/dist/api/graph-api.js +133 -45
- package/dist/api/graph-api.js.map +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +14 -25
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +3 -10
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +143 -54
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +2 -7
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/config/config-manager.d.ts.map +1 -1
- package/dist/cli/config/config-manager.js +9 -17
- package/dist/cli/config/config-manager.js.map +1 -1
- package/dist/cli/config/types.d.ts +19 -25
- package/dist/cli/config/types.d.ts.map +1 -1
- package/dist/cli/config/types.js.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/gateways/context-injector.d.ts.map +1 -1
- package/dist/gateways/context-injector.js +6 -3
- package/dist/gateways/context-injector.js.map +1 -1
- package/dist/gateways/discord.d.ts +4 -0
- package/dist/gateways/discord.d.ts.map +1 -1
- package/dist/gateways/discord.js +39 -16
- package/dist/gateways/discord.js.map +1 -1
- package/dist/gateways/message-router.d.ts +6 -1
- package/dist/gateways/message-router.d.ts.map +1 -1
- package/dist/gateways/message-router.js +92 -7
- package/dist/gateways/message-router.js.map +1 -1
- package/dist/multi-agent/agent-process-manager.d.ts.map +1 -1
- package/dist/multi-agent/agent-process-manager.js +36 -9
- package/dist/multi-agent/agent-process-manager.js.map +1 -1
- package/dist/multi-agent/runtime-process.d.ts +4 -4
- package/dist/multi-agent/runtime-process.d.ts.map +1 -1
- package/dist/multi-agent/runtime-process.js +9 -20
- package/dist/multi-agent/runtime-process.js.map +1 -1
- package/dist/multi-agent/types.d.ts +13 -8
- package/dist/multi-agent/types.d.ts.map +1 -1
- package/dist/multi-agent/types.js.map +1 -1
- package/dist/setup/setup-prompt.d.ts +1 -1
- package/dist/setup/setup-prompt.d.ts.map +1 -1
- package/dist/setup/setup-prompt.js +19 -0
- package/dist/setup/setup-prompt.js.map +1 -1
- package/dist/setup/setup-server.d.ts.map +1 -1
- package/dist/setup/setup-server.js +39 -16
- package/dist/setup/setup-server.js.map +1 -1
- package/dist/skills/skill-registry.d.ts.map +1 -1
- package/dist/skills/skill-registry.js +5 -2
- package/dist/skills/skill-registry.js.map +1 -1
- package/package.json +5 -3
- package/public/setup.html +12 -1
- package/public/viewer/js/modules/chat.js +1760 -1976
- package/public/viewer/js/modules/dashboard.js +613 -695
- package/public/viewer/js/modules/graph.js +857 -970
- package/public/viewer/js/modules/memory.js +357 -312
- package/public/viewer/js/modules/settings.js +1009 -1026
- package/public/viewer/js/modules/skills.js +336 -355
- package/public/viewer/js/utils/api.js +255 -255
- package/public/viewer/js/utils/debug-logger.js +20 -26
- package/public/viewer/js/utils/dom.js +73 -60
- package/public/viewer/js/utils/format.js +182 -228
- package/public/viewer/js/utils/markdown.js +40 -0
- package/public/viewer/src/modules/chat.ts +2258 -0
- package/public/viewer/src/modules/dashboard.ts +1052 -0
- package/public/viewer/src/modules/graph.ts +1080 -0
- package/public/viewer/src/modules/memory.ts +453 -0
- package/public/viewer/src/modules/settings.ts +1398 -0
- package/public/viewer/src/modules/skills.ts +457 -0
- package/public/viewer/src/types/global.d.ts +168 -0
- package/public/viewer/src/utils/api.ts +650 -0
- package/public/viewer/src/utils/debug-logger.ts +36 -0
- package/public/viewer/src/utils/dom.ts +138 -0
- package/public/viewer/src/utils/format.ts +331 -0
- package/public/viewer/src/utils/markdown.ts +46 -0
- package/public/viewer/tsconfig.viewer.json +18 -0
- package/public/viewer/viewer.html +214 -311
- package/dist/agent/codex-cli-wrapper.d.ts +0 -85
- package/dist/agent/codex-cli-wrapper.d.ts.map +0 -1
- package/dist/agent/codex-cli-wrapper.js +0 -295
- package/dist/agent/codex-cli-wrapper.js.map +0 -1
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Module - MAMA OS Settings Management
|
|
3
|
+
* @module modules/settings
|
|
4
|
+
* @version 1.0.0
|
|
5
|
+
*
|
|
6
|
+
* Handles Settings tab functionality including:
|
|
7
|
+
* - Load current configuration
|
|
8
|
+
* - Save configuration changes
|
|
9
|
+
* - Form validation
|
|
10
|
+
* - Gateway enable/disable toggles
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/* eslint-env browser */
|
|
14
|
+
|
|
15
|
+
import { showToast, escapeHtml, escapeAttr, getElementByIdOrNull } from '../utils/dom.js';
|
|
16
|
+
import { formatModelName } from '../utils/format.js';
|
|
17
|
+
import { DebugLogger } from '../utils/debug-logger.js';
|
|
18
|
+
import {
|
|
19
|
+
API,
|
|
20
|
+
type ApiConfigResponse,
|
|
21
|
+
type ApiRoleDefinition,
|
|
22
|
+
type CronJob,
|
|
23
|
+
type CronJobsResponse,
|
|
24
|
+
type EffortLevel,
|
|
25
|
+
type McpServer,
|
|
26
|
+
type McpServersResponse,
|
|
27
|
+
type MultiAgentAgent,
|
|
28
|
+
type MultiAgentAgentsResponse,
|
|
29
|
+
type SkillsResponse,
|
|
30
|
+
} from '../utils/api.js';
|
|
31
|
+
|
|
32
|
+
const logger = new DebugLogger('Settings');
|
|
33
|
+
|
|
34
|
+
type AgentBackend = 'claude' | 'codex-mcp';
|
|
35
|
+
type SettingsFilterValue = 'loading' | 'error' | 'success' | '';
|
|
36
|
+
type SettingsPayloadToolConfig = {
|
|
37
|
+
gateway: string[];
|
|
38
|
+
mcp: string[];
|
|
39
|
+
mcp_config: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type SettingsPayload = {
|
|
43
|
+
discord: {
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
token: string;
|
|
46
|
+
default_channel_id: string;
|
|
47
|
+
};
|
|
48
|
+
slack: {
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
bot_token: string;
|
|
51
|
+
app_token: string;
|
|
52
|
+
};
|
|
53
|
+
telegram: {
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
token: string;
|
|
56
|
+
};
|
|
57
|
+
chatwork: {
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
api_token: string;
|
|
60
|
+
};
|
|
61
|
+
heartbeat: {
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
interval: number;
|
|
64
|
+
quiet_start: number;
|
|
65
|
+
quiet_end: number;
|
|
66
|
+
};
|
|
67
|
+
use_claude_cli: boolean;
|
|
68
|
+
agent: {
|
|
69
|
+
backend: AgentBackend;
|
|
70
|
+
model: string;
|
|
71
|
+
effort?: EffortLevel;
|
|
72
|
+
max_turns: number;
|
|
73
|
+
timeout: number;
|
|
74
|
+
tools: SettingsPayloadToolConfig;
|
|
75
|
+
};
|
|
76
|
+
token_budget: {
|
|
77
|
+
daily_limit?: number;
|
|
78
|
+
alert_threshold?: number;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Model options by backend (single source of truth)
|
|
83
|
+
// Claude models: https://platform.claude.com/docs/en/about-claude/models/overview
|
|
84
|
+
const MODEL_OPTIONS: Record<AgentBackend, readonly string[]> = {
|
|
85
|
+
'codex-mcp': [
|
|
86
|
+
'gpt-5.3-codex',
|
|
87
|
+
'gpt-5.2-codex',
|
|
88
|
+
'gpt-5.1-codex-max',
|
|
89
|
+
'gpt-4.1',
|
|
90
|
+
'gpt-4o',
|
|
91
|
+
'gpt-4o-mini',
|
|
92
|
+
'o1',
|
|
93
|
+
'o1-mini',
|
|
94
|
+
'o3-mini',
|
|
95
|
+
],
|
|
96
|
+
claude: [
|
|
97
|
+
// Latest models
|
|
98
|
+
'claude-opus-4-6',
|
|
99
|
+
'claude-sonnet-4-5-20250929',
|
|
100
|
+
'claude-haiku-4-5-20251001',
|
|
101
|
+
// Legacy models
|
|
102
|
+
'claude-opus-4-5-20251101',
|
|
103
|
+
'claude-sonnet-4-20250514',
|
|
104
|
+
'claude-opus-4-20250514',
|
|
105
|
+
'claude-3-7-sonnet-20250219',
|
|
106
|
+
'claude-3-haiku-20240307',
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Settings Module Class
|
|
112
|
+
*/
|
|
113
|
+
export class SettingsModule {
|
|
114
|
+
config: ApiConfigResponse | null = null;
|
|
115
|
+
mcpServersData: McpServersResponse = { servers: [] };
|
|
116
|
+
multiAgentData: MultiAgentAgentsResponse = { agents: [] };
|
|
117
|
+
initialized = false;
|
|
118
|
+
backendListenersInitialized = false;
|
|
119
|
+
delegatedListenersInitialized = false;
|
|
120
|
+
|
|
121
|
+
constructor() {}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse and validate required integer input.
|
|
125
|
+
*/
|
|
126
|
+
private parseIntegerInput(id: string, min: number, max: number, fallback: number | null): number {
|
|
127
|
+
const raw = this.getValue(id);
|
|
128
|
+
if (raw === null) {
|
|
129
|
+
if (fallback === null) {
|
|
130
|
+
throw new Error(`필수 값이 비어 있습니다: ${id}`);
|
|
131
|
+
}
|
|
132
|
+
return fallback;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const trimmed = raw.trim();
|
|
136
|
+
if (!trimmed) {
|
|
137
|
+
if (fallback === null) {
|
|
138
|
+
throw new Error(`필수 값이 비어 있습니다: ${id}`);
|
|
139
|
+
}
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const parsed = Number(trimmed);
|
|
144
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
145
|
+
throw new Error(`숫자 형식이 유효하지 않습니다: ${id}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (parsed < min || parsed > max) {
|
|
149
|
+
throw new Error(`${id}는 ${min}~${max} 사이여야 합니다.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return parsed;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private parseOptionalNumber(
|
|
156
|
+
id: string,
|
|
157
|
+
fieldName: string,
|
|
158
|
+
options: {
|
|
159
|
+
min: number;
|
|
160
|
+
max?: number;
|
|
161
|
+
integerOnly?: boolean;
|
|
162
|
+
}
|
|
163
|
+
): number | undefined {
|
|
164
|
+
const raw = this.getValue(id);
|
|
165
|
+
const trimmed = raw.trim();
|
|
166
|
+
if (!trimmed) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const parsed = Number(trimmed);
|
|
171
|
+
if (!Number.isFinite(parsed)) {
|
|
172
|
+
throw new Error(`${fieldName}은(는) 유효한 숫자여야 합니다.`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (parsed < options.min) {
|
|
176
|
+
throw new Error(`${fieldName}은(는) ${options.min} 이상이어야 합니다.`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (options.max !== undefined && parsed > options.max) {
|
|
180
|
+
throw new Error(`${fieldName}은(는) ${options.max} 이하여야 합니다.`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (options.integerOnly !== false && !Number.isInteger(parsed)) {
|
|
184
|
+
throw new Error(`${fieldName}은(는) 정수여야 합니다.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return parsed;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Initialize settings module
|
|
192
|
+
*/
|
|
193
|
+
async init(): Promise<void> {
|
|
194
|
+
if (this.initialized) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.initialized = true;
|
|
198
|
+
|
|
199
|
+
await this.loadSettings();
|
|
200
|
+
this.initBackendModelBinding();
|
|
201
|
+
this.initDelegatedEventHandlers();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
initDelegatedEventHandlers(): void {
|
|
205
|
+
if (this.delegatedListenersInitialized) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.delegatedListenersInitialized = true;
|
|
209
|
+
|
|
210
|
+
document.addEventListener('change', (e: Event) => {
|
|
211
|
+
const target = e.target;
|
|
212
|
+
if (!(target instanceof HTMLElement)) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const actionElement = target.closest<HTMLElement>('[data-action]');
|
|
217
|
+
const action = actionElement?.dataset.action;
|
|
218
|
+
if (!action || !actionElement) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (action === 'agent-toggle') {
|
|
223
|
+
const checkbox = actionElement as HTMLInputElement;
|
|
224
|
+
const agentId = checkbox.dataset.agentId || '';
|
|
225
|
+
if (agentId) {
|
|
226
|
+
void this.toggleAgent(agentId, checkbox.checked);
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (action === 'agent-backend') {
|
|
232
|
+
const select = actionElement as HTMLSelectElement;
|
|
233
|
+
const agentId = select.dataset.agentId || '';
|
|
234
|
+
if (agentId) {
|
|
235
|
+
this.onAgentBackendChange(agentId);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (action === 'cron-toggle') {
|
|
241
|
+
const checkbox = actionElement as HTMLInputElement;
|
|
242
|
+
const cronId = checkbox.dataset.cronId || '';
|
|
243
|
+
if (cronId) {
|
|
244
|
+
void this.toggleCronJob(cronId, checkbox.checked);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
document.addEventListener('click', (e: MouseEvent) => {
|
|
251
|
+
const target = e.target;
|
|
252
|
+
if (!(target instanceof HTMLElement)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Handle agent-save button click
|
|
257
|
+
const saveButton = target.closest<HTMLElement>('[data-action="agent-save"]');
|
|
258
|
+
if (saveButton) {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
const agentId = saveButton.dataset.agentId || '';
|
|
261
|
+
if (agentId) {
|
|
262
|
+
void this.saveAgentConfig(agentId);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Handle cron-delete button click
|
|
268
|
+
const deleteButton = target.closest<HTMLElement>('[data-action="cron-delete"]');
|
|
269
|
+
if (deleteButton) {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
const cronId = deleteButton.dataset.cronId || '';
|
|
272
|
+
if (cronId) {
|
|
273
|
+
void this.deleteCronJob(cronId);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Load current settings from API
|
|
282
|
+
*/
|
|
283
|
+
async loadSettings(): Promise<void> {
|
|
284
|
+
this.setStatus('Loading...');
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
this.config = await API.get<ApiConfigResponse>('/api/config');
|
|
288
|
+
|
|
289
|
+
// Load MCP servers data
|
|
290
|
+
try {
|
|
291
|
+
this.mcpServersData = await API.get<McpServersResponse>('/api/mcp-servers');
|
|
292
|
+
} catch (e) {
|
|
293
|
+
logger.warn('MCP servers data unavailable:', e);
|
|
294
|
+
this.mcpServersData = { servers: [] };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Load multi-agent data (F3)
|
|
298
|
+
try {
|
|
299
|
+
this.multiAgentData = await API.get<MultiAgentAgentsResponse>('/api/multi-agent/agents');
|
|
300
|
+
} catch (e) {
|
|
301
|
+
logger.warn('Multi-agent data unavailable:', e);
|
|
302
|
+
this.multiAgentData = { agents: [] };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.populateForm();
|
|
306
|
+
this.setStatus('');
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
309
|
+
logger.error('Load error:', message);
|
|
310
|
+
this.setStatus(`Error: ${message}`, 'error');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Populate form with current config values
|
|
316
|
+
*/
|
|
317
|
+
populateForm(): void {
|
|
318
|
+
if (!this.config) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Discord
|
|
323
|
+
this.setCheckbox('settings-discord-enabled', this.config.discord?.enabled);
|
|
324
|
+
this.setValue('settings-discord-token', this.config.discord?.token || '', true);
|
|
325
|
+
this.setValue('settings-discord-channel', this.config.discord?.default_channel_id || '');
|
|
326
|
+
|
|
327
|
+
// Slack
|
|
328
|
+
this.setCheckbox('settings-slack-enabled', this.config.slack?.enabled);
|
|
329
|
+
this.setValue('settings-slack-bot-token', this.config.slack?.bot_token || '', true);
|
|
330
|
+
this.setValue('settings-slack-app-token', this.config.slack?.app_token || '', true);
|
|
331
|
+
|
|
332
|
+
// Telegram
|
|
333
|
+
this.setCheckbox('settings-telegram-enabled', this.config.telegram?.enabled);
|
|
334
|
+
this.setValue('settings-telegram-token', this.config.telegram?.token || '', true);
|
|
335
|
+
|
|
336
|
+
// Chatwork
|
|
337
|
+
this.setCheckbox('settings-chatwork-enabled', this.config.chatwork?.enabled);
|
|
338
|
+
this.setValue('settings-chatwork-token', this.config.chatwork?.api_token || '', true);
|
|
339
|
+
|
|
340
|
+
// Heartbeat
|
|
341
|
+
this.setCheckbox('settings-heartbeat-enabled', this.config.heartbeat?.enabled);
|
|
342
|
+
this.setValue(
|
|
343
|
+
'settings-heartbeat-interval',
|
|
344
|
+
Math.round((this.config.heartbeat?.interval || 1800000) / 60000)
|
|
345
|
+
);
|
|
346
|
+
this.setValue('settings-heartbeat-quiet-start', this.config.heartbeat?.quiet_start ?? 23);
|
|
347
|
+
this.setValue('settings-heartbeat-quiet-end', this.config.heartbeat?.quiet_end ?? 8);
|
|
348
|
+
|
|
349
|
+
// Agent
|
|
350
|
+
const backend = (this.config.agent?.backend || 'claude') as AgentBackend;
|
|
351
|
+
const model = this.config.agent?.model || 'claude-sonnet-4-20250514';
|
|
352
|
+
const effort = (this.config.agent?.effort || 'medium') as EffortLevel;
|
|
353
|
+
this.setSelectValue('settings-agent-backend', backend);
|
|
354
|
+
this.updateModelOptions(backend, model);
|
|
355
|
+
const normalizedModel = this.getNormalizedModelForBackend(backend, model);
|
|
356
|
+
this.setSelectValue('settings-agent-model', normalizedModel);
|
|
357
|
+
this.setSelectValue('settings-agent-effort', effort);
|
|
358
|
+
this.updateEffortVisibility(normalizedModel);
|
|
359
|
+
this.setValue('settings-agent-max-turns', this.config.agent?.max_turns || 10);
|
|
360
|
+
this.setValue(
|
|
361
|
+
'settings-agent-timeout',
|
|
362
|
+
Math.round((this.config.agent?.timeout || 300000) / 1000)
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
// Tool Mode
|
|
366
|
+
this.populateToolMode();
|
|
367
|
+
|
|
368
|
+
// Role Permissions
|
|
369
|
+
this.populateRoles();
|
|
370
|
+
|
|
371
|
+
// Multi-Agent Team (F3)
|
|
372
|
+
this.populateMultiAgentSection();
|
|
373
|
+
|
|
374
|
+
// Skills + Token Budget + Cron
|
|
375
|
+
this.populateSkillsSection();
|
|
376
|
+
this.populateTokenSection();
|
|
377
|
+
this.populateCronSection();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Populate role permissions from config
|
|
382
|
+
*/
|
|
383
|
+
populateRoles(): void {
|
|
384
|
+
const container = getElementByIdOrNull<HTMLElement>('settings-roles-container');
|
|
385
|
+
if (!container || !this.config.roles) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const { definitions, sourceMapping } = this.config.roles;
|
|
390
|
+
if (!definitions || !sourceMapping) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Build reverse mapping: role -> sources
|
|
395
|
+
const roleSources: Record<string, string[]> = {};
|
|
396
|
+
for (const [source, role] of Object.entries(sourceMapping)) {
|
|
397
|
+
if (!roleSources[role]) {
|
|
398
|
+
roleSources[role] = [];
|
|
399
|
+
}
|
|
400
|
+
roleSources[role].push(source);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Render each role
|
|
404
|
+
const roleColors: Record<string, { badge: string; label: string }> = {
|
|
405
|
+
os_agent: { badge: 'green', label: 'Full Access' },
|
|
406
|
+
chat_bot: { badge: 'yellow', label: 'Limited' },
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const roleIcons: Record<string, string> = {
|
|
410
|
+
os_agent: '🖥️',
|
|
411
|
+
chat_bot: '🤖',
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const roleDefs = definitions as Record<string, ApiRoleDefinition>;
|
|
415
|
+
const html = Object.entries(roleDefs)
|
|
416
|
+
.map(([roleName, roleConfig]) => {
|
|
417
|
+
const sources = roleSources[roleName] || [];
|
|
418
|
+
const color = roleColors[roleName] || { badge: 'gray', label: 'Custom' };
|
|
419
|
+
const icon = roleIcons[roleName] || '⚙️';
|
|
420
|
+
const displayName = escapeHtml(
|
|
421
|
+
roleName.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const allowedTools = roleConfig.allowedTools || [];
|
|
425
|
+
const blockedTools = roleConfig.blockedTools || [];
|
|
426
|
+
const hasSystemControl = roleConfig.systemControl;
|
|
427
|
+
const hasSensitiveAccess = roleConfig.sensitiveAccess;
|
|
428
|
+
const model = roleConfig.model || 'default';
|
|
429
|
+
const maxTurns = roleConfig.maxTurns;
|
|
430
|
+
|
|
431
|
+
// Format model name for display (and escape)
|
|
432
|
+
const displayModel = escapeHtml(formatModelName(model));
|
|
433
|
+
|
|
434
|
+
return `
|
|
435
|
+
<div class="bg-white border border-gray-200 rounded-lg p-2.5">
|
|
436
|
+
<div class="flex items-center justify-between mb-2">
|
|
437
|
+
<div class="flex items-center gap-2">
|
|
438
|
+
<span class="text-xl">${icon}</span>
|
|
439
|
+
<h3 class="font-semibold text-gray-900 text-sm">${displayName}</h3>
|
|
440
|
+
<span class="text-[10px] bg-${color.badge}-100 text-${color.badge}-800 px-1.5 py-0.5 rounded">${color.label}</span>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
<div class="text-xs text-gray-600 space-y-1">
|
|
444
|
+
<div class="flex items-center gap-2">
|
|
445
|
+
<span class="font-medium">Model:</span>
|
|
446
|
+
<span class="bg-indigo-50 text-indigo-700 px-1.5 py-0.5 rounded text-[10px] font-medium">${displayModel}</span>
|
|
447
|
+
${
|
|
448
|
+
maxTurns
|
|
449
|
+
? `<span class="text-gray-400">| ${escapeHtml(String(maxTurns))} turns</span>`
|
|
450
|
+
: ''
|
|
451
|
+
}
|
|
452
|
+
</div>
|
|
453
|
+
<div><span class="font-medium">Source:</span> ${sources.map((s) => `<code class="bg-gray-100 px-1 rounded">${escapeHtml(s)}</code>`).join(' ')}</div>
|
|
454
|
+
<div><span class="font-medium">Allowed:</span> <code class="text-green-600 text-[10px]">${escapeHtml(allowedTools.join(', '))}</code></div>
|
|
455
|
+
${blockedTools.length > 0 ? `<div><span class="font-medium">Blocked:</span> <code class="text-red-600 text-[10px]">${escapeHtml(blockedTools.join(', '))}</code></div>` : ''}
|
|
456
|
+
${
|
|
457
|
+
hasSystemControl || hasSensitiveAccess
|
|
458
|
+
? `<div><span class="font-medium">Permissions:</span>
|
|
459
|
+
${hasSystemControl ? '<span class="inline-block bg-blue-100 text-blue-800 text-[10px] px-1 rounded mr-1">systemControl</span>' : ''}
|
|
460
|
+
${hasSensitiveAccess ? '<span class="inline-block bg-purple-100 text-purple-800 text-[10px] px-1 rounded">sensitiveAccess</span>' : ''}
|
|
461
|
+
</div>`
|
|
462
|
+
: ''
|
|
463
|
+
}
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
`;
|
|
467
|
+
})
|
|
468
|
+
.join('');
|
|
469
|
+
|
|
470
|
+
container.innerHTML = html;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Populate tool selection checkboxes
|
|
475
|
+
*/
|
|
476
|
+
populateToolMode(): void {
|
|
477
|
+
const tools = this.config.agent?.tools || { gateway: ['*'], mcp: [] };
|
|
478
|
+
const gatewayTools = tools.gateway || ['*'];
|
|
479
|
+
const mcpTools = tools.mcp || [];
|
|
480
|
+
|
|
481
|
+
// Set Gateway tool checkboxes
|
|
482
|
+
const gatewayCheckboxes = document.querySelectorAll<HTMLInputElement>('.gateway-tool');
|
|
483
|
+
const isGatewayAll = gatewayTools.includes('*');
|
|
484
|
+
|
|
485
|
+
gatewayCheckboxes.forEach((cb) => {
|
|
486
|
+
if (isGatewayAll) {
|
|
487
|
+
cb.checked = true;
|
|
488
|
+
} else {
|
|
489
|
+
cb.checked = gatewayTools.includes(cb.value);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Set Select All checkbox
|
|
494
|
+
const gatewaySelectAll = getElementByIdOrNull<HTMLInputElement>('gateway-select-all');
|
|
495
|
+
if (gatewaySelectAll) {
|
|
496
|
+
gatewaySelectAll.checked = isGatewayAll || this.allChecked('.gateway-tool');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Dynamically render MCP servers from API
|
|
500
|
+
this.renderMCPServers(mcpTools);
|
|
501
|
+
|
|
502
|
+
// Update summary
|
|
503
|
+
this.updateToolSummary();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Render MCP servers dynamically from loaded data
|
|
508
|
+
*/
|
|
509
|
+
renderMCPServers(selectedTools: string[] = []): void {
|
|
510
|
+
const container = getElementByIdOrNull<HTMLElement>('mcp-tools-list');
|
|
511
|
+
if (!container) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const servers = (this.mcpServersData?.servers || []) as McpServer[];
|
|
516
|
+
const isMCPAll = selectedTools.includes('*');
|
|
517
|
+
|
|
518
|
+
if (servers.length === 0) {
|
|
519
|
+
container.innerHTML = `
|
|
520
|
+
<p class="text-xs text-gray-500 col-span-full">
|
|
521
|
+
No MCP servers configured. Add servers to ~/.mama/mama-mcp-config.json
|
|
522
|
+
</p>
|
|
523
|
+
`;
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const serverColors: Record<string, { border: string; bg: string; icon: string }> = {
|
|
528
|
+
'brave-devtools': { border: 'border-blue-200', bg: 'bg-blue-50', icon: '🌐' },
|
|
529
|
+
'brave-search': { border: 'border-orange-200', bg: 'bg-orange-50', icon: '🔍' },
|
|
530
|
+
mama: { border: 'border-purple-200', bg: 'bg-purple-50', icon: '🧠' },
|
|
531
|
+
default: { border: 'border-gray-200', bg: 'bg-gray-50', icon: '🔌' },
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const html = servers
|
|
535
|
+
.map((server) => {
|
|
536
|
+
const serverName = server.name || '';
|
|
537
|
+
const colors = serverColors[serverName] || serverColors['default'];
|
|
538
|
+
const toolValue = `mcp__${serverName}__*`;
|
|
539
|
+
const isChecked = isMCPAll || selectedTools.includes(toolValue);
|
|
540
|
+
// Escape server.name for safe HTML rendering (XSS prevention)
|
|
541
|
+
const safeName = escapeHtml(serverName);
|
|
542
|
+
const safeToolValue = escapeAttr(toolValue);
|
|
543
|
+
|
|
544
|
+
return `
|
|
545
|
+
<label class="flex items-center gap-2 p-2 border ${colors.border} rounded-lg text-xs cursor-pointer hover:${colors.bg}">
|
|
546
|
+
<input type="checkbox" class="mcp-tool" value="${safeToolValue}" ${isChecked ? 'checked' : ''}>
|
|
547
|
+
${colors.icon} ${safeName}
|
|
548
|
+
</label>
|
|
549
|
+
`;
|
|
550
|
+
})
|
|
551
|
+
.join('');
|
|
552
|
+
|
|
553
|
+
container.innerHTML = html;
|
|
554
|
+
|
|
555
|
+
// Update Select All checkbox
|
|
556
|
+
const mcpSelectAll = getElementByIdOrNull<HTMLInputElement>('mcp-select-all');
|
|
557
|
+
if (mcpSelectAll) {
|
|
558
|
+
mcpSelectAll.checked = isMCPAll || this.allChecked('.mcp-tool');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Check if all checkboxes of a class are checked
|
|
564
|
+
*/
|
|
565
|
+
allChecked(selector: string): boolean {
|
|
566
|
+
const checkboxes = document.querySelectorAll<HTMLInputElement>(selector);
|
|
567
|
+
return Array.from(checkboxes).every((cb) => cb.checked);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Toggle all Gateway tools
|
|
572
|
+
*/
|
|
573
|
+
toggleAllGateway(checked: boolean): void {
|
|
574
|
+
document.querySelectorAll<HTMLInputElement>('.gateway-tool').forEach((cb) => {
|
|
575
|
+
cb.checked = checked;
|
|
576
|
+
});
|
|
577
|
+
this.updateToolSummary();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Toggle all MCP tools
|
|
582
|
+
*/
|
|
583
|
+
toggleAllMCP(checked: boolean): void {
|
|
584
|
+
document.querySelectorAll<HTMLInputElement>('.mcp-tool').forEach((cb) => {
|
|
585
|
+
cb.checked = checked;
|
|
586
|
+
});
|
|
587
|
+
this.updateToolSummary();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Update tool summary display
|
|
592
|
+
*/
|
|
593
|
+
updateToolSummary(): void {
|
|
594
|
+
const gatewayCount =
|
|
595
|
+
document.querySelectorAll<HTMLInputElement>('.gateway-tool:checked').length;
|
|
596
|
+
const mcpCount = document.querySelectorAll<HTMLInputElement>('.mcp-tool:checked').length;
|
|
597
|
+
|
|
598
|
+
const summaryEl = getElementByIdOrNull<HTMLElement>('tool-summary');
|
|
599
|
+
if (summaryEl) {
|
|
600
|
+
summaryEl.textContent = `Gateway: ${gatewayCount} tools | MCP: ${mcpCount} tools`;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Save settings and restart daemon to apply changes
|
|
606
|
+
*/
|
|
607
|
+
async saveAndRestart(): Promise<void> {
|
|
608
|
+
this.setStatus('Saving...');
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
const updates = this.collectFormData();
|
|
612
|
+
await API.put('/api/config', updates);
|
|
613
|
+
|
|
614
|
+
this.setStatus('Saved! Restarting...', 'success');
|
|
615
|
+
showToast('Settings saved. Restarting daemon...');
|
|
616
|
+
|
|
617
|
+
// Trigger restart after save
|
|
618
|
+
try {
|
|
619
|
+
await API.post('/api/restart', {});
|
|
620
|
+
} catch {
|
|
621
|
+
// Expected: connection drops when server exits
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const isServiceReady = await this.waitForServiceAfterRestart();
|
|
625
|
+
if (!isServiceReady) {
|
|
626
|
+
this.setStatus('Restarted, but reconnect timed out. Please refresh manually.', 'error');
|
|
627
|
+
showToast('Restart request sent. Auto reconnect timed out - please refresh page.');
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
this.setStatus('Reconnected. Reloading page...', 'success');
|
|
632
|
+
setTimeout(() => {
|
|
633
|
+
window.location.reload();
|
|
634
|
+
}, 400);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
637
|
+
logger.error('Save error:', message);
|
|
638
|
+
this.setStatus(`Error: ${message}`, 'error');
|
|
639
|
+
showToast(`Failed to save: ${message}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Wait for service recovery after restart by polling dashboard status endpoint.
|
|
645
|
+
*/
|
|
646
|
+
private async waitForServiceAfterRestart(): Promise<boolean> {
|
|
647
|
+
const maxAttempts = 40;
|
|
648
|
+
const intervalMs = 1000;
|
|
649
|
+
const readinessChecks = ['/api/health', '/api/dashboard/status'];
|
|
650
|
+
// Server waits 500ms + shell sleeps 1s before stopping, so wait at least 2.5s
|
|
651
|
+
// before first poll to ensure old server is actually down
|
|
652
|
+
const initialDelayMs = 2500;
|
|
653
|
+
|
|
654
|
+
this.setStatus('Restarting... waiting for shutdown', '');
|
|
655
|
+
await new Promise((resolve) => setTimeout(resolve, initialDelayMs));
|
|
656
|
+
|
|
657
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
658
|
+
this.setStatus(`Restarting... reconnecting (${attempt}/${maxAttempts})`, '');
|
|
659
|
+
|
|
660
|
+
let isReady = false;
|
|
661
|
+
for (const endpoint of readinessChecks) {
|
|
662
|
+
try {
|
|
663
|
+
// Use shared API client for strict JSON response parsing and consistent errors.
|
|
664
|
+
await API.get(endpoint);
|
|
665
|
+
logger.debug('[Settings] Service ready check passed:', endpoint);
|
|
666
|
+
isReady = true;
|
|
667
|
+
break;
|
|
668
|
+
} catch {
|
|
669
|
+
logger.debug('[Settings] Service not ready yet:', endpoint);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (isReady) {
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Collect form data into config update object
|
|
685
|
+
*/
|
|
686
|
+
collectFormData(): SettingsPayload {
|
|
687
|
+
const backend = (this.getSelectValue('settings-agent-backend') || 'claude') as AgentBackend;
|
|
688
|
+
const model = this.getSelectValue('settings-agent-model');
|
|
689
|
+
const effort = (this.getSelectValue('settings-agent-effort') || 'medium') as EffortLevel;
|
|
690
|
+
const useClaudeCli = backend === 'claude';
|
|
691
|
+
|
|
692
|
+
// Get token values - if empty and original was masked, keep original
|
|
693
|
+
const discordToken = this.getTokenValue('settings-discord-token', this.config?.discord?.token);
|
|
694
|
+
const slackBotToken = this.getTokenValue(
|
|
695
|
+
'settings-slack-bot-token',
|
|
696
|
+
this.config?.slack?.bot_token
|
|
697
|
+
);
|
|
698
|
+
const slackAppToken = this.getTokenValue(
|
|
699
|
+
'settings-slack-app-token',
|
|
700
|
+
this.config?.slack?.app_token
|
|
701
|
+
);
|
|
702
|
+
const telegramToken = this.getTokenValue(
|
|
703
|
+
'settings-telegram-token',
|
|
704
|
+
this.config?.telegram?.token
|
|
705
|
+
);
|
|
706
|
+
const chatworkToken = this.getTokenValue(
|
|
707
|
+
'settings-chatwork-token',
|
|
708
|
+
this.config?.chatwork?.api_token
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
discord: {
|
|
713
|
+
enabled: this.getCheckbox('settings-discord-enabled'),
|
|
714
|
+
token: discordToken,
|
|
715
|
+
default_channel_id: this.getValue('settings-discord-channel'),
|
|
716
|
+
},
|
|
717
|
+
slack: {
|
|
718
|
+
enabled: this.getCheckbox('settings-slack-enabled'),
|
|
719
|
+
bot_token: slackBotToken,
|
|
720
|
+
app_token: slackAppToken,
|
|
721
|
+
},
|
|
722
|
+
telegram: {
|
|
723
|
+
enabled: this.getCheckbox('settings-telegram-enabled'),
|
|
724
|
+
token: telegramToken,
|
|
725
|
+
},
|
|
726
|
+
chatwork: {
|
|
727
|
+
enabled: this.getCheckbox('settings-chatwork-enabled'),
|
|
728
|
+
api_token: chatworkToken,
|
|
729
|
+
},
|
|
730
|
+
heartbeat: {
|
|
731
|
+
enabled: this.getCheckbox('settings-heartbeat-enabled'),
|
|
732
|
+
interval: this.parseIntegerInput('settings-heartbeat-interval', 1, 1440, 30) * 60000,
|
|
733
|
+
quiet_start: this.parseIntegerInput('settings-heartbeat-quiet-start', 0, 23, 23),
|
|
734
|
+
quiet_end: this.parseIntegerInput('settings-heartbeat-quiet-end', 0, 23, 8),
|
|
735
|
+
},
|
|
736
|
+
use_claude_cli: useClaudeCli,
|
|
737
|
+
agent: {
|
|
738
|
+
backend,
|
|
739
|
+
model: model || (backend === 'codex-mcp' ? 'gpt-5.2-codex' : 'claude-sonnet-4-20250514'),
|
|
740
|
+
// Only include effort for Opus 4.6 (supports adaptive thinking)
|
|
741
|
+
effort: model === 'claude-opus-4-6' ? effort : undefined,
|
|
742
|
+
max_turns: this.parseIntegerInput('settings-agent-max-turns', 1, 100, 10),
|
|
743
|
+
timeout: this.parseIntegerInput('settings-agent-timeout', 1, 600, 300) * 1000,
|
|
744
|
+
tools: this.collectToolModeData(),
|
|
745
|
+
},
|
|
746
|
+
token_budget: {
|
|
747
|
+
// Keep existing integer constraint for daily limit to avoid partial values.
|
|
748
|
+
daily_limit: this.parseOptionalNumber('settings-token-daily-limit', 'daily_limit', {
|
|
749
|
+
min: 0,
|
|
750
|
+
integerOnly: true,
|
|
751
|
+
}),
|
|
752
|
+
alert_threshold: this.parseOptionalNumber(
|
|
753
|
+
'settings-token-alert-threshold',
|
|
754
|
+
'alert_threshold',
|
|
755
|
+
{
|
|
756
|
+
min: 0,
|
|
757
|
+
max: 100,
|
|
758
|
+
integerOnly: false,
|
|
759
|
+
}
|
|
760
|
+
),
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
initBackendModelBinding(): void {
|
|
766
|
+
if (this.backendListenersInitialized) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
this.backendListenersInitialized = true;
|
|
770
|
+
const backendSelect = getElementByIdOrNull<HTMLSelectElement>('settings-agent-backend');
|
|
771
|
+
if (!backendSelect) {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
backendSelect.addEventListener('change', () => {
|
|
775
|
+
const backend = (this.getSelectValue('settings-agent-backend') || 'claude') as AgentBackend;
|
|
776
|
+
const currentModel = this.getSelectValue('settings-agent-model');
|
|
777
|
+
this.updateModelOptions(backend, currentModel);
|
|
778
|
+
const normalizedModel = this.getNormalizedModelForBackend(backend, currentModel);
|
|
779
|
+
this.setSelectValue('settings-agent-model', normalizedModel);
|
|
780
|
+
this.updateEffortVisibility(normalizedModel);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// Also listen for model changes to update effort visibility
|
|
784
|
+
const modelSelect = getElementByIdOrNull<HTMLSelectElement>('settings-agent-model');
|
|
785
|
+
if (modelSelect) {
|
|
786
|
+
modelSelect.addEventListener('change', () => {
|
|
787
|
+
const model = this.getSelectValue('settings-agent-model');
|
|
788
|
+
this.updateEffortVisibility(model);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
updateModelOptions(backend: AgentBackend, currentModel: string): void {
|
|
794
|
+
const select = getElementByIdOrNull<HTMLSelectElement>('settings-agent-model');
|
|
795
|
+
if (!select) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const modelList = MODEL_OPTIONS[backend] || MODEL_OPTIONS.claude;
|
|
799
|
+
const normalized = this.getNormalizedModelForBackend(backend, currentModel);
|
|
800
|
+
select.innerHTML = modelList
|
|
801
|
+
.map(
|
|
802
|
+
(m) =>
|
|
803
|
+
`<option value="${escapeHtml(m)}" ${m === normalized ? 'selected' : ''}>${escapeHtml(formatModelName(m))}</option>`
|
|
804
|
+
)
|
|
805
|
+
.join('');
|
|
806
|
+
|
|
807
|
+
// Update effort visibility when model options change
|
|
808
|
+
this.updateEffortVisibility(normalized);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Show/hide effort level dropdown based on model selection
|
|
813
|
+
* Effort level only applies to Claude Opus 4.6 which supports adaptive thinking
|
|
814
|
+
*/
|
|
815
|
+
updateEffortVisibility(model: string): void {
|
|
816
|
+
const effortContainer = getElementByIdOrNull<HTMLElement>('settings-effort-container');
|
|
817
|
+
if (!effortContainer) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
// Show effort only for Opus 4.6 which supports adaptive thinking
|
|
821
|
+
const supportsEffort = model === 'claude-opus-4-6';
|
|
822
|
+
effortContainer.style.display = supportsEffort ? 'block' : 'none';
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
getNormalizedModelForBackend(backend: AgentBackend, model: string): string {
|
|
826
|
+
const isCodexBackend = backend === 'codex-mcp';
|
|
827
|
+
if (!model) {
|
|
828
|
+
return isCodexBackend ? 'gpt-5.2-codex' : 'claude-sonnet-4-20250514';
|
|
829
|
+
}
|
|
830
|
+
const isClaudeModel = /^claude-/i.test(model);
|
|
831
|
+
if (isCodexBackend && isClaudeModel) {
|
|
832
|
+
return 'gpt-5.2-codex';
|
|
833
|
+
}
|
|
834
|
+
if (backend === 'claude' && !isClaudeModel) {
|
|
835
|
+
return 'claude-sonnet-4-20250514';
|
|
836
|
+
}
|
|
837
|
+
return model;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Collect tool selection data from checkboxes
|
|
842
|
+
*/
|
|
843
|
+
collectToolModeData(): SettingsPayloadToolConfig {
|
|
844
|
+
const gatewayTools: string[] = [];
|
|
845
|
+
const mcpTools: string[] = [];
|
|
846
|
+
|
|
847
|
+
// Collect selected Gateway tools
|
|
848
|
+
document.querySelectorAll<HTMLInputElement>('.gateway-tool:checked').forEach((cb) => {
|
|
849
|
+
gatewayTools.push(cb.value);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// Collect selected MCP tools
|
|
853
|
+
document.querySelectorAll<HTMLInputElement>('.mcp-tool:checked').forEach((cb) => {
|
|
854
|
+
mcpTools.push(cb.value);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// If all Gateway tools are selected, use wildcard
|
|
858
|
+
const allGateway = document.querySelectorAll<HTMLInputElement>('.gateway-tool');
|
|
859
|
+
if (gatewayTools.length === allGateway.length && gatewayTools.length > 0) {
|
|
860
|
+
return {
|
|
861
|
+
gateway: ['*'],
|
|
862
|
+
mcp: mcpTools,
|
|
863
|
+
mcp_config: '~/.mama/mama-mcp-config.json',
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return {
|
|
868
|
+
gateway: gatewayTools,
|
|
869
|
+
mcp: mcpTools,
|
|
870
|
+
mcp_config: '~/.mama/mama-mcp-config.json',
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Reset form to current saved values
|
|
876
|
+
*/
|
|
877
|
+
resetForm(): void {
|
|
878
|
+
this.populateForm();
|
|
879
|
+
this.setStatus('Form reset');
|
|
880
|
+
setTimeout(() => this.setStatus(''), 2000);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Helper: Set checkbox value
|
|
885
|
+
*/
|
|
886
|
+
setCheckbox(id: string, checked: boolean): void {
|
|
887
|
+
const el = getElementByIdOrNull<HTMLInputElement>(id);
|
|
888
|
+
if (el) {
|
|
889
|
+
el.checked = !!checked;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Helper: Get checkbox value
|
|
895
|
+
*/
|
|
896
|
+
getCheckbox(id: string): boolean {
|
|
897
|
+
const el = getElementByIdOrNull<HTMLInputElement>(id);
|
|
898
|
+
return el ? el.checked : false;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Helper: Set input value
|
|
903
|
+
* @param {string} id - Element ID
|
|
904
|
+
* @param {string} value - Value to set
|
|
905
|
+
* @param {boolean} isSensitive - If true, treat as sensitive token (keep if masked)
|
|
906
|
+
*/
|
|
907
|
+
setValue(id: string, value: string | number, isSensitive = false): void {
|
|
908
|
+
const el = getElementByIdOrNull<HTMLInputElement>(id);
|
|
909
|
+
if (el) {
|
|
910
|
+
// For sensitive fields (tokens), preserve placeholder if value is masked
|
|
911
|
+
const normalized = String(value ?? '');
|
|
912
|
+
if (isSensitive && this.isMaskedToken(normalized)) {
|
|
913
|
+
el.placeholder = normalized;
|
|
914
|
+
el.value = '';
|
|
915
|
+
} else {
|
|
916
|
+
el.value = normalized;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Check if a token is masked (e.g., "***[redacted]***")
|
|
923
|
+
*/
|
|
924
|
+
isMaskedToken(token: string | number | undefined): boolean {
|
|
925
|
+
if (token === undefined || token === null) {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
const str = String(token);
|
|
929
|
+
return str === '***[redacted]***' || (str.startsWith('***[') && str.endsWith(']***'));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Get token value from input, preserving original if input is empty and original was masked
|
|
934
|
+
* @param {string} id - Input element ID
|
|
935
|
+
* @param {string} originalToken - Original token value from config
|
|
936
|
+
* @returns {string} Token to send (either new value or original masked token)
|
|
937
|
+
*/
|
|
938
|
+
getTokenValue(id: string, originalToken?: string): string {
|
|
939
|
+
const inputValue = this.getValue(id);
|
|
940
|
+
|
|
941
|
+
// If user entered a new value, use it
|
|
942
|
+
if (inputValue && inputValue.trim() !== '') {
|
|
943
|
+
return inputValue;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// If input is empty and original was masked, keep the masked token (backend will preserve it)
|
|
947
|
+
if (this.isMaskedToken(originalToken)) {
|
|
948
|
+
return originalToken;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Otherwise return the input value (may be empty)
|
|
952
|
+
return inputValue;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Helper: Get input value
|
|
957
|
+
*/
|
|
958
|
+
getValue(id: string): string {
|
|
959
|
+
const el = getElementByIdOrNull<HTMLInputElement>(id);
|
|
960
|
+
return el ? el.value : '';
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Helper: Set select value
|
|
965
|
+
*/
|
|
966
|
+
setSelectValue(id: string, value: string): void {
|
|
967
|
+
const el = getElementByIdOrNull<HTMLSelectElement>(id);
|
|
968
|
+
if (el) {
|
|
969
|
+
el.value = value;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Helper: Get select value
|
|
975
|
+
*/
|
|
976
|
+
getSelectValue(id: string): string {
|
|
977
|
+
const el = getElementByIdOrNull<HTMLSelectElement>(id);
|
|
978
|
+
return el ? el.value : '';
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Helper: Set radio button
|
|
983
|
+
*/
|
|
984
|
+
setRadio(id: string, checked: boolean): void {
|
|
985
|
+
const el = getElementByIdOrNull<HTMLInputElement>(id);
|
|
986
|
+
if (el) {
|
|
987
|
+
el.checked = !!checked;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Helper: Get radio button value
|
|
993
|
+
*/
|
|
994
|
+
getRadio(id: string): boolean {
|
|
995
|
+
const el = getElementByIdOrNull<HTMLInputElement>(id);
|
|
996
|
+
return el ? el.checked : false;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Set status message
|
|
1001
|
+
*/
|
|
1002
|
+
setStatus(message: string, type: SettingsFilterValue = ''): void {
|
|
1003
|
+
const statusEl = getElementByIdOrNull<HTMLElement>('settings-status');
|
|
1004
|
+
if (statusEl) {
|
|
1005
|
+
statusEl.textContent = message;
|
|
1006
|
+
statusEl.className = `text-sm ${
|
|
1007
|
+
type === 'error'
|
|
1008
|
+
? 'text-red-500'
|
|
1009
|
+
: type === 'success'
|
|
1010
|
+
? 'text-green-500'
|
|
1011
|
+
: 'text-gray-500 dark:text-gray-400'
|
|
1012
|
+
}`;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Populate Multi-Agent Team section (F3)
|
|
1018
|
+
*/
|
|
1019
|
+
populateMultiAgentSection(): void {
|
|
1020
|
+
const container = getElementByIdOrNull<HTMLElement>('settings-multi-agent-container');
|
|
1021
|
+
if (!container) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const agents = (this.multiAgentData?.agents || []) as MultiAgentAgent[];
|
|
1026
|
+
|
|
1027
|
+
if (agents.length === 0) {
|
|
1028
|
+
container.innerHTML = `
|
|
1029
|
+
<div class="bg-white border border-gray-200 rounded-lg p-3 text-xs text-gray-500">
|
|
1030
|
+
No agents configured. Add agents in <code class="bg-gray-100 px-1 rounded">config.yaml</code>
|
|
1031
|
+
</div>
|
|
1032
|
+
`;
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Tier badge colors
|
|
1037
|
+
const tierColors: Record<number, string> = {
|
|
1038
|
+
1: 'bg-indigo-100 text-indigo-700',
|
|
1039
|
+
2: 'bg-green-100 text-green-700',
|
|
1040
|
+
3: 'bg-yellow-100 text-yellow-700',
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
const agentCards = agents
|
|
1044
|
+
.map((agent: MultiAgentAgent) => {
|
|
1045
|
+
const tierColor = tierColors[agent.tier || 1] || tierColors[1];
|
|
1046
|
+
const backend =
|
|
1047
|
+
((agent.backend || this.config?.agent?.backend || 'claude') as AgentBackend) || 'claude';
|
|
1048
|
+
const normalizedModel = this.getNormalizedModelForBackend(backend, agent.model || '');
|
|
1049
|
+
const agentId = agent.id || '';
|
|
1050
|
+
const backendOptions = ['codex-mcp', 'claude']
|
|
1051
|
+
.map(
|
|
1052
|
+
(b) =>
|
|
1053
|
+
`<option value="${escapeAttr(b)}" ${backend === b ? 'selected' : ''}>${escapeHtml(b)}</option>`
|
|
1054
|
+
)
|
|
1055
|
+
.join('');
|
|
1056
|
+
const modelOptions = MODEL_OPTIONS[backend] || MODEL_OPTIONS.claude;
|
|
1057
|
+
const modelOptionHtml = modelOptions
|
|
1058
|
+
.map(
|
|
1059
|
+
(m) =>
|
|
1060
|
+
`<option value="${escapeAttr(m)}" ${m === normalizedModel ? 'selected' : ''}>${escapeHtml(formatModelName(m))}</option>`
|
|
1061
|
+
)
|
|
1062
|
+
.join('');
|
|
1063
|
+
|
|
1064
|
+
// Permission flags
|
|
1065
|
+
const canDelegate = agent.can_delegate ?? false;
|
|
1066
|
+
const hasAllTools = agent.tool_permissions?.allowed?.includes('*') ?? true;
|
|
1067
|
+
|
|
1068
|
+
return `
|
|
1069
|
+
<div class="bg-white border border-gray-200 rounded-lg p-2 shadow-sm hover:shadow-md transition-shadow">
|
|
1070
|
+
<!-- Header: Tier + Name + Toggle -->
|
|
1071
|
+
<div class="flex items-center justify-between mb-2">
|
|
1072
|
+
<div class="flex items-center gap-1.5">
|
|
1073
|
+
<span class="${tierColor} text-[10px] font-bold px-1 py-0.5 rounded">T${agent.tier}</span>
|
|
1074
|
+
<span class="font-medium text-gray-900 text-xs truncate max-w-[80px]" title="${escapeAttr(agent.display_name || agent.name)}">${escapeHtml(agent.display_name || agent.name)}</span>
|
|
1075
|
+
</div>
|
|
1076
|
+
<label class="relative inline-flex items-center cursor-pointer">
|
|
1077
|
+
<input type="checkbox" class="sr-only peer" data-action="agent-toggle" data-agent-id="${escapeAttr(agentId)}" ${agent.enabled ? 'checked' : ''}>
|
|
1078
|
+
<div class="w-7 h-4 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-green-500"></div>
|
|
1079
|
+
</label>
|
|
1080
|
+
</div>
|
|
1081
|
+
|
|
1082
|
+
<!-- Backend + Model Row -->
|
|
1083
|
+
<div class="flex gap-1 mb-2">
|
|
1084
|
+
<select id="agent-backend-${escapeAttr(agentId)}" data-action="agent-backend" data-agent-id="${escapeAttr(agentId)}" class="flex-1 text-[10px] rounded border border-gray-200 px-1 py-0.5 bg-gray-50">${backendOptions}</select>
|
|
1085
|
+
<select id="agent-model-${escapeAttr(agentId)}" class="flex-1 text-[10px] rounded border border-gray-200 px-1 py-0.5 bg-gray-50">${modelOptionHtml}</select>
|
|
1086
|
+
</div>
|
|
1087
|
+
|
|
1088
|
+
<!-- Permissions Row -->
|
|
1089
|
+
<div class="flex items-center gap-2 mb-2 text-[10px] text-gray-600">
|
|
1090
|
+
<label class="flex items-center gap-0.5 cursor-pointer">
|
|
1091
|
+
<input type="checkbox" id="agent-delegate-${escapeAttr(agentId)}" class="w-3 h-3 rounded border-gray-300 text-yellow-500 focus:ring-yellow-400" ${canDelegate ? 'checked' : ''}>
|
|
1092
|
+
<span>Delegate</span>
|
|
1093
|
+
</label>
|
|
1094
|
+
<label class="flex items-center gap-0.5 cursor-pointer">
|
|
1095
|
+
<input type="checkbox" id="agent-alltools-${escapeAttr(agentId)}" class="w-3 h-3 rounded border-gray-300 text-yellow-500 focus:ring-yellow-400" ${hasAllTools ? 'checked' : ''}>
|
|
1096
|
+
<span>All Tools</span>
|
|
1097
|
+
</label>
|
|
1098
|
+
</div>
|
|
1099
|
+
|
|
1100
|
+
<!-- Save Button -->
|
|
1101
|
+
<button type="button" data-action="agent-save" data-agent-id="${escapeAttr(agentId)}" class="w-full text-[10px] px-2 py-1 rounded bg-yellow-400 text-black hover:bg-yellow-300 font-medium">Save</button>
|
|
1102
|
+
</div>
|
|
1103
|
+
`;
|
|
1104
|
+
})
|
|
1105
|
+
.join('');
|
|
1106
|
+
|
|
1107
|
+
// Grid layout: 2 cols on mobile, 3 cols on md+
|
|
1108
|
+
container.innerHTML = `<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">${agentCards}</div>`;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Toggle agent enabled status (F3)
|
|
1113
|
+
*/
|
|
1114
|
+
async toggleAgent(agentId: string, enabled: boolean): Promise<void> {
|
|
1115
|
+
try {
|
|
1116
|
+
await API.put(`/api/multi-agent/agents/${agentId}`, { enabled });
|
|
1117
|
+
|
|
1118
|
+
logger.info(`Agent ${agentId} ${enabled ? 'enabled' : 'disabled'}`);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
logger.error('Failed to toggle agent:', error);
|
|
1121
|
+
// Revert checkbox on error
|
|
1122
|
+
const checkbox = document.querySelector<HTMLInputElement>(
|
|
1123
|
+
`input[data-action="agent-toggle"][data-agent-id="${agentId}"]`
|
|
1124
|
+
);
|
|
1125
|
+
if (checkbox) {
|
|
1126
|
+
checkbox.checked = !enabled;
|
|
1127
|
+
}
|
|
1128
|
+
alert(`Failed to update agent: ${error instanceof Error ? error.message : String(error)}`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
onAgentBackendChange(agentId: string): void {
|
|
1133
|
+
const backendSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-backend-${agentId}`);
|
|
1134
|
+
const modelSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-model-${agentId}`);
|
|
1135
|
+
if (!backendSelect || !modelSelect) {
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const backend = (backendSelect.value || 'claude') as AgentBackend;
|
|
1140
|
+
const currentModel = modelSelect.value || '';
|
|
1141
|
+
const normalized = this.getNormalizedModelForBackend(backend, currentModel);
|
|
1142
|
+
const options = MODEL_OPTIONS[backend] || MODEL_OPTIONS.claude;
|
|
1143
|
+
|
|
1144
|
+
modelSelect.innerHTML = options
|
|
1145
|
+
.map(
|
|
1146
|
+
(m) =>
|
|
1147
|
+
`<option value="${escapeAttr(m)}" ${m === normalized ? 'selected' : ''}>${escapeHtml(formatModelName(m))}</option>`
|
|
1148
|
+
)
|
|
1149
|
+
.join('');
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async saveAgentConfig(agentId: string): Promise<void> {
|
|
1153
|
+
try {
|
|
1154
|
+
const backendSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-backend-${agentId}`);
|
|
1155
|
+
const modelSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-model-${agentId}`);
|
|
1156
|
+
const delegateCheckbox = getElementByIdOrNull<HTMLInputElement>(`agent-delegate-${agentId}`);
|
|
1157
|
+
const allToolsCheckbox = getElementByIdOrNull<HTMLInputElement>(`agent-alltools-${agentId}`);
|
|
1158
|
+
|
|
1159
|
+
if (!backendSelect || !modelSelect) {
|
|
1160
|
+
throw new Error('Agent settings inputs not found');
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const backend = (backendSelect.value || 'claude') as AgentBackend;
|
|
1164
|
+
const model = this.getNormalizedModelForBackend(backend, modelSelect.value || '');
|
|
1165
|
+
const can_delegate = delegateCheckbox?.checked ?? false;
|
|
1166
|
+
const hasAllTools = allToolsCheckbox?.checked ?? true;
|
|
1167
|
+
|
|
1168
|
+
// Build tool_permissions based on checkbox
|
|
1169
|
+
const tool_permissions = hasAllTools
|
|
1170
|
+
? { allowed: ['*'], blocked: [] }
|
|
1171
|
+
: { allowed: ['Read', 'Grep', 'Glob'], blocked: [] };
|
|
1172
|
+
|
|
1173
|
+
await API.put(`/api/multi-agent/agents/${agentId}`, {
|
|
1174
|
+
backend,
|
|
1175
|
+
model,
|
|
1176
|
+
can_delegate,
|
|
1177
|
+
tool_permissions,
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
showToast(`Saved ${agentId} (applied)`);
|
|
1181
|
+
await this.loadSettings();
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
logger.error('Failed to save agent config:', error);
|
|
1184
|
+
alert(
|
|
1185
|
+
`Failed to save agent config: ${error instanceof Error ? error.message : String(error)}`
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Populate installed skills section
|
|
1192
|
+
*/
|
|
1193
|
+
async populateSkillsSection(): Promise<void> {
|
|
1194
|
+
const container = getElementByIdOrNull<HTMLElement>('settings-skills-container');
|
|
1195
|
+
if (!container) {
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
try {
|
|
1200
|
+
const { skills } = await API.get<SkillsResponse>('/api/skills');
|
|
1201
|
+
if (!skills || skills.length === 0) {
|
|
1202
|
+
container.innerHTML = '<p class="text-xs text-gray-400">No skills installed</p>';
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const sourceColors = {
|
|
1207
|
+
mama: 'bg-yellow-100 text-yellow-700',
|
|
1208
|
+
cowork: 'bg-blue-100 text-blue-700',
|
|
1209
|
+
external: 'bg-purple-100 text-purple-700',
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
container.innerHTML = `
|
|
1213
|
+
<div class="space-y-1.5">
|
|
1214
|
+
${skills
|
|
1215
|
+
.map(
|
|
1216
|
+
(s) => `
|
|
1217
|
+
<div class="flex items-center justify-between py-1">
|
|
1218
|
+
<div class="flex items-center gap-2">
|
|
1219
|
+
<span class="text-xs font-medium text-gray-900">${escapeHtml(s.name)}</span>
|
|
1220
|
+
<span class="text-[10px] px-1.5 py-0.5 rounded ${sourceColors[s.source] || 'bg-gray-100 text-gray-600'}">${escapeHtml(s.source)}</span>
|
|
1221
|
+
</div>
|
|
1222
|
+
<label class="relative inline-flex items-center cursor-pointer">
|
|
1223
|
+
<input type="checkbox" ${s.enabled !== false ? 'checked' : ''}
|
|
1224
|
+
data-skill-source="${escapeHtml(s.source)}"
|
|
1225
|
+
data-skill-id="${escapeHtml(s.id)}"
|
|
1226
|
+
class="sr-only peer">
|
|
1227
|
+
<div class="w-9 h-5 bg-gray-200 peer-focus:ring-2 peer-focus:ring-yellow-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-yellow-400"></div>
|
|
1228
|
+
</label>
|
|
1229
|
+
</div>
|
|
1230
|
+
`
|
|
1231
|
+
)
|
|
1232
|
+
.join('')}
|
|
1233
|
+
</div>
|
|
1234
|
+
`;
|
|
1235
|
+
container.querySelectorAll('input[data-skill-id]').forEach((input) => {
|
|
1236
|
+
input.addEventListener('change', (event) => {
|
|
1237
|
+
const target = event.target;
|
|
1238
|
+
if (!(target instanceof HTMLInputElement)) {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const source = target.dataset.skillSource || '';
|
|
1242
|
+
const id = target.dataset.skillId || '';
|
|
1243
|
+
if (!source || !id) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
this.toggleSkill(source, id, target.checked);
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
logger.warn('Skills load error:', error instanceof Error ? error.message : String(error));
|
|
1251
|
+
container.innerHTML = '<p class="text-xs text-gray-400">Failed to load skills</p>';
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Toggle skill enabled/disabled from settings
|
|
1257
|
+
*/
|
|
1258
|
+
async toggleSkill(source: string, name: string, enabled: boolean): Promise<void> {
|
|
1259
|
+
try {
|
|
1260
|
+
await API.toggleSkill(name, enabled, source);
|
|
1261
|
+
} catch (error) {
|
|
1262
|
+
logger.error('Skill toggle failed:', error instanceof Error ? error.message : String(error));
|
|
1263
|
+
this.populateSkillsSection();
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Populate scheduled jobs section
|
|
1269
|
+
*/
|
|
1270
|
+
async populateCronSection(): Promise<void> {
|
|
1271
|
+
const container = getElementByIdOrNull<HTMLElement>('settings-cron-container');
|
|
1272
|
+
if (!container) {
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
try {
|
|
1277
|
+
const { jobs } = await API.get<CronJobsResponse>('/api/cron');
|
|
1278
|
+
if (!jobs || jobs.length === 0) {
|
|
1279
|
+
container.innerHTML = '<p class="text-xs text-gray-400">No scheduled jobs</p>';
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const cronJobs = jobs as CronJob[];
|
|
1284
|
+
container.innerHTML = `<div class="space-y-1.5">${cronJobs
|
|
1285
|
+
.map((job) => {
|
|
1286
|
+
const nextRun = job.nextRun
|
|
1287
|
+
? new Date(job.nextRun).toLocaleString([], {
|
|
1288
|
+
month: 'short',
|
|
1289
|
+
day: 'numeric',
|
|
1290
|
+
hour: '2-digit',
|
|
1291
|
+
minute: '2-digit',
|
|
1292
|
+
})
|
|
1293
|
+
: '-';
|
|
1294
|
+
return `
|
|
1295
|
+
<div class="flex items-center justify-between py-1">
|
|
1296
|
+
<div class="flex-1 min-w-0">
|
|
1297
|
+
<div class="flex items-center gap-2">
|
|
1298
|
+
<span class="text-xs font-medium text-gray-900">${escapeHtml(job.name || job.id)}</span>
|
|
1299
|
+
<code class="text-[10px] bg-gray-100 px-1 rounded">${escapeHtml(job.schedule || job.cronExpr || '')}</code>
|
|
1300
|
+
</div>
|
|
1301
|
+
<p class="text-[10px] text-gray-500 truncate">${escapeHtml((job.prompt || '').slice(0, 80))}</p>
|
|
1302
|
+
</div>
|
|
1303
|
+
<div class="flex items-center gap-1 ml-2 shrink-0">
|
|
1304
|
+
<span class="text-[10px] text-gray-400">${nextRun}</span>
|
|
1305
|
+
<label class="relative inline-flex items-center cursor-pointer">
|
|
1306
|
+
<input
|
|
1307
|
+
type="checkbox"
|
|
1308
|
+
${job.enabled !== false ? 'checked' : ''}
|
|
1309
|
+
data-action="cron-toggle"
|
|
1310
|
+
data-cron-id="${escapeAttr(job.id)}"
|
|
1311
|
+
class="sr-only peer cron-toggle"
|
|
1312
|
+
>
|
|
1313
|
+
<div class="w-9 h-5 bg-gray-200 peer-focus:ring-2 peer-focus:ring-yellow-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-yellow-400"></div>
|
|
1314
|
+
</label>
|
|
1315
|
+
<button
|
|
1316
|
+
type="button"
|
|
1317
|
+
data-action="cron-delete"
|
|
1318
|
+
data-cron-id="${escapeAttr(job.id)}"
|
|
1319
|
+
class="text-red-400 hover:text-red-600 text-xs px-1"
|
|
1320
|
+
title="Delete"
|
|
1321
|
+
>
|
|
1322
|
+
✕
|
|
1323
|
+
</button>
|
|
1324
|
+
</div>
|
|
1325
|
+
</div>`;
|
|
1326
|
+
})
|
|
1327
|
+
.join('')}</div>`;
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
logger.warn('Cron load error:', error);
|
|
1330
|
+
container.innerHTML = '<p class="text-xs text-gray-400">Failed to load jobs</p>';
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
async addCronJob(): Promise<void> {
|
|
1335
|
+
const nameInput = getElementByIdOrNull<HTMLInputElement>('settings-cron-name');
|
|
1336
|
+
const cronExprInput = getElementByIdOrNull<HTMLInputElement>('settings-cron-expr');
|
|
1337
|
+
const promptInput = getElementByIdOrNull<HTMLInputElement>('settings-cron-prompt');
|
|
1338
|
+
if (!nameInput || !cronExprInput || !promptInput) {
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const name = nameInput.value.trim();
|
|
1343
|
+
const cronExpr = cronExprInput.value.trim();
|
|
1344
|
+
const prompt = promptInput.value.trim();
|
|
1345
|
+
|
|
1346
|
+
if (!name || !cronExpr || !prompt) {
|
|
1347
|
+
showToast('Please fill in all fields');
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
try {
|
|
1352
|
+
await API.post('/api/cron', { name, cron_expr: cronExpr, prompt });
|
|
1353
|
+
|
|
1354
|
+
nameInput.value = '';
|
|
1355
|
+
cronExprInput.value = '';
|
|
1356
|
+
promptInput.value = '';
|
|
1357
|
+
showToast('Job created');
|
|
1358
|
+
this.populateCronSection();
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
showToast(`Failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async toggleCronJob(id: string, enabled: boolean): Promise<void> {
|
|
1365
|
+
try {
|
|
1366
|
+
await API.updateCronJob(id, { enabled });
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
logger.error('Cron toggle failed:', error);
|
|
1369
|
+
this.populateCronSection();
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
async deleteCronJob(id: string): Promise<void> {
|
|
1374
|
+
if (!confirm('Delete this scheduled job?')) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
try {
|
|
1378
|
+
await API.del(`/api/cron/${encodeURIComponent(id)}`);
|
|
1379
|
+
showToast('Job deleted');
|
|
1380
|
+
this.populateCronSection();
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
showToast(`Failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Populate token budget section from config
|
|
1388
|
+
*/
|
|
1389
|
+
populateTokenSection(): void {
|
|
1390
|
+
const budget = this.config?.token_budget;
|
|
1391
|
+
if (!budget) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
this.setValue('settings-token-daily-limit', budget.daily_limit || '');
|
|
1396
|
+
this.setValue('settings-token-alert-threshold', budget.alert_threshold || '');
|
|
1397
|
+
}
|
|
1398
|
+
}
|