botschat 0.1.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/LICENSE +201 -0
- package/README.md +213 -0
- package/migrations/0001_initial.sql +88 -0
- package/migrations/0002_rename_projects_to_channels.sql +53 -0
- package/migrations/0003_messages.sql +14 -0
- package/migrations/0004_jobs.sql +15 -0
- package/migrations/0005_deleted_cron_jobs.sql +6 -0
- package/migrations/0006_tasks_add_model.sql +2 -0
- package/migrations/0007_sessions.sql +25 -0
- package/migrations/0008_remove_openclaw_fields.sql +8 -0
- package/package.json +53 -0
- package/packages/api/package.json +17 -0
- package/packages/api/src/do/connection-do.ts +929 -0
- package/packages/api/src/env.ts +8 -0
- package/packages/api/src/index.ts +297 -0
- package/packages/api/src/routes/agents.ts +68 -0
- package/packages/api/src/routes/auth.ts +105 -0
- package/packages/api/src/routes/channels.ts +185 -0
- package/packages/api/src/routes/jobs.ts +65 -0
- package/packages/api/src/routes/models.ts +22 -0
- package/packages/api/src/routes/pairing.ts +76 -0
- package/packages/api/src/routes/projects.ts +177 -0
- package/packages/api/src/routes/sessions.ts +171 -0
- package/packages/api/src/routes/tasks.ts +375 -0
- package/packages/api/src/routes/upload.ts +52 -0
- package/packages/api/src/utils/auth.ts +101 -0
- package/packages/api/src/utils/id.ts +19 -0
- package/packages/api/tsconfig.json +18 -0
- package/packages/plugin/dist/index.d.ts +19 -0
- package/packages/plugin/dist/index.d.ts.map +1 -0
- package/packages/plugin/dist/index.js +17 -0
- package/packages/plugin/dist/index.js.map +1 -0
- package/packages/plugin/dist/src/accounts.d.ts +12 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
- package/packages/plugin/dist/src/accounts.js +103 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -0
- package/packages/plugin/dist/src/channel.d.ts +206 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -0
- package/packages/plugin/dist/src/channel.js +1248 -0
- package/packages/plugin/dist/src/channel.js.map +1 -0
- package/packages/plugin/dist/src/runtime.d.ts +3 -0
- package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
- package/packages/plugin/dist/src/runtime.js +18 -0
- package/packages/plugin/dist/src/runtime.js.map +1 -0
- package/packages/plugin/dist/src/types.d.ts +179 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -0
- package/packages/plugin/dist/src/types.js +6 -0
- package/packages/plugin/dist/src/types.js.map +1 -0
- package/packages/plugin/dist/src/ws-client.d.ts +51 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
- package/packages/plugin/dist/src/ws-client.js +170 -0
- package/packages/plugin/dist/src/ws-client.js.map +1 -0
- package/packages/plugin/openclaw.plugin.json +11 -0
- package/packages/plugin/package.json +39 -0
- package/packages/plugin/tsconfig.json +20 -0
- package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
- package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
- package/packages/web/dist/index.html +17 -0
- package/packages/web/index.html +16 -0
- package/packages/web/package.json +29 -0
- package/packages/web/postcss.config.js +6 -0
- package/packages/web/src/App.tsx +827 -0
- package/packages/web/src/api.ts +242 -0
- package/packages/web/src/components/ChatWindow.tsx +864 -0
- package/packages/web/src/components/CronDetail.tsx +943 -0
- package/packages/web/src/components/CronSidebar.tsx +123 -0
- package/packages/web/src/components/DebugLogPanel.tsx +258 -0
- package/packages/web/src/components/IconRail.tsx +163 -0
- package/packages/web/src/components/JobList.tsx +120 -0
- package/packages/web/src/components/LoginPage.tsx +178 -0
- package/packages/web/src/components/MessageContent.tsx +1082 -0
- package/packages/web/src/components/ModelSelect.tsx +87 -0
- package/packages/web/src/components/ScheduleEditor.tsx +403 -0
- package/packages/web/src/components/SessionTabs.tsx +246 -0
- package/packages/web/src/components/Sidebar.tsx +331 -0
- package/packages/web/src/components/TaskBar.tsx +413 -0
- package/packages/web/src/components/ThreadPanel.tsx +212 -0
- package/packages/web/src/debug-log.ts +58 -0
- package/packages/web/src/index.css +170 -0
- package/packages/web/src/main.tsx +10 -0
- package/packages/web/src/store.ts +492 -0
- package/packages/web/src/ws.ts +99 -0
- package/packages/web/tailwind.config.js +65 -0
- package/packages/web/tsconfig.json +18 -0
- package/packages/web/vite.config.ts +20 -0
- package/scripts/dev.sh +122 -0
- package/tsconfig.json +18 -0
- package/wrangler.toml +40 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import React, { useReducer, useEffect, useCallback, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
appReducer,
|
|
4
|
+
initialState,
|
|
5
|
+
AppStateContext,
|
|
6
|
+
AppDispatchContext,
|
|
7
|
+
type ChatMessage,
|
|
8
|
+
type AppState,
|
|
9
|
+
type ActiveView,
|
|
10
|
+
} from "./store";
|
|
11
|
+
import { getToken, setToken, agentsApi, channelsApi, tasksApi, jobsApi, authApi, messagesApi, modelsApi, meApi, sessionsApi, type ModelInfo } from "./api";
|
|
12
|
+
import { ModelSelect } from "./components/ModelSelect";
|
|
13
|
+
import { BotsChatWSClient, type WSMessage } from "./ws";
|
|
14
|
+
import { IconRail } from "./components/IconRail";
|
|
15
|
+
import { Sidebar } from "./components/Sidebar";
|
|
16
|
+
import { ChatWindow } from "./components/ChatWindow";
|
|
17
|
+
import { ThreadPanel } from "./components/ThreadPanel";
|
|
18
|
+
import { JobList } from "./components/JobList";
|
|
19
|
+
import { LoginPage } from "./components/LoginPage";
|
|
20
|
+
import { DebugLogPanel } from "./components/DebugLogPanel";
|
|
21
|
+
import { CronSidebar } from "./components/CronSidebar";
|
|
22
|
+
import { CronDetail } from "./components/CronDetail";
|
|
23
|
+
import { dlog } from "./debug-log";
|
|
24
|
+
|
|
25
|
+
export default function App() {
|
|
26
|
+
const [state, dispatch] = useReducer(appReducer, initialState, (init): AppState => {
|
|
27
|
+
// Restore last active view from localStorage
|
|
28
|
+
try {
|
|
29
|
+
const savedView = localStorage.getItem("botschat_active_view");
|
|
30
|
+
if (savedView === "messages" || savedView === "automations") {
|
|
31
|
+
return { ...init, activeView: savedView as ActiveView };
|
|
32
|
+
}
|
|
33
|
+
} catch { /* ignore */ }
|
|
34
|
+
return init;
|
|
35
|
+
});
|
|
36
|
+
const wsClientRef = useRef<BotsChatWSClient | null>(null);
|
|
37
|
+
const handleWSMessageRef = useRef<(msg: WSMessage) => void>(() => {});
|
|
38
|
+
|
|
39
|
+
const [showSettings, setShowSettings] = useState(false);
|
|
40
|
+
|
|
41
|
+
// Theme state – default to system preference then dark
|
|
42
|
+
const [theme, setTheme] = useState<"dark" | "light">(() => {
|
|
43
|
+
const saved = localStorage.getItem("botschat_theme");
|
|
44
|
+
if (saved === "light" || saved === "dark") return saved;
|
|
45
|
+
return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
50
|
+
localStorage.setItem("botschat_theme", theme);
|
|
51
|
+
}, [theme]);
|
|
52
|
+
|
|
53
|
+
// Persist active view (messages / automations)
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
localStorage.setItem("botschat_active_view", state.activeView);
|
|
56
|
+
}, [state.activeView]);
|
|
57
|
+
|
|
58
|
+
// Persist selected cron task for automations view
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (state.selectedCronTaskId) {
|
|
61
|
+
localStorage.setItem("botschat_last_cron_task", state.selectedCronTaskId);
|
|
62
|
+
}
|
|
63
|
+
}, [state.selectedCronTaskId]);
|
|
64
|
+
|
|
65
|
+
// Persist selected session per channel
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (state.selectedSessionId) {
|
|
68
|
+
const agent = state.agents.find((a) => a.id === state.selectedAgentId);
|
|
69
|
+
if (agent?.channelId) {
|
|
70
|
+
localStorage.setItem(`botschat_last_session_${agent.channelId}`, state.selectedSessionId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}, [state.selectedSessionId, state.selectedAgentId, state.agents]);
|
|
74
|
+
|
|
75
|
+
const toggleTheme = useCallback(() => {
|
|
76
|
+
setTheme((t) => (t === "dark" ? "light" : "dark"));
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
// ---- Auto-login on mount ----
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const token = getToken();
|
|
82
|
+
if (token) {
|
|
83
|
+
dlog.api("Auth", "Auto-login with stored token");
|
|
84
|
+
authApi
|
|
85
|
+
.me()
|
|
86
|
+
.then((user) => {
|
|
87
|
+
dlog.info("Auth", `Logged in as ${user.email} (${user.id})`);
|
|
88
|
+
dispatch({ type: "SET_USER", user });
|
|
89
|
+
if (user.settings?.defaultModel) {
|
|
90
|
+
dispatch({ type: "SET_DEFAULT_MODEL", model: user.settings.defaultModel });
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.catch((err) => {
|
|
94
|
+
dlog.warn("Auth", `Auto-login failed: ${err}`);
|
|
95
|
+
setToken(null);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
// Models are delivered via WS (connection.status) on browser auth.
|
|
101
|
+
// Fallback: fetch from REST if WS didn't deliver models within 2s.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!state.user) return;
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
if (state.models.length === 0) {
|
|
106
|
+
modelsApi.list().then(({ models }) => {
|
|
107
|
+
if (models.length > 0) dispatch({ type: "SET_MODELS", models });
|
|
108
|
+
}).catch(() => {});
|
|
109
|
+
}
|
|
110
|
+
}, 2000);
|
|
111
|
+
return () => clearTimeout(timer);
|
|
112
|
+
}, [state.user, state.models.length]);
|
|
113
|
+
|
|
114
|
+
// ---- Load agents (default + channel agents) when user is set ----
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (state.user) {
|
|
117
|
+
dlog.api("Agents", "Loading agents list");
|
|
118
|
+
agentsApi.list().then(({ agents }) => {
|
|
119
|
+
dlog.info("Agents", `Loaded ${agents.length} agents`, agents.map((a) => ({ id: a.id, name: a.name, channelId: a.channelId })));
|
|
120
|
+
dispatch({ type: "SET_AGENTS", agents });
|
|
121
|
+
if (agents.length > 0 && !state.selectedAgentId) {
|
|
122
|
+
// Restore last selected channel from localStorage if available
|
|
123
|
+
let target = agents[0];
|
|
124
|
+
try {
|
|
125
|
+
const lastId = localStorage.getItem("botschat_last_agent");
|
|
126
|
+
if (lastId) {
|
|
127
|
+
const found = agents.find((a) => a.id === lastId);
|
|
128
|
+
if (found) {
|
|
129
|
+
dlog.info("Agents", `Restoring last channel: ${found.name} (${found.id})`);
|
|
130
|
+
target = found;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch { /* ignore */ }
|
|
134
|
+
dispatch({
|
|
135
|
+
type: "SELECT_AGENT",
|
|
136
|
+
agentId: target.id,
|
|
137
|
+
sessionKey: target.sessionKey,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
dlog.api("Channels", "Loading channels list");
|
|
142
|
+
channelsApi.list().then(({ channels }) => {
|
|
143
|
+
dlog.info("Channels", `Loaded ${channels.length} channels`, channels.map((c) => ({ id: c.id, name: c.name })));
|
|
144
|
+
dispatch({ type: "SET_CHANNELS", channels });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}, [state.user]);
|
|
148
|
+
|
|
149
|
+
// ---- Load cron tasks when switching to automations view ----
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (state.user && state.activeView === "automations") {
|
|
152
|
+
dlog.api("Cron", "Loading all background tasks + scan data");
|
|
153
|
+
// Load D1 task metadata AND OpenClaw scan data in parallel
|
|
154
|
+
Promise.all([
|
|
155
|
+
tasksApi.listAll("background"),
|
|
156
|
+
tasksApi.scanData(),
|
|
157
|
+
]).then(([{ tasks }, { tasks: scanTasks }]) => {
|
|
158
|
+
dlog.info("Cron", `Loaded ${tasks.length} cron tasks + ${scanTasks.length} scan entries`);
|
|
159
|
+
dispatch({ type: "SET_CRON_TASKS", cronTasks: tasks });
|
|
160
|
+
// Merge OpenClaw-owned fields (schedule/instructions/model)
|
|
161
|
+
dispatch({
|
|
162
|
+
type: "MERGE_SCAN_DATA",
|
|
163
|
+
scanTasks: scanTasks.map((t) => ({
|
|
164
|
+
cronJobId: t.cronJobId,
|
|
165
|
+
schedule: t.schedule,
|
|
166
|
+
instructions: t.instructions,
|
|
167
|
+
model: t.model || undefined,
|
|
168
|
+
enabled: t.enabled,
|
|
169
|
+
})),
|
|
170
|
+
});
|
|
171
|
+
if (tasks.length > 0 && !state.selectedCronTaskId) {
|
|
172
|
+
// Restore last selected cron task from localStorage if available
|
|
173
|
+
let targetTaskId = tasks[0].id;
|
|
174
|
+
try {
|
|
175
|
+
const lastId = localStorage.getItem("botschat_last_cron_task");
|
|
176
|
+
if (lastId) {
|
|
177
|
+
const found = tasks.find((t) => t.id === lastId);
|
|
178
|
+
if (found) {
|
|
179
|
+
dlog.info("Cron", `Restoring last cron task: ${found.name} (${found.id})`);
|
|
180
|
+
targetTaskId = found.id;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch { /* ignore */ }
|
|
184
|
+
dispatch({ type: "SELECT_CRON_TASK", taskId: targetTaskId });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}, [state.user, state.activeView]);
|
|
189
|
+
|
|
190
|
+
// ---- When agent changes (or switching back to messages view), load sessions ----
|
|
191
|
+
// Derive channelId so the effect re-runs when the default agent gets a channelId
|
|
192
|
+
const selectedAgentChannelId = state.agents.find((a) => a.id === state.selectedAgentId)?.channelId;
|
|
193
|
+
// Include activeView so sessions + sessionKey are restored after automations view
|
|
194
|
+
// (SELECT_CRON_TASK / SELECT_CRON_JOB overwrite the shared selectedSessionKey)
|
|
195
|
+
const isMessagesView = state.activeView === "messages";
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
// Only load sessions when in messages view
|
|
199
|
+
if (!isMessagesView) return;
|
|
200
|
+
|
|
201
|
+
if (!state.selectedAgentId) {
|
|
202
|
+
dispatch({ type: "SET_TASKS", tasks: [] });
|
|
203
|
+
dispatch({ type: "SELECT_TASK", taskId: null });
|
|
204
|
+
dispatch({ type: "SET_SESSIONS", sessions: [] });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const agent = state.agents.find((a) => a.id === state.selectedAgentId);
|
|
209
|
+
if (agent?.channelId) {
|
|
210
|
+
// Load tasks (for non-default channel agents)
|
|
211
|
+
if (!agent.isDefault) {
|
|
212
|
+
tasksApi.list(agent.channelId).then(({ tasks }) => {
|
|
213
|
+
dispatch({ type: "SET_TASKS", tasks });
|
|
214
|
+
if (tasks.length > 0) {
|
|
215
|
+
const first = tasks[0];
|
|
216
|
+
dispatch({
|
|
217
|
+
type: "SELECT_TASK",
|
|
218
|
+
taskId: first.id,
|
|
219
|
+
// Don't set sessionKey here — sessions will handle it
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
dispatch({ type: "SET_TASKS", tasks: [] });
|
|
225
|
+
dispatch({ type: "SELECT_TASK", taskId: null });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Load sessions for this channel (all agents including default)
|
|
229
|
+
sessionsApi.list(agent.channelId).then(({ sessions }) => {
|
|
230
|
+
dlog.info("Sessions", `Loaded ${sessions.length} sessions for channel ${agent.channelId}`);
|
|
231
|
+
dispatch({ type: "SET_SESSIONS", sessions });
|
|
232
|
+
if (sessions.length > 0) {
|
|
233
|
+
// Restore last selected session from localStorage if available
|
|
234
|
+
let target = sessions[0];
|
|
235
|
+
try {
|
|
236
|
+
const lastId = localStorage.getItem(`botschat_last_session_${agent.channelId}`);
|
|
237
|
+
if (lastId) {
|
|
238
|
+
const found = sessions.find((s) => s.id === lastId);
|
|
239
|
+
if (found) {
|
|
240
|
+
dlog.info("Sessions", `Restoring last session: ${found.name} (${found.id})`);
|
|
241
|
+
target = found;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch { /* ignore */ }
|
|
245
|
+
dispatch({
|
|
246
|
+
type: "SELECT_SESSION",
|
|
247
|
+
sessionId: target.id,
|
|
248
|
+
sessionKey: target.sessionKey,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}).catch((err) => {
|
|
252
|
+
dlog.error("Sessions", `Failed to load sessions: ${err}`);
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
dispatch({ type: "SET_TASKS", tasks: [] });
|
|
256
|
+
dispatch({ type: "SELECT_TASK", taskId: null });
|
|
257
|
+
dispatch({ type: "SET_SESSIONS", sessions: [] });
|
|
258
|
+
}
|
|
259
|
+
}, [state.selectedAgentId, selectedAgentChannelId, isMessagesView]);
|
|
260
|
+
|
|
261
|
+
// ---- Load jobs when a background task is selected ----
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (!state.selectedTaskId) return;
|
|
264
|
+
const task = state.tasks.find((t) => t.id === state.selectedTaskId);
|
|
265
|
+
if (!task || task.kind !== "background") {
|
|
266
|
+
dispatch({ type: "SET_JOBS", jobs: [] });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const agent = state.agents.find((a) => a.id === state.selectedAgentId);
|
|
270
|
+
if (!agent?.channelId) return;
|
|
271
|
+
|
|
272
|
+
jobsApi
|
|
273
|
+
.list(agent.channelId, task.id)
|
|
274
|
+
.then(({ jobs }) => {
|
|
275
|
+
dispatch({ type: "SET_JOBS", jobs });
|
|
276
|
+
// Auto-select the most recent job
|
|
277
|
+
if (jobs.length > 0 && !state.selectedJobId) {
|
|
278
|
+
dispatch({
|
|
279
|
+
type: "SELECT_JOB",
|
|
280
|
+
jobId: jobs[0].id,
|
|
281
|
+
sessionKey: jobs[0].sessionKey,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
.catch((err) => {
|
|
286
|
+
console.error("Failed to load jobs:", err);
|
|
287
|
+
});
|
|
288
|
+
}, [state.selectedTaskId, state.tasks]);
|
|
289
|
+
|
|
290
|
+
// ---- Load message history when session changes ----
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (!state.user || !state.selectedSessionKey) return;
|
|
293
|
+
let stale = false;
|
|
294
|
+
messagesApi
|
|
295
|
+
.list(state.user.id, state.selectedSessionKey)
|
|
296
|
+
.then(({ messages, replyCounts }) => {
|
|
297
|
+
// Guard against stale responses when the user rapidly switches channels:
|
|
298
|
+
// the cleanup function sets `stale = true` before the new effect runs.
|
|
299
|
+
if (!stale) {
|
|
300
|
+
dispatch({ type: "SET_MESSAGES", messages, replyCounts });
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
.catch((err) => {
|
|
304
|
+
console.error("Failed to load message history:", err);
|
|
305
|
+
});
|
|
306
|
+
return () => { stale = true; };
|
|
307
|
+
}, [state.user, state.selectedSessionKey]);
|
|
308
|
+
|
|
309
|
+
// Keep a ref to state for use in WS handler (avoids stale closures)
|
|
310
|
+
const stateRef = useRef(state);
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
stateRef.current = state;
|
|
313
|
+
}, [state]);
|
|
314
|
+
|
|
315
|
+
// ---- WS message handler ----
|
|
316
|
+
const handleWSMessage = useCallback(
|
|
317
|
+
(msg: WSMessage) => {
|
|
318
|
+
const sessionKey = msg.sessionKey as string | undefined;
|
|
319
|
+
const threadId = (msg.threadId ?? msg.replyToId) as string | undefined;
|
|
320
|
+
|
|
321
|
+
// Log every incoming WS message
|
|
322
|
+
dlog.wsIn("WS", `${msg.type}`, msg);
|
|
323
|
+
|
|
324
|
+
// Helper: extract base sessionKey (strip ":thread:*" suffix) for comparison
|
|
325
|
+
const getBaseSessionKey = (sk: string | undefined): string | undefined => {
|
|
326
|
+
if (!sk) return undefined;
|
|
327
|
+
return sk.replace(/:thread:.+$/, "");
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Helper: check if an incoming message's sessionKey matches the currently
|
|
331
|
+
// viewed session. This prevents cron-task or other background-session
|
|
332
|
+
// messages from being injected into the wrong chat view (and potentially
|
|
333
|
+
// replacing the user's streaming reply via ADD_MESSAGE).
|
|
334
|
+
const isCurrentSession = (sk: string | undefined): boolean => {
|
|
335
|
+
if (!sk) return true; // no sessionKey → allow (e.g. status messages)
|
|
336
|
+
const base = getBaseSessionKey(sk);
|
|
337
|
+
return base === stateRef.current.selectedSessionKey;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
switch (msg.type) {
|
|
341
|
+
case "connection.status":
|
|
342
|
+
dlog.info("Connection", `OpenClaw ${msg.openclawConnected ? "connected" : "disconnected"}${msg.defaultModel ? ` (default: ${msg.defaultModel})` : ""}`);
|
|
343
|
+
dispatch({
|
|
344
|
+
type: "SET_OPENCLAW_CONNECTED",
|
|
345
|
+
connected: msg.openclawConnected as boolean,
|
|
346
|
+
defaultModel: (msg.defaultModel as string) || undefined,
|
|
347
|
+
});
|
|
348
|
+
// Models are delivered alongside connection.status
|
|
349
|
+
if (Array.isArray(msg.models) && msg.models.length > 0) {
|
|
350
|
+
dispatch({ type: "SET_MODELS", models: msg.models as ModelInfo[] });
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
|
|
354
|
+
case "openclaw.disconnected":
|
|
355
|
+
dlog.warn("Connection", "OpenClaw disconnected");
|
|
356
|
+
dispatch({ type: "SET_OPENCLAW_CONNECTED", connected: false });
|
|
357
|
+
break;
|
|
358
|
+
|
|
359
|
+
case "model.changed":
|
|
360
|
+
if (msg.model && msg.sessionKey) {
|
|
361
|
+
dlog.info("Model", `Session model changed to: ${msg.model} (session: ${msg.sessionKey})`);
|
|
362
|
+
dispatch({ type: "SET_SESSION_MODEL", model: msg.model as string });
|
|
363
|
+
// Persist per-session model to localStorage
|
|
364
|
+
try {
|
|
365
|
+
const stored = JSON.parse(localStorage.getItem("botschat:sessionModels") || "{}");
|
|
366
|
+
stored[msg.sessionKey as string] = msg.model;
|
|
367
|
+
localStorage.setItem("botschat:sessionModels", JSON.stringify(stored));
|
|
368
|
+
} catch { /* ignore */ }
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
|
|
372
|
+
case "agent.stream.start":
|
|
373
|
+
// Only start streaming in the currently viewed session — otherwise
|
|
374
|
+
// background cron-task streams would inject a placeholder into the
|
|
375
|
+
// wrong chat, and the subsequent agent.text would replace the user's
|
|
376
|
+
// last message.
|
|
377
|
+
if (sessionKey && isCurrentSession(sessionKey)) {
|
|
378
|
+
// Detect thread streaming: threadId from message or extracted from sessionKey
|
|
379
|
+
const streamThreadId = threadId ?? sessionKey.match(/:thread:(.+)$/)?.[1];
|
|
380
|
+
dispatch({
|
|
381
|
+
type: "STREAM_START",
|
|
382
|
+
runId: msg.runId as string,
|
|
383
|
+
sessionKey,
|
|
384
|
+
threadId: streamThreadId,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
|
|
389
|
+
case "agent.stream.chunk":
|
|
390
|
+
// Already guarded by streamingRunId match in the reducer — if
|
|
391
|
+
// STREAM_START was skipped (different session), chunks are no-ops.
|
|
392
|
+
dispatch({
|
|
393
|
+
type: "STREAM_CHUNK",
|
|
394
|
+
runId: msg.runId as string,
|
|
395
|
+
sessionKey: sessionKey ?? "",
|
|
396
|
+
text: msg.text as string,
|
|
397
|
+
});
|
|
398
|
+
break;
|
|
399
|
+
|
|
400
|
+
case "agent.stream.end":
|
|
401
|
+
dispatch({
|
|
402
|
+
type: "STREAM_END",
|
|
403
|
+
runId: msg.runId as string,
|
|
404
|
+
});
|
|
405
|
+
break;
|
|
406
|
+
|
|
407
|
+
case "agent.text": {
|
|
408
|
+
// Skip messages for sessions we're not viewing — they'll be loaded
|
|
409
|
+
// from the server when the user navigates to that session.
|
|
410
|
+
if (!isCurrentSession(sessionKey)) break;
|
|
411
|
+
const chatMsg: ChatMessage = {
|
|
412
|
+
id: crypto.randomUUID(),
|
|
413
|
+
sender: "agent",
|
|
414
|
+
text: msg.text as string,
|
|
415
|
+
timestamp: Date.now(),
|
|
416
|
+
threadId,
|
|
417
|
+
};
|
|
418
|
+
if (threadId && sessionKey) {
|
|
419
|
+
dispatch({ type: "ADD_THREAD_MESSAGE", message: chatMsg });
|
|
420
|
+
} else {
|
|
421
|
+
dispatch({ type: "ADD_MESSAGE", message: chatMsg });
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
case "agent.media": {
|
|
427
|
+
if (!isCurrentSession(sessionKey)) break;
|
|
428
|
+
const mediaMsg: ChatMessage = {
|
|
429
|
+
id: crypto.randomUUID(),
|
|
430
|
+
sender: "agent",
|
|
431
|
+
text: (msg.caption as string) ?? "",
|
|
432
|
+
mediaUrl: msg.mediaUrl as string,
|
|
433
|
+
timestamp: Date.now(),
|
|
434
|
+
threadId,
|
|
435
|
+
};
|
|
436
|
+
if (threadId && sessionKey) {
|
|
437
|
+
dispatch({ type: "ADD_THREAD_MESSAGE", message: mediaMsg });
|
|
438
|
+
} else {
|
|
439
|
+
dispatch({ type: "ADD_MESSAGE", message: mediaMsg });
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
case "agent.a2ui": {
|
|
445
|
+
if (!isCurrentSession(sessionKey)) break;
|
|
446
|
+
const a2uiMsg: ChatMessage = {
|
|
447
|
+
id: crypto.randomUUID(),
|
|
448
|
+
sender: "agent",
|
|
449
|
+
text: "",
|
|
450
|
+
a2ui: msg.jsonl as string,
|
|
451
|
+
timestamp: Date.now(),
|
|
452
|
+
threadId,
|
|
453
|
+
};
|
|
454
|
+
if (threadId && sessionKey) {
|
|
455
|
+
dispatch({ type: "ADD_THREAD_MESSAGE", message: a2uiMsg });
|
|
456
|
+
} else {
|
|
457
|
+
dispatch({ type: "ADD_MESSAGE", message: a2uiMsg });
|
|
458
|
+
}
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
case "job.update": {
|
|
463
|
+
// A background task job completed/updated
|
|
464
|
+
const job = {
|
|
465
|
+
id: msg.jobId as string,
|
|
466
|
+
number: 0,
|
|
467
|
+
sessionKey: msg.sessionKey as string,
|
|
468
|
+
status: msg.status as "running" | "ok" | "error" | "skipped",
|
|
469
|
+
startedAt: msg.startedAt as number,
|
|
470
|
+
finishedAt: (msg.finishedAt as number) ?? null,
|
|
471
|
+
durationMs: (msg.durationMs as number) ?? null,
|
|
472
|
+
summary: (msg.summary as string) ?? "",
|
|
473
|
+
time: new Date(((msg.startedAt as number) ?? 0) * 1000).toLocaleString(),
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// Update Messages view jobs
|
|
477
|
+
if (job.status === "running") {
|
|
478
|
+
dispatch({ type: "ADD_JOB", job });
|
|
479
|
+
} else {
|
|
480
|
+
// Check if we already have this job (was "running", now finished)
|
|
481
|
+
const s = stateRef.current;
|
|
482
|
+
const existsInJobs = s.jobs.some((j) => j.id === job.id);
|
|
483
|
+
if (existsInJobs) {
|
|
484
|
+
// Update in place
|
|
485
|
+
dispatch({ type: "SET_JOBS", jobs: s.jobs.map((j) => j.id === job.id ? { ...j, ...job } : j) });
|
|
486
|
+
} else {
|
|
487
|
+
dispatch({ type: "ADD_JOB", job });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Update Automations view cronJobs
|
|
492
|
+
if (job.status === "running") {
|
|
493
|
+
dispatch({ type: "ADD_CRON_JOB", job });
|
|
494
|
+
} else {
|
|
495
|
+
dispatch({ type: "UPDATE_CRON_JOB", job });
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
case "job.output": {
|
|
501
|
+
// Streaming output from a running job — update the job's summary in real-time
|
|
502
|
+
const outputJobId = msg.jobId as string;
|
|
503
|
+
const outputText = msg.text as string;
|
|
504
|
+
if (outputJobId && outputText) {
|
|
505
|
+
dispatch({ type: "APPEND_JOB_OUTPUT", jobId: outputJobId, text: outputText });
|
|
506
|
+
}
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
case "task.scan.result": {
|
|
511
|
+
// Task scan completed — backend may have auto-created a default channel
|
|
512
|
+
// for orphan cron jobs. Reload agents, channels, and basic task metadata.
|
|
513
|
+
const scannedTasks = (msg.tasks as Array<{
|
|
514
|
+
cronJobId: string;
|
|
515
|
+
name: string;
|
|
516
|
+
schedule: string;
|
|
517
|
+
agentId: string;
|
|
518
|
+
enabled: boolean;
|
|
519
|
+
instructions?: string;
|
|
520
|
+
model?: string;
|
|
521
|
+
}>) ?? [];
|
|
522
|
+
dlog.info("TaskScan", `Scan result: ${scannedTasks.length} tasks reported`, scannedTasks);
|
|
523
|
+
|
|
524
|
+
// Reload agents and channels (backend may have created new ones)
|
|
525
|
+
agentsApi.list().then(({ agents }) => {
|
|
526
|
+
dlog.info("TaskScan", `Reloaded ${agents.length} agents`);
|
|
527
|
+
dispatch({ type: "SET_AGENTS", agents });
|
|
528
|
+
});
|
|
529
|
+
channelsApi.list().then(({ channels }) => {
|
|
530
|
+
dlog.info("TaskScan", `Reloaded ${channels.length} channels`);
|
|
531
|
+
dispatch({ type: "SET_CHANNELS", channels });
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// Reload basic task metadata from D1, then merge OpenClaw-owned
|
|
535
|
+
// fields (schedule, instructions, model) from the scan results.
|
|
536
|
+
tasksApi.listAll("background").then(({ tasks: cronTasks }) => {
|
|
537
|
+
dlog.info("TaskScan", `Reloaded ${cronTasks.length} cron tasks, merging scan data`);
|
|
538
|
+
dispatch({ type: "SET_CRON_TASKS", cronTasks });
|
|
539
|
+
// Merge schedule/instructions/model from scan results
|
|
540
|
+
dispatch({
|
|
541
|
+
type: "MERGE_SCAN_DATA",
|
|
542
|
+
scanTasks: scannedTasks.map((t) => ({
|
|
543
|
+
cronJobId: t.cronJobId,
|
|
544
|
+
schedule: t.schedule,
|
|
545
|
+
instructions: t.instructions ?? "",
|
|
546
|
+
model: t.model,
|
|
547
|
+
enabled: t.enabled,
|
|
548
|
+
})),
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const s = stateRef.current;
|
|
553
|
+
if (s.selectedAgentId) {
|
|
554
|
+
const currentAgent = s.agents.find((a) => a.id === s.selectedAgentId);
|
|
555
|
+
if (currentAgent?.channelId) {
|
|
556
|
+
tasksApi.list(currentAgent.channelId).then(({ tasks }) => {
|
|
557
|
+
dispatch({ type: "SET_TASKS", tasks });
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
case "status":
|
|
565
|
+
// Status pings carry the gateway default model, not the per-session model.
|
|
566
|
+
// model.changed is the authoritative source, so we intentionally ignore status.model.
|
|
567
|
+
break;
|
|
568
|
+
|
|
569
|
+
case "models.list":
|
|
570
|
+
if (Array.isArray(msg.models)) {
|
|
571
|
+
dispatch({ type: "SET_MODELS", models: msg.models as ModelInfo[] });
|
|
572
|
+
}
|
|
573
|
+
break;
|
|
574
|
+
|
|
575
|
+
case "task.schedule.ack":
|
|
576
|
+
if (msg.ok as boolean) {
|
|
577
|
+
dlog.info("Task", `Schedule applied to OpenClaw: ${msg.cronJobId}`);
|
|
578
|
+
} else {
|
|
579
|
+
dlog.error("Task", `Schedule push to OpenClaw failed: ${msg.error}`, msg);
|
|
580
|
+
// TODO: could revert optimistic update here
|
|
581
|
+
}
|
|
582
|
+
break;
|
|
583
|
+
|
|
584
|
+
case "error":
|
|
585
|
+
dlog.error("Server", msg.message as string, msg);
|
|
586
|
+
break;
|
|
587
|
+
|
|
588
|
+
default:
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
[],
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
handleWSMessageRef.current = handleWSMessage;
|
|
597
|
+
}, [handleWSMessage]);
|
|
598
|
+
|
|
599
|
+
// ---- WebSocket connection ----
|
|
600
|
+
useEffect(() => {
|
|
601
|
+
if (!state.user) return;
|
|
602
|
+
|
|
603
|
+
const token = getToken();
|
|
604
|
+
if (!token) return;
|
|
605
|
+
|
|
606
|
+
const sessionId = crypto.randomUUID();
|
|
607
|
+
dlog.info("WS", `Connecting WebSocket (session=${sessionId.slice(0, 8)}...)`);
|
|
608
|
+
const client = new BotsChatWSClient({
|
|
609
|
+
userId: state.user.id,
|
|
610
|
+
sessionId,
|
|
611
|
+
token,
|
|
612
|
+
onMessage: (msg) => {
|
|
613
|
+
handleWSMessageRef.current(msg);
|
|
614
|
+
},
|
|
615
|
+
onStatusChange: (connected) => {
|
|
616
|
+
dlog.info("WS", connected ? "WebSocket connected" : "WebSocket disconnected");
|
|
617
|
+
dispatch({ type: "SET_WS_CONNECTED", connected });
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
client.connect();
|
|
622
|
+
wsClientRef.current = client;
|
|
623
|
+
|
|
624
|
+
return () => {
|
|
625
|
+
client.disconnect();
|
|
626
|
+
wsClientRef.current = null;
|
|
627
|
+
};
|
|
628
|
+
}, [state.user]);
|
|
629
|
+
|
|
630
|
+
const sendMessage = useCallback((msg: WSMessage) => {
|
|
631
|
+
dlog.wsOut("WS", `${msg.type}`, msg);
|
|
632
|
+
wsClientRef.current?.send(msg);
|
|
633
|
+
}, []);
|
|
634
|
+
|
|
635
|
+
const handleDefaultModelChange = useCallback(async (modelId: string) => {
|
|
636
|
+
dispatch({ type: "SET_DEFAULT_MODEL", model: modelId || null });
|
|
637
|
+
try {
|
|
638
|
+
await meApi.updateSettings({ defaultModel: modelId || undefined });
|
|
639
|
+
} catch (err) {
|
|
640
|
+
console.error("Failed to update default model:", err);
|
|
641
|
+
}
|
|
642
|
+
}, []);
|
|
643
|
+
|
|
644
|
+
const handleSelectJob = useCallback(
|
|
645
|
+
(jobId: string) => {
|
|
646
|
+
const job = state.jobs.find((j) => j.id === jobId);
|
|
647
|
+
if (job) {
|
|
648
|
+
dispatch({
|
|
649
|
+
type: "SELECT_JOB",
|
|
650
|
+
jobId: job.id,
|
|
651
|
+
sessionKey: job.sessionKey || undefined,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
[state.jobs],
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
// ---- Render ----
|
|
659
|
+
if (!state.user) {
|
|
660
|
+
return (
|
|
661
|
+
<AppStateContext.Provider value={state}>
|
|
662
|
+
<AppDispatchContext.Provider value={dispatch}>
|
|
663
|
+
<LoginPage />
|
|
664
|
+
</AppDispatchContext.Provider>
|
|
665
|
+
</AppStateContext.Provider>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const selectedAgent = state.agents.find((a) => a.id === state.selectedAgentId);
|
|
670
|
+
const selectedTask = state.tasks.find((t) => t.id === state.selectedTaskId);
|
|
671
|
+
const isBackgroundTask = selectedTask?.kind === "background";
|
|
672
|
+
const hasSession = Boolean(state.selectedSessionKey);
|
|
673
|
+
|
|
674
|
+
const isAutomationsView = state.activeView === "automations";
|
|
675
|
+
|
|
676
|
+
return (
|
|
677
|
+
<AppStateContext.Provider value={state}>
|
|
678
|
+
<AppDispatchContext.Provider value={dispatch}>
|
|
679
|
+
<div className="flex flex-col h-screen">
|
|
680
|
+
<div className="flex flex-1 min-h-0">
|
|
681
|
+
{/* Icon Rail (68px fixed) */}
|
|
682
|
+
<IconRail onToggleTheme={toggleTheme} onOpenSettings={() => setShowSettings(true)} theme={theme} />
|
|
683
|
+
|
|
684
|
+
{/* Sidebar (220px) — switches based on active view */}
|
|
685
|
+
{isAutomationsView ? <CronSidebar /> : <Sidebar />}
|
|
686
|
+
|
|
687
|
+
{/* Main content area (flex) */}
|
|
688
|
+
{isAutomationsView ? (
|
|
689
|
+
<CronDetail />
|
|
690
|
+
) : (
|
|
691
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
692
|
+
{hasSession ? (
|
|
693
|
+
<>
|
|
694
|
+
<div className="flex-1 flex min-h-0">
|
|
695
|
+
{isBackgroundTask && (
|
|
696
|
+
<JobList
|
|
697
|
+
jobs={state.jobs}
|
|
698
|
+
selectedJobId={state.selectedJobId}
|
|
699
|
+
onSelectJob={handleSelectJob}
|
|
700
|
+
/>
|
|
701
|
+
)}
|
|
702
|
+
|
|
703
|
+
<ChatWindow sendMessage={sendMessage} />
|
|
704
|
+
|
|
705
|
+
{/* Detail Panel (right side, conditional) */}
|
|
706
|
+
<ThreadPanel sendMessage={sendMessage} />
|
|
707
|
+
</div>
|
|
708
|
+
</>
|
|
709
|
+
) : (
|
|
710
|
+
<div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
|
|
711
|
+
<div className="text-center">
|
|
712
|
+
<svg
|
|
713
|
+
className="w-20 h-20 mx-auto mb-4"
|
|
714
|
+
fill="none"
|
|
715
|
+
viewBox="0 0 24 24"
|
|
716
|
+
stroke="currentColor"
|
|
717
|
+
strokeWidth={1}
|
|
718
|
+
style={{ color: "var(--text-muted)" }}
|
|
719
|
+
>
|
|
720
|
+
<path
|
|
721
|
+
strokeLinecap="round"
|
|
722
|
+
strokeLinejoin="round"
|
|
723
|
+
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
724
|
+
/>
|
|
725
|
+
</svg>
|
|
726
|
+
<p className="text-body font-bold" style={{ color: "var(--text-muted)" }}>
|
|
727
|
+
Select a channel to get started
|
|
728
|
+
</p>
|
|
729
|
+
<p className="text-caption mt-1" style={{ color: "var(--text-muted)" }}>
|
|
730
|
+
Choose a channel from the sidebar
|
|
731
|
+
</p>
|
|
732
|
+
</div>
|
|
733
|
+
</div>
|
|
734
|
+
)}
|
|
735
|
+
</div>
|
|
736
|
+
)}
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
{/* Global debug log panel — collapsible at bottom */}
|
|
740
|
+
<DebugLogPanel />
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
{/* Settings modal */}
|
|
744
|
+
{showSettings && (
|
|
745
|
+
<div
|
|
746
|
+
className="fixed inset-0 flex items-center justify-center z-50"
|
|
747
|
+
style={{ background: "rgba(0,0,0,0.5)" }}
|
|
748
|
+
onClick={() => setShowSettings(false)}
|
|
749
|
+
>
|
|
750
|
+
<div
|
|
751
|
+
className="rounded-lg p-6 w-[420px] max-w-[90vw]"
|
|
752
|
+
style={{ background: "var(--bg-surface)", boxShadow: "var(--shadow-lg)" }}
|
|
753
|
+
onClick={(e) => e.stopPropagation()}
|
|
754
|
+
>
|
|
755
|
+
<div className="flex items-center justify-between mb-5">
|
|
756
|
+
<h2 className="text-h1 font-bold" style={{ color: "var(--text-primary)" }}>
|
|
757
|
+
Settings
|
|
758
|
+
</h2>
|
|
759
|
+
<button
|
|
760
|
+
onClick={() => setShowSettings(false)}
|
|
761
|
+
className="p-1 hover:bg-[--bg-hover] rounded"
|
|
762
|
+
style={{ color: "var(--text-muted)" }}
|
|
763
|
+
>
|
|
764
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
765
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
766
|
+
</svg>
|
|
767
|
+
</button>
|
|
768
|
+
</div>
|
|
769
|
+
|
|
770
|
+
<div className="space-y-5">
|
|
771
|
+
{/* Default Model */}
|
|
772
|
+
<div>
|
|
773
|
+
<label
|
|
774
|
+
className="block text-caption font-bold mb-1.5"
|
|
775
|
+
style={{ color: "var(--text-secondary)" }}
|
|
776
|
+
>
|
|
777
|
+
Default Model
|
|
778
|
+
</label>
|
|
779
|
+
<ModelSelect
|
|
780
|
+
value={state.defaultModel ?? ""}
|
|
781
|
+
onChange={handleDefaultModelChange}
|
|
782
|
+
models={state.models}
|
|
783
|
+
placeholder="Not set (use agent default)"
|
|
784
|
+
/>
|
|
785
|
+
<p className="text-tiny mt-1.5" style={{ color: "var(--text-muted)" }}>
|
|
786
|
+
Default model for new conversations. You can override per session using{" "}
|
|
787
|
+
<code>/model</code> or per automation in its settings.
|
|
788
|
+
</p>
|
|
789
|
+
</div>
|
|
790
|
+
|
|
791
|
+
{/* Connection info */}
|
|
792
|
+
<div>
|
|
793
|
+
<label
|
|
794
|
+
className="block text-caption font-bold mb-1.5"
|
|
795
|
+
style={{ color: "var(--text-secondary)" }}
|
|
796
|
+
>
|
|
797
|
+
Current Session Model
|
|
798
|
+
</label>
|
|
799
|
+
<span
|
|
800
|
+
className="text-body font-mono"
|
|
801
|
+
style={{ color: (state.sessionModel || state.defaultModel) ? "var(--text-primary)" : "var(--text-muted)" }}
|
|
802
|
+
>
|
|
803
|
+
{state.sessionModel ?? state.defaultModel ?? "Not connected"}
|
|
804
|
+
</span>
|
|
805
|
+
</div>
|
|
806
|
+
</div>
|
|
807
|
+
|
|
808
|
+
<div
|
|
809
|
+
className="mt-6 pt-4 flex justify-end"
|
|
810
|
+
style={{ borderTop: "1px solid var(--border)" }}
|
|
811
|
+
>
|
|
812
|
+
<button
|
|
813
|
+
onClick={() => setShowSettings(false)}
|
|
814
|
+
className="px-4 py-1.5 text-caption font-bold text-white rounded-sm"
|
|
815
|
+
style={{ background: "var(--bg-active)" }}
|
|
816
|
+
>
|
|
817
|
+
Done
|
|
818
|
+
</button>
|
|
819
|
+
</div>
|
|
820
|
+
</div>
|
|
821
|
+
</div>
|
|
822
|
+
)}
|
|
823
|
+
|
|
824
|
+
</AppDispatchContext.Provider>
|
|
825
|
+
</AppStateContext.Provider>
|
|
826
|
+
);
|
|
827
|
+
}
|