@aion0/forge 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/CLAUDE.md +4 -0
- package/README.md +264 -0
- package/app/api/auth/[...nextauth]/route.ts +3 -0
- package/app/api/claude/[id]/route.ts +31 -0
- package/app/api/claude/[id]/stream/route.ts +63 -0
- package/app/api/claude/route.ts +28 -0
- package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/app/api/claude-sessions/sync/route.ts +17 -0
- package/app/api/flows/route.ts +6 -0
- package/app/api/flows/run/route.ts +19 -0
- package/app/api/notify/test/route.ts +33 -0
- package/app/api/projects/route.ts +7 -0
- package/app/api/sessions/[id]/chat/route.ts +64 -0
- package/app/api/sessions/[id]/messages/route.ts +9 -0
- package/app/api/sessions/[id]/route.ts +17 -0
- package/app/api/sessions/route.ts +20 -0
- package/app/api/settings/route.ts +15 -0
- package/app/api/status/route.ts +12 -0
- package/app/api/tasks/[id]/route.ts +36 -0
- package/app/api/tasks/[id]/stream/route.ts +77 -0
- package/app/api/tasks/link/route.ts +37 -0
- package/app/api/tasks/route.ts +43 -0
- package/app/api/tasks/session/route.ts +14 -0
- package/app/api/templates/route.ts +6 -0
- package/app/api/tunnel/route.ts +20 -0
- package/app/api/watchers/route.ts +33 -0
- package/app/globals.css +26 -0
- package/app/icon.svg +26 -0
- package/app/layout.tsx +17 -0
- package/app/login/page.tsx +61 -0
- package/app/page.tsx +9 -0
- package/cli/mw.ts +377 -0
- package/components/ChatPanel.tsx +191 -0
- package/components/ClaudeTerminal.tsx +267 -0
- package/components/Dashboard.tsx +270 -0
- package/components/MarkdownContent.tsx +57 -0
- package/components/NewSessionModal.tsx +93 -0
- package/components/NewTaskModal.tsx +456 -0
- package/components/ProjectList.tsx +108 -0
- package/components/SessionList.tsx +74 -0
- package/components/SessionView.tsx +655 -0
- package/components/SettingsModal.tsx +366 -0
- package/components/StatusBar.tsx +99 -0
- package/components/TaskBoard.tsx +110 -0
- package/components/TaskDetail.tsx +351 -0
- package/components/TunnelToggle.tsx +163 -0
- package/components/WebTerminal.tsx +1069 -0
- package/docs/LOCAL-DEPLOY.md +144 -0
- package/docs/roadmap-multi-agent-workflow.md +330 -0
- package/instrumentation.ts +14 -0
- package/lib/auth.ts +47 -0
- package/lib/claude-process.ts +352 -0
- package/lib/claude-sessions.ts +267 -0
- package/lib/cloudflared.ts +218 -0
- package/lib/flows.ts +86 -0
- package/lib/init.ts +82 -0
- package/lib/notify.ts +75 -0
- package/lib/password.ts +77 -0
- package/lib/projects.ts +86 -0
- package/lib/session-manager.ts +156 -0
- package/lib/session-watcher.ts +345 -0
- package/lib/settings.ts +44 -0
- package/lib/task-manager.ts +668 -0
- package/lib/telegram-bot.ts +912 -0
- package/lib/terminal-server.ts +70 -0
- package/lib/terminal-standalone.ts +363 -0
- package/middleware.ts +33 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +16 -0
- package/package.json +66 -0
- package/postcss.config.mjs +7 -0
- package/src/config/index.ts +119 -0
- package/src/core/db/database.ts +133 -0
- package/src/core/memory/strategy.ts +32 -0
- package/src/core/providers/chat.ts +65 -0
- package/src/core/providers/registry.ts +60 -0
- package/src/core/session/manager.ts +190 -0
- package/src/types/index.ts +128 -0
- package/tsconfig.json +41 -0
package/cli/mw.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* forge — Forge CLI
|
|
4
|
+
*
|
|
5
|
+
* Local CLI that talks to the same backend as Telegram.
|
|
6
|
+
* Usage:
|
|
7
|
+
* mw task <project> "prompt" — submit a task
|
|
8
|
+
* mw run <flow-name> — run a YAML workflow
|
|
9
|
+
* mw tasks [status] — list tasks
|
|
10
|
+
* mw log <id> — show task execution log
|
|
11
|
+
* mw status <id> — task details
|
|
12
|
+
* mw cancel <id> — cancel a task
|
|
13
|
+
* mw retry <id> — retry a failed task
|
|
14
|
+
* mw flows — list available workflows
|
|
15
|
+
* mw projects — list projects
|
|
16
|
+
* mw watch <id> — live stream task output
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const BASE = process.env.MW_URL || 'http://localhost:3000';
|
|
20
|
+
|
|
21
|
+
const [, , cmd, ...args] = process.argv;
|
|
22
|
+
|
|
23
|
+
async function api(path: string, opts?: RequestInit) {
|
|
24
|
+
const res = await fetch(`${BASE}${path}`, opts);
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
console.error(`Error ${res.status}: ${text}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
return res.json();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
switch (cmd) {
|
|
35
|
+
case 'task':
|
|
36
|
+
case 't': {
|
|
37
|
+
// Parse --new flag to force a fresh session
|
|
38
|
+
const newSession = args.includes('--new');
|
|
39
|
+
const filtered = args.filter(a => a !== '--new');
|
|
40
|
+
const project = filtered[0];
|
|
41
|
+
const prompt = filtered.slice(1).join(' ');
|
|
42
|
+
if (!project || !prompt) {
|
|
43
|
+
console.log('Usage: mw task <project> <prompt> [--new]');
|
|
44
|
+
console.log(' --new Start a fresh session (ignore previous context)');
|
|
45
|
+
console.log('Example: mw task my-app "Fix the login bug"');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const task = await api('/api/tasks', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ projectName: project, prompt, newSession }),
|
|
52
|
+
});
|
|
53
|
+
const session = task.conversationId ? '(continuing session)' : '(new session)';
|
|
54
|
+
console.log(`✓ Task ${task.id} created ${session}`);
|
|
55
|
+
console.log(` Project: ${task.projectName}`);
|
|
56
|
+
console.log(` ${prompt}`);
|
|
57
|
+
console.log(`\n Watch: mw watch ${task.id}`);
|
|
58
|
+
console.log(` Status: mw status ${task.id}`);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
case 'run':
|
|
63
|
+
case 'r': {
|
|
64
|
+
const flowName = args[0];
|
|
65
|
+
if (!flowName) {
|
|
66
|
+
console.log('Usage: mw run <flow-name>');
|
|
67
|
+
console.log('List flows: mw flows');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const result = await api('/api/flows/run', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({ name: flowName }),
|
|
74
|
+
});
|
|
75
|
+
console.log(`✓ Flow "${flowName}" started`);
|
|
76
|
+
for (const t of result.tasks) {
|
|
77
|
+
console.log(` Task ${t.id}: ${t.projectName} — ${t.prompt.slice(0, 60)}`);
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'tasks':
|
|
83
|
+
case 'ls': {
|
|
84
|
+
const status = args[0] || '';
|
|
85
|
+
const query = status ? `?status=${status}` : '';
|
|
86
|
+
const tasks = await api(`/api/tasks${query}`);
|
|
87
|
+
if (tasks.length === 0) {
|
|
88
|
+
console.log('No tasks.');
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const icons: Record<string, string> = {
|
|
92
|
+
queued: '⏳', running: '🔄', done: '✅', failed: '❌', cancelled: '⚪',
|
|
93
|
+
};
|
|
94
|
+
for (const t of tasks) {
|
|
95
|
+
const icon = icons[t.status] || '?';
|
|
96
|
+
const cost = t.costUSD != null ? ` $${t.costUSD.toFixed(3)}` : '';
|
|
97
|
+
console.log(`${icon} ${t.id} ${t.status.padEnd(9)} ${t.projectName.padEnd(20)} ${t.prompt.slice(0, 50)}${cost}`);
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case 'status':
|
|
103
|
+
case 's': {
|
|
104
|
+
const id = args[0];
|
|
105
|
+
if (!id) { console.log('Usage: mw status <id>'); process.exit(1); }
|
|
106
|
+
const task = await api(`/api/tasks/${id}`);
|
|
107
|
+
console.log(`Task: ${task.id}`);
|
|
108
|
+
console.log(`Project: ${task.projectName} (${task.projectPath})`);
|
|
109
|
+
console.log(`Status: ${task.status}`);
|
|
110
|
+
console.log(`Prompt: ${task.prompt}`);
|
|
111
|
+
if (task.startedAt) console.log(`Started: ${task.startedAt}`);
|
|
112
|
+
if (task.completedAt) console.log(`Completed: ${task.completedAt}`);
|
|
113
|
+
if (task.costUSD != null) console.log(`Cost: $${task.costUSD.toFixed(4)}`);
|
|
114
|
+
if (task.error) console.log(`Error: ${task.error}`);
|
|
115
|
+
if (task.resultSummary) {
|
|
116
|
+
console.log(`\nResult:\n${task.resultSummary}`);
|
|
117
|
+
}
|
|
118
|
+
if (task.gitDiff) {
|
|
119
|
+
console.log(`\nGit Diff:\n${task.gitDiff.slice(0, 2000)}`);
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'log':
|
|
125
|
+
case 'l': {
|
|
126
|
+
const id = args[0];
|
|
127
|
+
if (!id) { console.log('Usage: mw log <id>'); process.exit(1); }
|
|
128
|
+
const task = await api(`/api/tasks/${id}`);
|
|
129
|
+
if (task.log.length === 0) {
|
|
130
|
+
console.log('No log entries.');
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
for (const entry of task.log) {
|
|
134
|
+
const prefix = entry.subtype === 'tool_use' ? `🔧 [${entry.tool}]`
|
|
135
|
+
: entry.subtype === 'error' ? '❗'
|
|
136
|
+
: entry.type === 'result' ? '✅'
|
|
137
|
+
: entry.subtype === 'tool_result' ? ' ↳'
|
|
138
|
+
: ' ';
|
|
139
|
+
console.log(`${prefix} ${entry.content.slice(0, 300)}`);
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case 'watch':
|
|
145
|
+
case 'w': {
|
|
146
|
+
const id = args[0];
|
|
147
|
+
if (!id) { console.log('Usage: mw watch <id>'); process.exit(1); }
|
|
148
|
+
console.log(`Watching task ${id}... (Ctrl+C to stop)\n`);
|
|
149
|
+
|
|
150
|
+
const res = await fetch(`${BASE}/api/tasks/${id}/stream`);
|
|
151
|
+
if (!res.ok || !res.body) {
|
|
152
|
+
console.error('Failed to connect to stream');
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const reader = res.body.getReader();
|
|
157
|
+
const decoder = new TextDecoder();
|
|
158
|
+
let buffer = '';
|
|
159
|
+
|
|
160
|
+
while (true) {
|
|
161
|
+
const { done, value } = await reader.read();
|
|
162
|
+
if (done) break;
|
|
163
|
+
|
|
164
|
+
buffer += decoder.decode(value, { stream: true });
|
|
165
|
+
const lines = buffer.split('\n');
|
|
166
|
+
buffer = lines.pop() || '';
|
|
167
|
+
|
|
168
|
+
for (const line of lines) {
|
|
169
|
+
if (!line.startsWith('data: ')) continue;
|
|
170
|
+
try {
|
|
171
|
+
const data = JSON.parse(line.slice(6));
|
|
172
|
+
if (data.type === 'log') {
|
|
173
|
+
const e = data.entry;
|
|
174
|
+
if (e.subtype === 'tool_use') {
|
|
175
|
+
console.log(`🔧 [${e.tool}] ${e.content.slice(0, 200)}`);
|
|
176
|
+
} else if (e.subtype === 'text') {
|
|
177
|
+
process.stdout.write(e.content);
|
|
178
|
+
} else if (e.type === 'result') {
|
|
179
|
+
console.log(`\n✅ ${e.content}`);
|
|
180
|
+
} else if (e.subtype === 'error') {
|
|
181
|
+
console.log(`❗ ${e.content}`);
|
|
182
|
+
}
|
|
183
|
+
} else if (data.type === 'status') {
|
|
184
|
+
if (data.status === 'done') {
|
|
185
|
+
console.log('\n✅ Task completed');
|
|
186
|
+
} else if (data.status === 'failed') {
|
|
187
|
+
console.log('\n❌ Task failed');
|
|
188
|
+
} else if (data.status === 'running') {
|
|
189
|
+
console.log('🚀 Started...\n');
|
|
190
|
+
}
|
|
191
|
+
} else if (data.type === 'complete') {
|
|
192
|
+
if (data.task?.costUSD != null) {
|
|
193
|
+
console.log(`Cost: $${data.task.costUSD.toFixed(4)}`);
|
|
194
|
+
}
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
} catch {}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case 'cancel': {
|
|
204
|
+
const id = args[0];
|
|
205
|
+
if (!id) { console.log('Usage: mw cancel <id>'); process.exit(1); }
|
|
206
|
+
await api(`/api/tasks/${id}`, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'Content-Type': 'application/json' },
|
|
209
|
+
body: JSON.stringify({ action: 'cancel' }),
|
|
210
|
+
});
|
|
211
|
+
console.log(`✓ Task ${id} cancelled`);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case 'retry': {
|
|
216
|
+
const id = args[0];
|
|
217
|
+
if (!id) { console.log('Usage: mw retry <id>'); process.exit(1); }
|
|
218
|
+
const task = await api(`/api/tasks/${id}`, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: { 'Content-Type': 'application/json' },
|
|
221
|
+
body: JSON.stringify({ action: 'retry' }),
|
|
222
|
+
});
|
|
223
|
+
console.log(`✓ Retrying as task ${task.id}`);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
case 'flows':
|
|
228
|
+
case 'f': {
|
|
229
|
+
const flows = await api('/api/flows');
|
|
230
|
+
if (flows.length === 0) {
|
|
231
|
+
console.log('No flows defined.');
|
|
232
|
+
console.log(`Create flows in ~/.my-workflow/flows/*.yaml`);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
for (const f of flows) {
|
|
236
|
+
const schedule = f.schedule ? ` (${f.schedule})` : '';
|
|
237
|
+
console.log(` ${f.name}${schedule} — ${f.steps.length} steps`);
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case 'session': {
|
|
243
|
+
const subCmd = args[0];
|
|
244
|
+
|
|
245
|
+
// mw session link <project> <session-id> — register a local CLI session
|
|
246
|
+
if (subCmd === 'link') {
|
|
247
|
+
const project = args[1];
|
|
248
|
+
const sessionId = args[2];
|
|
249
|
+
if (!project || !sessionId) {
|
|
250
|
+
console.log('Usage: mw session link <project> <session-id>');
|
|
251
|
+
console.log('\nFind your session ID:');
|
|
252
|
+
console.log(' In Claude Code CLI, look for the session ID in the output');
|
|
253
|
+
console.log(' Or check: ls ~/.claude/projects/');
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
const result = await api('/api/tasks/link', {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { 'Content-Type': 'application/json' },
|
|
259
|
+
body: JSON.stringify({ projectName: project, conversationId: sessionId }),
|
|
260
|
+
});
|
|
261
|
+
console.log(`✓ Linked session ${sessionId} to project ${result.projectName}`);
|
|
262
|
+
console.log(` Future "mw task ${result.projectName} ..." will continue this session`);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// mw session (no args) — list all project sessions
|
|
267
|
+
if (!subCmd) {
|
|
268
|
+
const tasks = await api('/api/tasks?status=done');
|
|
269
|
+
const sessions = new Map<string, { id: string; project: string; path: string; lastUsed: string }>();
|
|
270
|
+
for (const t of tasks) {
|
|
271
|
+
if (t.conversationId && !sessions.has(t.projectName)) {
|
|
272
|
+
sessions.set(t.projectName, {
|
|
273
|
+
id: t.conversationId,
|
|
274
|
+
project: t.projectName,
|
|
275
|
+
path: t.projectPath,
|
|
276
|
+
lastUsed: t.completedAt,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (sessions.size === 0) {
|
|
281
|
+
console.log('No active sessions. Submit a task first, or link a local session:');
|
|
282
|
+
console.log(' mw session link <project> <session-id>');
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
console.log('Project sessions:\n');
|
|
286
|
+
for (const [name, s] of sessions) {
|
|
287
|
+
console.log(` ${name.padEnd(25)} ${s.id}`);
|
|
288
|
+
console.log(` ${''.padEnd(25)} cd ${s.path} && claude --resume ${s.id}`);
|
|
289
|
+
console.log();
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// mw session <project> — get session for specific project
|
|
295
|
+
const project = subCmd;
|
|
296
|
+
const tasks = await api('/api/tasks?status=done');
|
|
297
|
+
const match = tasks.find((t: any) => t.projectName === project && t.conversationId);
|
|
298
|
+
if (!match) {
|
|
299
|
+
console.log(`No session found for project: ${project}`);
|
|
300
|
+
console.log(`\nLink a local session: mw session link ${project} <session-id>`);
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
console.log(`Project: ${match.projectName}`);
|
|
304
|
+
console.log(`Session: ${match.conversationId}`);
|
|
305
|
+
console.log(`Path: ${match.projectPath}`);
|
|
306
|
+
console.log(`\nResume in CLI:`);
|
|
307
|
+
console.log(` cd ${match.projectPath} && claude --resume ${match.conversationId}`);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case 'password':
|
|
312
|
+
case 'pw': {
|
|
313
|
+
const { readFileSync } = await import('node:fs');
|
|
314
|
+
const { homedir } = await import('node:os');
|
|
315
|
+
const { join } = await import('node:path');
|
|
316
|
+
const pwFile = join(homedir(), '.my-workflow', 'password.json');
|
|
317
|
+
try {
|
|
318
|
+
const data = JSON.parse(readFileSync(pwFile, 'utf-8'));
|
|
319
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
320
|
+
if (data.date === today) {
|
|
321
|
+
console.log(`Login password: ${data.password}`);
|
|
322
|
+
console.log(`Valid for: ${data.date}`);
|
|
323
|
+
} else {
|
|
324
|
+
console.log(`Password expired (was for ${data.date}). Restart server to generate new one.`);
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
console.log('No password file found. Password is set via MW_PASSWORD env var.');
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
case 'projects':
|
|
333
|
+
case 'p': {
|
|
334
|
+
const projects = await api('/api/projects');
|
|
335
|
+
for (const p of projects) {
|
|
336
|
+
const lang = p.language ? `[${p.language}]` : '';
|
|
337
|
+
console.log(` ${p.name.padEnd(25)} ${lang.padEnd(6)} ${p.path}`);
|
|
338
|
+
}
|
|
339
|
+
console.log(`\n${projects.length} projects`);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
default:
|
|
344
|
+
console.log(`forge — Forge CLI (@aion0/forge)
|
|
345
|
+
|
|
346
|
+
Usage:
|
|
347
|
+
forge task <project> <prompt> Submit a task (auto-continues project session)
|
|
348
|
+
forge task <project> <prompt> --new Force a fresh session
|
|
349
|
+
forge run <flow-name> Run a YAML workflow
|
|
350
|
+
forge tasks [status] List tasks (running|queued|done|failed)
|
|
351
|
+
forge watch <id> Live stream task output
|
|
352
|
+
forge log <id> Show execution log
|
|
353
|
+
forge status <id> Task details + result
|
|
354
|
+
forge session [project] Show session IDs → local claude --resume
|
|
355
|
+
forge session link <project> <id> Link a local CLI session to the web system
|
|
356
|
+
forge cancel <id> Cancel a task
|
|
357
|
+
forge retry <id> Retry a failed task
|
|
358
|
+
forge flows List workflows
|
|
359
|
+
forge projects List projects
|
|
360
|
+
forge password Show login password
|
|
361
|
+
|
|
362
|
+
Shortcuts: t=task, r=run, ls=tasks, w=watch, l=log, s=status, f=flows, p=projects, pw=password
|
|
363
|
+
|
|
364
|
+
Examples:
|
|
365
|
+
forge task accord "Fix the authentication bug in login.ts"
|
|
366
|
+
forge watch abc123
|
|
367
|
+
forge run daily-review
|
|
368
|
+
forge tasks running
|
|
369
|
+
forge session accord Show session ID, then:
|
|
370
|
+
cd ~/IdeaProjects/accord && claude --resume <session-id>`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
main().catch(err => {
|
|
375
|
+
console.error(err.message);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
|
+
import type { Session, Message } from '@/src/types';
|
|
5
|
+
|
|
6
|
+
const providerLabels: Record<string, string> = {
|
|
7
|
+
anthropic: 'Claude',
|
|
8
|
+
google: 'Gemini',
|
|
9
|
+
openai: 'OpenAI',
|
|
10
|
+
grok: 'Grok',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default function ChatPanel({
|
|
14
|
+
session,
|
|
15
|
+
onUpdate,
|
|
16
|
+
}: {
|
|
17
|
+
session: Session;
|
|
18
|
+
onUpdate: () => void;
|
|
19
|
+
}) {
|
|
20
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
21
|
+
const [input, setInput] = useState('');
|
|
22
|
+
const [streaming, setStreaming] = useState(false);
|
|
23
|
+
const [streamContent, setStreamContent] = useState('');
|
|
24
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
26
|
+
|
|
27
|
+
// Load messages when session changes
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
fetch(`/api/sessions/${session.id}/messages`)
|
|
30
|
+
.then(r => r.json())
|
|
31
|
+
.then(setMessages);
|
|
32
|
+
}, [session.id]);
|
|
33
|
+
|
|
34
|
+
// Auto-scroll
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
37
|
+
}, [messages, streamContent]);
|
|
38
|
+
|
|
39
|
+
const sendMessage = async () => {
|
|
40
|
+
const text = input.trim();
|
|
41
|
+
if (!text || streaming) return;
|
|
42
|
+
|
|
43
|
+
setInput('');
|
|
44
|
+
setStreaming(true);
|
|
45
|
+
setStreamContent('');
|
|
46
|
+
|
|
47
|
+
// Optimistic: add user message
|
|
48
|
+
const userMsg: Message = {
|
|
49
|
+
id: Date.now(),
|
|
50
|
+
sessionId: session.id,
|
|
51
|
+
role: 'user',
|
|
52
|
+
content: text,
|
|
53
|
+
provider: session.provider,
|
|
54
|
+
model: session.model,
|
|
55
|
+
createdAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
setMessages(prev => [...prev, userMsg]);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(`/api/sessions/${session.id}/chat`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ message: text }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const reader = res.body?.getReader();
|
|
67
|
+
const decoder = new TextDecoder();
|
|
68
|
+
let fullContent = '';
|
|
69
|
+
|
|
70
|
+
if (reader) {
|
|
71
|
+
while (true) {
|
|
72
|
+
const { done, value } = await reader.read();
|
|
73
|
+
if (done) break;
|
|
74
|
+
|
|
75
|
+
const chunk = decoder.decode(value);
|
|
76
|
+
const lines = chunk.split('\n');
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (!line.startsWith('data: ')) continue;
|
|
80
|
+
try {
|
|
81
|
+
const data = JSON.parse(line.slice(6));
|
|
82
|
+
if (data.token) {
|
|
83
|
+
fullContent += data.token;
|
|
84
|
+
setStreamContent(fullContent);
|
|
85
|
+
}
|
|
86
|
+
if (data.done) {
|
|
87
|
+
// Add final assistant message
|
|
88
|
+
const assistantMsg: Message = {
|
|
89
|
+
id: Date.now() + 1,
|
|
90
|
+
sessionId: session.id,
|
|
91
|
+
role: 'assistant',
|
|
92
|
+
content: fullContent,
|
|
93
|
+
provider: session.provider,
|
|
94
|
+
model: session.model,
|
|
95
|
+
createdAt: new Date().toISOString(),
|
|
96
|
+
};
|
|
97
|
+
setMessages(prev => [...prev, assistantMsg]);
|
|
98
|
+
setStreamContent('');
|
|
99
|
+
}
|
|
100
|
+
if (data.error) {
|
|
101
|
+
setStreamContent(`Error: ${data.error}`);
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (err: any) {
|
|
108
|
+
setStreamContent(`Error: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setStreaming(false);
|
|
112
|
+
onUpdate();
|
|
113
|
+
inputRef.current?.focus();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
117
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
sendMessage();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
125
|
+
{/* Session header */}
|
|
126
|
+
<div className="h-10 border-b border-[var(--border)] flex items-center px-4 gap-3 shrink-0">
|
|
127
|
+
<span className="text-sm font-semibold">{session.name}</span>
|
|
128
|
+
<span className="text-[10px] px-1.5 py-0.5 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
|
|
129
|
+
{providerLabels[session.provider] || session.provider}
|
|
130
|
+
</span>
|
|
131
|
+
<span className="text-[10px] px-1.5 py-0.5 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
|
|
132
|
+
{session.memory.strategy}
|
|
133
|
+
</span>
|
|
134
|
+
<span className="text-[10px] text-[var(--text-secondary)]">
|
|
135
|
+
{session.messageCount} messages
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Messages */}
|
|
140
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
141
|
+
{messages.map(msg => (
|
|
142
|
+
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
143
|
+
<div
|
|
144
|
+
className={`max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap ${
|
|
145
|
+
msg.role === 'user'
|
|
146
|
+
? 'bg-[var(--accent)] text-white'
|
|
147
|
+
: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
|
|
148
|
+
}`}
|
|
149
|
+
>
|
|
150
|
+
{msg.content}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
))}
|
|
154
|
+
|
|
155
|
+
{streamContent && (
|
|
156
|
+
<div className="flex justify-start">
|
|
157
|
+
<div className="max-w-[80%] px-3 py-2 rounded-lg text-sm bg-[var(--bg-tertiary)] text-[var(--text-primary)] whitespace-pre-wrap">
|
|
158
|
+
{streamContent}
|
|
159
|
+
<span className="animate-pulse">▌</span>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
<div ref={messagesEndRef} />
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Input */}
|
|
168
|
+
<div className="border-t border-[var(--border)] p-3 shrink-0">
|
|
169
|
+
<div className="flex gap-2">
|
|
170
|
+
<textarea
|
|
171
|
+
ref={inputRef}
|
|
172
|
+
value={input}
|
|
173
|
+
onChange={e => setInput(e.target.value)}
|
|
174
|
+
onKeyDown={handleKeyDown}
|
|
175
|
+
placeholder={streaming ? 'Waiting for response...' : 'Type a message... (Enter to send, Shift+Enter for newline)'}
|
|
176
|
+
disabled={streaming}
|
|
177
|
+
rows={1}
|
|
178
|
+
className="flex-1 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
179
|
+
/>
|
|
180
|
+
<button
|
|
181
|
+
onClick={sendMessage}
|
|
182
|
+
disabled={streaming || !input.trim()}
|
|
183
|
+
className="px-4 py-2 bg-[var(--accent)] text-white text-sm rounded hover:opacity-90 disabled:opacity-50"
|
|
184
|
+
>
|
|
185
|
+
Send
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|