@aion0/forge 0.6.1 → 0.8.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/.forge/mcp.json +8 -0
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
- package/CLAUDE.md +2 -2
- package/RELEASE_NOTES.md +101 -5
- package/app/api/auth/check/route.ts +18 -0
- package/app/api/browser-bridge/route.ts +70 -0
- package/app/api/chat/sessions/[id]/events/route.ts +17 -0
- package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
- package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
- package/app/api/chat/sessions/[id]/route.ts +23 -0
- package/app/api/chat/sessions/route.ts +12 -0
- package/app/api/chat/temper-ping/route.ts +18 -0
- package/app/api/chat-proxy/[...path]/route.ts +83 -0
- package/app/api/connector-tool/route.ts +38 -0
- package/app/api/connectors/[id]/settings/route.ts +112 -0
- package/app/api/connectors/route.ts +108 -0
- package/app/api/health/tools/route.ts +14 -0
- package/app/api/issue-scanner-gitlab/route.ts +95 -0
- package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
- package/app/api/jobs/[id]/route.ts +31 -0
- package/app/api/jobs/[id]/run/route.ts +44 -0
- package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
- package/app/api/jobs/[id]/runs/route.ts +15 -0
- package/app/api/jobs/preview/route.ts +193 -0
- package/app/api/jobs/route.ts +36 -0
- package/app/api/notify/test/route.ts +39 -7
- package/app/api/pipelines/[id]/route.ts +10 -1
- package/app/api/pipelines/route.ts +16 -2
- package/app/api/plugins/route.ts +40 -8
- package/app/api/project-sessions/route.ts +50 -10
- package/app/api/settings/route.ts +13 -0
- package/app/chat/page.tsx +531 -0
- package/bin/forge-server.mjs +3 -1
- package/cli/chat.ts +283 -0
- package/cli/jobs.ts +176 -0
- package/cli/mw.ts +28 -1
- package/cli/worktree.ts +245 -0
- package/components/ConnectorsPanel.tsx +275 -0
- package/components/Dashboard.tsx +90 -37
- package/components/JobsView.tsx +361 -0
- package/components/LogViewer.tsx +12 -2
- package/components/PipelineView.tsx +275 -56
- package/components/PluginsPanel.tsx +3 -1
- package/components/SettingsModal.tsx +229 -40
- package/components/SkillsPanel.tsx +12 -4
- package/components/TerminalLauncher.tsx +3 -1
- package/components/WebTerminal.tsx +32 -9
- package/components/WorkspaceView.tsx +18 -10
- package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
- package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
- package/docs/Implementation-Plan-Browser-Agent.md +487 -0
- package/docs/Jobs-Design.md +240 -0
- package/docs/LOCAL-DEPLOY.md +3 -3
- package/docs/RFC-Browser-Connectors.md +509 -0
- package/lib/agents/index.ts +44 -6
- package/lib/agents/types.ts +1 -1
- package/lib/browser-bridge-standalone.ts +317 -0
- package/lib/builtin-plugins/github-api.yaml +93 -0
- package/lib/builtin-plugins/gitlab.yaml +860 -0
- package/lib/builtin-plugins/mantis.probe.js +176 -0
- package/lib/builtin-plugins/mantis.yaml +964 -0
- package/lib/builtin-plugins/pmdb.yaml +178 -0
- package/lib/builtin-plugins/teams.yaml +913 -0
- package/lib/chat/__test__/smoke.ts +30 -0
- package/lib/chat/agent-loop.ts +523 -0
- package/lib/chat/bridge-client.ts +59 -0
- package/lib/chat/llm/anthropic.ts +99 -0
- package/lib/chat/llm/index.ts +20 -0
- package/lib/chat/llm/openai.ts +215 -0
- package/lib/chat/llm/types.ts +42 -0
- package/lib/chat/local-memory.ts +300 -0
- package/lib/chat/memory-store.ts +87 -0
- package/lib/chat/memory-tools.ts +157 -0
- package/lib/chat/protocols/http.ts +118 -0
- package/lib/chat/protocols/shell.ts +101 -0
- package/lib/chat/proxy.ts +51 -0
- package/lib/chat/session-store.ts +272 -0
- package/lib/chat/telegram-bridge.ts +276 -0
- package/lib/chat/temper.ts +281 -0
- package/lib/chat/tool-dispatcher.ts +190 -0
- package/lib/chat/types.ts +50 -0
- package/lib/chat-standalone.ts +286 -0
- package/lib/crypto.ts +1 -1
- package/lib/health.ts +131 -0
- package/lib/help-docs/00-overview.md +2 -1
- package/lib/help-docs/01-settings.md +46 -25
- package/lib/help-docs/07-projects.md +1 -1
- package/lib/help-docs/10-troubleshooting.md +10 -2
- package/lib/help-docs/16-gitlab-autofix.md +114 -0
- package/lib/help-docs/17-connectors.md +322 -0
- package/lib/help-docs/18-chrome-mcp.md +134 -0
- package/lib/help-docs/19-jobs.md +140 -0
- package/lib/help-docs/20-mantis-bug-fix.md +115 -0
- package/lib/help-docs/CLAUDE.md +10 -0
- package/lib/init.ts +137 -50
- package/lib/iso-time.ts +30 -0
- package/lib/issue-scanner-gitlab.ts +281 -0
- package/lib/jobs/dispatcher.ts +217 -0
- package/lib/jobs/scheduler.ts +334 -0
- package/lib/jobs/store.ts +319 -0
- package/lib/jobs/types.ts +117 -0
- package/lib/pipeline-scheduler.ts +1 -6
- package/lib/pipeline.ts +790 -10
- package/lib/plugins/registry.ts +133 -8
- package/lib/plugins/templates.ts +83 -0
- package/lib/plugins/types.ts +140 -1
- package/lib/session-watcher.ts +36 -10
- package/lib/settings.ts +65 -33
- package/lib/skills.ts +3 -1
- package/lib/task-manager.ts +50 -22
- package/lib/telegram-bot.ts +71 -0
- package/lib/terminal-standalone.ts +58 -36
- package/lib/workspace/orchestrator.ts +1 -0
- package/middleware.ts +10 -0
- package/package.json +3 -2
- package/scripts/bench/README.md +1 -1
- package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
- package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
- package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
- package/src/core/db/database.ts +21 -12
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simplified web chat for Forge — no extension required.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the chat-standalone server through /api/chat-proxy. Full
|
|
5
|
+
* featureset (per-tab connector hints, browser-side scripts, unread
|
|
6
|
+
* badges, fork UI) stays in the browser extension; this page is a
|
|
7
|
+
* fallback so users can chat from any device with a browser.
|
|
8
|
+
*
|
|
9
|
+
* Routes used:
|
|
10
|
+
* GET /api/chat-proxy/sessions
|
|
11
|
+
* GET /api/chat-proxy/sessions/main
|
|
12
|
+
* POST /api/chat-proxy/sessions (new temp session)
|
|
13
|
+
* GET /api/chat-proxy/sessions/:id (messages for a session)
|
|
14
|
+
* DELETE /api/chat-proxy/sessions/:id/messages (clear)
|
|
15
|
+
* POST /api/chat-proxy/sessions/:id/messages (send user turn)
|
|
16
|
+
* GET /api/chat-proxy/sessions/:id/events (SSE)
|
|
17
|
+
* DELETE /api/chat-proxy/sessions/:id (delete — refused for main)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use client';
|
|
21
|
+
|
|
22
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
23
|
+
import MarkdownContent from '@/components/MarkdownContent';
|
|
24
|
+
import type { ContentBlock, Message, Session } from '@/lib/chat/types';
|
|
25
|
+
|
|
26
|
+
const PROXY = '/api/chat-proxy';
|
|
27
|
+
|
|
28
|
+
// The global body font is monospace (see globals.css). The chat page
|
|
29
|
+
// is a reading surface — override to a UI-friendly system stack and
|
|
30
|
+
// let MarkdownContent's <code> elements switch back to mono inline.
|
|
31
|
+
const SANS_FONT =
|
|
32
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif';
|
|
33
|
+
|
|
34
|
+
interface MemoryStatus {
|
|
35
|
+
backend?: 'temper' | 'local';
|
|
36
|
+
pinnedCount?: number;
|
|
37
|
+
blocksCount?: number;
|
|
38
|
+
hitsCount?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ChatSession extends Session {
|
|
42
|
+
meta?: { kind?: 'main' | 'temp'; [k: string]: unknown };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function ChatPage() {
|
|
46
|
+
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
47
|
+
const [activeId, setActiveId] = useState<string>('');
|
|
48
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
49
|
+
const [input, setInput] = useState('');
|
|
50
|
+
const [streaming, setStreaming] = useState(false);
|
|
51
|
+
const [partial, setPartial] = useState('');
|
|
52
|
+
const [memory, setMemory] = useState<MemoryStatus | null>(null);
|
|
53
|
+
const [error, setError] = useState('');
|
|
54
|
+
|
|
55
|
+
const eventSrcRef = useRef<EventSource | null>(null);
|
|
56
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
57
|
+
const composerRef = useRef<HTMLTextAreaElement>(null);
|
|
58
|
+
|
|
59
|
+
// ─── Load sessions ────────────────────────────────────────
|
|
60
|
+
const refreshSessions = useCallback(async () => {
|
|
61
|
+
try {
|
|
62
|
+
const [listResp, mainResp] = await Promise.all([
|
|
63
|
+
fetch(`${PROXY}/sessions?limit=200`),
|
|
64
|
+
fetch(`${PROXY}/sessions/main`),
|
|
65
|
+
]);
|
|
66
|
+
const listJson = (await listResp.json()) as { sessions: ChatSession[] };
|
|
67
|
+
const list = listJson.sessions || [];
|
|
68
|
+
const mainJson = (await mainResp.json()) as { session: ChatSession };
|
|
69
|
+
const mainId = mainJson?.session?.id;
|
|
70
|
+
const ordered = list.slice().sort((a, b) => {
|
|
71
|
+
if (a.id === mainId) return -1;
|
|
72
|
+
if (b.id === mainId) return 1;
|
|
73
|
+
return (b.updated_at || 0) - (a.updated_at || 0);
|
|
74
|
+
});
|
|
75
|
+
setSessions(ordered);
|
|
76
|
+
if (!activeId && mainId) setActiveId(mainId);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
79
|
+
}
|
|
80
|
+
}, [activeId]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
refreshSessions();
|
|
84
|
+
}, [refreshSessions]);
|
|
85
|
+
|
|
86
|
+
// ─── Load messages on session change ──────────────────────
|
|
87
|
+
const loadMessages = useCallback(async (id: string) => {
|
|
88
|
+
if (!id) return;
|
|
89
|
+
try {
|
|
90
|
+
const r = await fetch(`${PROXY}/sessions/${id}?limit=1000`);
|
|
91
|
+
const j = (await r.json()) as { messages?: Message[] };
|
|
92
|
+
setMessages(j.messages || []);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
95
|
+
}
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (activeId) loadMessages(activeId);
|
|
100
|
+
}, [activeId, loadMessages]);
|
|
101
|
+
|
|
102
|
+
// ─── SSE subscription ─────────────────────────────────────
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!activeId) return;
|
|
105
|
+
eventSrcRef.current?.close();
|
|
106
|
+
const src = new EventSource(`${PROXY}/sessions/${activeId}/events`);
|
|
107
|
+
eventSrcRef.current = src;
|
|
108
|
+
|
|
109
|
+
src.onmessage = (ev) => {
|
|
110
|
+
let payload: { type?: string; data?: any; message_id?: string };
|
|
111
|
+
try { payload = JSON.parse(ev.data); } catch { return; }
|
|
112
|
+
const type = payload.type;
|
|
113
|
+
const data = payload.data || {};
|
|
114
|
+
if (type === 'text_delta') {
|
|
115
|
+
setPartial((p) => p + (data.delta || ''));
|
|
116
|
+
} else if (type === 'message_saved') {
|
|
117
|
+
loadMessages(activeId);
|
|
118
|
+
setPartial('');
|
|
119
|
+
} else if (type === 'memory_status') {
|
|
120
|
+
setMemory({
|
|
121
|
+
backend: data.backend,
|
|
122
|
+
pinnedCount: data.pinnedCount,
|
|
123
|
+
blocksCount: data.blocksCount,
|
|
124
|
+
hitsCount: data.hitsCount,
|
|
125
|
+
});
|
|
126
|
+
} else if (type === 'turn_done') {
|
|
127
|
+
setStreaming(false);
|
|
128
|
+
setPartial('');
|
|
129
|
+
loadMessages(activeId);
|
|
130
|
+
refreshSessions();
|
|
131
|
+
} else if (type === 'error') {
|
|
132
|
+
setStreaming(false);
|
|
133
|
+
setError(String(data.error || 'unknown error'));
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
return () => {
|
|
137
|
+
src.close();
|
|
138
|
+
};
|
|
139
|
+
}, [activeId, loadMessages, refreshSessions]);
|
|
140
|
+
|
|
141
|
+
// ─── Auto-scroll on new content ───────────────────────────
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const el = scrollRef.current;
|
|
144
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
145
|
+
}, [messages, partial]);
|
|
146
|
+
|
|
147
|
+
// ─── Auto-resize composer ─────────────────────────────────
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const el = composerRef.current;
|
|
150
|
+
if (!el) return;
|
|
151
|
+
el.style.height = 'auto';
|
|
152
|
+
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
|
153
|
+
}, [input]);
|
|
154
|
+
|
|
155
|
+
// ─── Actions ──────────────────────────────────────────────
|
|
156
|
+
async function send() {
|
|
157
|
+
const text = input.trim();
|
|
158
|
+
if (!text || !activeId || streaming) return;
|
|
159
|
+
setInput('');
|
|
160
|
+
setStreaming(true);
|
|
161
|
+
setError('');
|
|
162
|
+
setPartial('');
|
|
163
|
+
try {
|
|
164
|
+
const r = await fetch(`${PROXY}/sessions/${activeId}/messages`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ text }),
|
|
168
|
+
});
|
|
169
|
+
if (!r.ok) {
|
|
170
|
+
const j = await r.json().catch(() => ({}));
|
|
171
|
+
throw new Error(j.error || `HTTP ${r.status}`);
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {
|
|
174
|
+
setStreaming(false);
|
|
175
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function newSession() {
|
|
180
|
+
try {
|
|
181
|
+
const r = await fetch(`${PROXY}/sessions`, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: { 'content-type': 'application/json' },
|
|
184
|
+
body: JSON.stringify({ meta: { kind: 'temp' } }),
|
|
185
|
+
});
|
|
186
|
+
const j = (await r.json()) as { session?: ChatSession };
|
|
187
|
+
if (j?.session?.id) {
|
|
188
|
+
await refreshSessions();
|
|
189
|
+
setActiveId(j.session.id);
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function clearMessages() {
|
|
197
|
+
if (!activeId) return;
|
|
198
|
+
if (!confirm('Clear all messages in this session?')) return;
|
|
199
|
+
await fetch(`${PROXY}/sessions/${activeId}/messages`, { method: 'DELETE' });
|
|
200
|
+
setMessages([]);
|
|
201
|
+
setPartial('');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function deleteSession(id: string) {
|
|
205
|
+
if (!confirm('Delete this session permanently?')) return;
|
|
206
|
+
const r = await fetch(`${PROXY}/sessions/${id}`, { method: 'DELETE' });
|
|
207
|
+
if (!r.ok) {
|
|
208
|
+
const j = await r.json().catch(() => ({}));
|
|
209
|
+
alert(j.error || 'Delete failed (main session cannot be deleted)');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (id === activeId) setActiveId('');
|
|
213
|
+
await refreshSessions();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Render ───────────────────────────────────────────────
|
|
217
|
+
const activeSession = useMemo(
|
|
218
|
+
() => sessions.find((s) => s.id === activeId),
|
|
219
|
+
[sessions, activeId],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
224
|
+
className="flex h-screen text-[var(--text-primary)] bg-[var(--bg-primary)]"
|
|
225
|
+
style={{ fontFamily: SANS_FONT }}
|
|
226
|
+
>
|
|
227
|
+
{/* ─── Sidebar ─────────────────────────────────────── */}
|
|
228
|
+
<aside className="w-64 shrink-0 border-r border-[var(--border)] flex flex-col bg-[var(--bg-secondary)]/40">
|
|
229
|
+
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
|
|
230
|
+
<a
|
|
231
|
+
href="/"
|
|
232
|
+
className="text-sm font-semibold text-[var(--text-primary)] hover:text-[var(--accent)] transition-colors"
|
|
233
|
+
title="Back to Dashboard"
|
|
234
|
+
>
|
|
235
|
+
← Forge
|
|
236
|
+
</a>
|
|
237
|
+
<button
|
|
238
|
+
onClick={newSession}
|
|
239
|
+
className="text-xs px-2.5 py-1 bg-[var(--accent)] text-white rounded-md hover:opacity-90 transition-opacity"
|
|
240
|
+
>
|
|
241
|
+
+ New
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div className="flex-1 overflow-y-auto py-2 px-2">
|
|
246
|
+
{sessions.length === 0 && (
|
|
247
|
+
<div className="text-xs text-[var(--text-secondary)] italic px-2 py-3">No sessions yet.</div>
|
|
248
|
+
)}
|
|
249
|
+
{sessions.map((s) => {
|
|
250
|
+
const isMain = s.meta?.kind === 'main';
|
|
251
|
+
const isActive = s.id === activeId;
|
|
252
|
+
return (
|
|
253
|
+
<div
|
|
254
|
+
key={s.id}
|
|
255
|
+
className={`group flex items-center gap-2 px-3 py-2 rounded-md text-sm cursor-pointer mb-1 transition-colors ${
|
|
256
|
+
isActive
|
|
257
|
+
? 'bg-[var(--accent)]/15 text-[var(--text-primary)]'
|
|
258
|
+
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
|
|
259
|
+
}`}
|
|
260
|
+
onClick={() => setActiveId(s.id)}
|
|
261
|
+
>
|
|
262
|
+
<span
|
|
263
|
+
className={`inline-block w-2 h-2 rounded-full shrink-0 ${
|
|
264
|
+
isMain ? 'bg-[var(--accent)]' : 'bg-[var(--text-secondary)]/40'
|
|
265
|
+
}`}
|
|
266
|
+
/>
|
|
267
|
+
<div className="truncate flex-1">
|
|
268
|
+
{s.title || (isMain ? 'Main conversation' : `Temp · ${s.id.slice(0, 6)}`)}
|
|
269
|
+
</div>
|
|
270
|
+
{!isMain && (
|
|
271
|
+
<button
|
|
272
|
+
onClick={(e) => { e.stopPropagation(); deleteSession(s.id); }}
|
|
273
|
+
className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-red-400 text-base leading-none px-1"
|
|
274
|
+
title="Delete session"
|
|
275
|
+
>
|
|
276
|
+
×
|
|
277
|
+
</button>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
})}
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<div className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--text-secondary)] space-y-1">
|
|
285
|
+
<div className="flex items-center gap-2">
|
|
286
|
+
<span>Memory</span>
|
|
287
|
+
<span
|
|
288
|
+
className={`px-1.5 py-[1px] rounded text-[10px] uppercase tracking-wide border ${
|
|
289
|
+
memory?.backend === 'temper'
|
|
290
|
+
? 'border-green-500/60 text-green-400'
|
|
291
|
+
: memory?.backend === 'local'
|
|
292
|
+
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
293
|
+
: 'border-[var(--border)]'
|
|
294
|
+
}`}
|
|
295
|
+
>
|
|
296
|
+
{memory?.backend ?? '…'}
|
|
297
|
+
</span>
|
|
298
|
+
</div>
|
|
299
|
+
{memory && (
|
|
300
|
+
<div className="text-[11px]">
|
|
301
|
+
{memory.pinnedCount ?? 0} pinned · {memory.blocksCount ?? 0} blocks
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
<div className="text-[10px] italic pt-1 leading-snug">
|
|
305
|
+
Simplified web chat — full UX lives in the browser extension.
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</aside>
|
|
309
|
+
|
|
310
|
+
{/* ─── Main pane ───────────────────────────────────── */}
|
|
311
|
+
<main className="flex-1 flex flex-col min-w-0">
|
|
312
|
+
<header className="border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
|
|
313
|
+
<div className="min-w-0">
|
|
314
|
+
<div className="text-sm font-medium truncate">
|
|
315
|
+
{activeSession?.title ||
|
|
316
|
+
(activeSession?.meta?.kind === 'main' ? 'Main conversation' : activeSession?.id) ||
|
|
317
|
+
'No session'}
|
|
318
|
+
</div>
|
|
319
|
+
{activeSession && (
|
|
320
|
+
<div className="text-[11px] text-[var(--text-secondary)]">
|
|
321
|
+
{activeSession.provider || 'auto'} · {activeSession.model || 'default'}
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
<button
|
|
326
|
+
onClick={clearMessages}
|
|
327
|
+
disabled={!activeId}
|
|
328
|
+
className="text-xs px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded-md hover:border-red-500/60 hover:text-red-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
329
|
+
>
|
|
330
|
+
Clear
|
|
331
|
+
</button>
|
|
332
|
+
</header>
|
|
333
|
+
|
|
334
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
|
335
|
+
<div className="max-w-3xl mx-auto px-6 py-6 space-y-6">
|
|
336
|
+
{messages.length === 0 && !partial && !streaming && (
|
|
337
|
+
<div className="text-center text-sm text-[var(--text-secondary)] mt-12">
|
|
338
|
+
<div className="text-base mb-1">Start a conversation</div>
|
|
339
|
+
<div className="text-xs">Type a message below. Markdown supported.</div>
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
{messages.map((m) => (
|
|
343
|
+
<MessageView key={m.id} m={m} />
|
|
344
|
+
))}
|
|
345
|
+
{partial && (
|
|
346
|
+
<RoleBlock role="assistant">
|
|
347
|
+
<MarkdownContent content={partial} />
|
|
348
|
+
<span className="inline-block w-2 h-3 ml-0.5 align-text-bottom bg-[var(--accent)] animate-pulse" />
|
|
349
|
+
</RoleBlock>
|
|
350
|
+
)}
|
|
351
|
+
{streaming && !partial && (
|
|
352
|
+
<RoleBlock role="assistant">
|
|
353
|
+
<div className="text-sm text-[var(--text-secondary)] italic">thinking…</div>
|
|
354
|
+
</RoleBlock>
|
|
355
|
+
)}
|
|
356
|
+
{error && (
|
|
357
|
+
<div className="text-sm text-red-400 border border-red-500/30 bg-red-500/5 rounded-md p-3">
|
|
358
|
+
{error}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<form
|
|
365
|
+
className="border-t border-[var(--border)] px-6 py-4"
|
|
366
|
+
onSubmit={(e) => { e.preventDefault(); send(); }}
|
|
367
|
+
>
|
|
368
|
+
<div className="max-w-3xl mx-auto flex items-end gap-3">
|
|
369
|
+
<textarea
|
|
370
|
+
ref={composerRef}
|
|
371
|
+
value={input}
|
|
372
|
+
onChange={(e) => setInput(e.target.value)}
|
|
373
|
+
onKeyDown={(e) => {
|
|
374
|
+
// Skip Enter while an IME composition is active — otherwise
|
|
375
|
+
// pinyin/kana commit-with-Enter sends the message by mistake.
|
|
376
|
+
// isComposing covers modern browsers; keyCode===229 is the
|
|
377
|
+
// legacy fallback some IMEs still emit.
|
|
378
|
+
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
379
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
send();
|
|
382
|
+
}
|
|
383
|
+
}}
|
|
384
|
+
disabled={!activeId || streaming}
|
|
385
|
+
placeholder={activeId ? 'Message… (Enter to send · Shift+Enter for newline)' : 'Pick or create a session'}
|
|
386
|
+
rows={1}
|
|
387
|
+
style={{ fontFamily: SANS_FONT }}
|
|
388
|
+
className="flex-1 resize-none bg-[var(--bg-tertiary)] border border-[var(--border)] rounded-lg px-4 py-3 text-sm text-[var(--text-primary)] leading-relaxed focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)]/40 disabled:opacity-50"
|
|
389
|
+
/>
|
|
390
|
+
<button
|
|
391
|
+
type="submit"
|
|
392
|
+
disabled={!input.trim() || !activeId || streaming}
|
|
393
|
+
className="px-4 py-2.5 text-sm font-medium bg-[var(--accent)] text-white rounded-lg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
|
394
|
+
>
|
|
395
|
+
{streaming ? '…' : 'Send'}
|
|
396
|
+
</button>
|
|
397
|
+
</div>
|
|
398
|
+
</form>
|
|
399
|
+
</main>
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ─── Message renderers ────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
function RoleBlock({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
|
|
407
|
+
const isUser = role === 'user';
|
|
408
|
+
return (
|
|
409
|
+
<div className="flex gap-4">
|
|
410
|
+
<div
|
|
411
|
+
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
|
412
|
+
isUser
|
|
413
|
+
? 'bg-[var(--accent)] text-white'
|
|
414
|
+
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]'
|
|
415
|
+
}`}
|
|
416
|
+
aria-hidden
|
|
417
|
+
>
|
|
418
|
+
{isUser ? 'U' : 'AI'}
|
|
419
|
+
</div>
|
|
420
|
+
<div className="flex-1 min-w-0">
|
|
421
|
+
<div className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">
|
|
422
|
+
{role}
|
|
423
|
+
</div>
|
|
424
|
+
<div className="space-y-2">{children}</div>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function MessageView({ m }: { m: Message }) {
|
|
431
|
+
return (
|
|
432
|
+
<RoleBlock role={m.role}>
|
|
433
|
+
{m.blocks.map((b, i) => (
|
|
434
|
+
<BlockView key={i} b={b} />
|
|
435
|
+
))}
|
|
436
|
+
{m.error && (
|
|
437
|
+
<div className="text-xs text-red-400 border border-red-500/30 bg-red-500/5 rounded-md p-2 mt-1">
|
|
438
|
+
{m.error}
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
</RoleBlock>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function BlockView({ b }: { b: ContentBlock }) {
|
|
446
|
+
if (b.type === 'text') {
|
|
447
|
+
return <MarkdownContent content={b.text} />;
|
|
448
|
+
}
|
|
449
|
+
if (b.type === 'tool_use') {
|
|
450
|
+
return <ToolUseBlockView name={b.name} input={b.input} />;
|
|
451
|
+
}
|
|
452
|
+
if (b.type === 'tool_result') {
|
|
453
|
+
const txt = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
|
|
454
|
+
return <ToolResultBlockView content={txt} isError={!!b.is_error} />;
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function ToolUseBlockView({ name, input }: { name: string; input: unknown }) {
|
|
460
|
+
const [open, setOpen] = useState(false);
|
|
461
|
+
const preview = JSON.stringify(input);
|
|
462
|
+
return (
|
|
463
|
+
<div className="rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)]/50 text-xs">
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={() => setOpen((v) => !v)}
|
|
467
|
+
className="w-full px-3 py-1.5 flex items-center gap-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
468
|
+
>
|
|
469
|
+
<span className="text-[10px]">{open ? '▾' : '▸'}</span>
|
|
470
|
+
<span className="text-[var(--accent)] font-mono">→ {name}</span>
|
|
471
|
+
{!open && (
|
|
472
|
+
<span className="text-[var(--text-secondary)] font-mono truncate flex-1">{preview}</span>
|
|
473
|
+
)}
|
|
474
|
+
</button>
|
|
475
|
+
{open && (
|
|
476
|
+
<pre className="px-3 pb-2 pt-1 text-[11px] font-mono text-[var(--text-secondary)] whitespace-pre-wrap break-words border-t border-[var(--border)] bg-[var(--bg-tertiary)]/30">
|
|
477
|
+
{tryPrettyJson(preview)}
|
|
478
|
+
</pre>
|
|
479
|
+
)}
|
|
480
|
+
</div>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function ToolResultBlockView({ content, isError }: { content: string; isError: boolean }) {
|
|
485
|
+
const [open, setOpen] = useState(false);
|
|
486
|
+
const truncated = content.length > 400 ? content.slice(0, 400) + '…' : content;
|
|
487
|
+
return (
|
|
488
|
+
<div
|
|
489
|
+
className={`rounded-md border text-xs ${
|
|
490
|
+
isError
|
|
491
|
+
? 'border-red-500/40 bg-red-500/5'
|
|
492
|
+
: 'border-[var(--border)] bg-[var(--bg-tertiary)]/30'
|
|
493
|
+
}`}
|
|
494
|
+
>
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
onClick={() => setOpen((v) => !v)}
|
|
498
|
+
className="w-full px-3 py-1.5 flex items-center gap-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
499
|
+
>
|
|
500
|
+
<span className="text-[10px]">{open ? '▾' : '▸'}</span>
|
|
501
|
+
<span className={isError ? 'text-red-400 font-mono' : 'text-[var(--text-secondary)] font-mono'}>
|
|
502
|
+
{isError ? 'error result' : 'tool result'}
|
|
503
|
+
</span>
|
|
504
|
+
{!open && (
|
|
505
|
+
<span className="text-[var(--text-secondary)] font-mono truncate flex-1">
|
|
506
|
+
{truncated.replace(/\s+/g, ' ').slice(0, 100)}
|
|
507
|
+
</span>
|
|
508
|
+
)}
|
|
509
|
+
</button>
|
|
510
|
+
{open && (
|
|
511
|
+
<pre
|
|
512
|
+
className={`px-3 pb-2 pt-1 text-[11px] font-mono whitespace-pre-wrap break-words border-t ${
|
|
513
|
+
isError
|
|
514
|
+
? 'border-red-500/30 text-red-400'
|
|
515
|
+
: 'border-[var(--border)] text-[var(--text-secondary)]'
|
|
516
|
+
}`}
|
|
517
|
+
>
|
|
518
|
+
{content.length > 4000 ? content.slice(0, 4000) + '\n…(truncated)' : content}
|
|
519
|
+
</pre>
|
|
520
|
+
)}
|
|
521
|
+
</div>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function tryPrettyJson(s: string): string {
|
|
526
|
+
try {
|
|
527
|
+
return JSON.stringify(JSON.parse(s), null, 2);
|
|
528
|
+
} catch {
|
|
529
|
+
return s;
|
|
530
|
+
}
|
|
531
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -296,7 +296,7 @@ function cleanupOrphans() {
|
|
|
296
296
|
}
|
|
297
297
|
// Kill standalone processes: our instance's + orphans without any tag
|
|
298
298
|
try {
|
|
299
|
-
const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone|workspace-standalone' | grep -v grep`, {
|
|
299
|
+
const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone|workspace-standalone|browser-bridge-standalone|chat-standalone' | grep -v grep`, {
|
|
300
300
|
encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
301
301
|
}).trim();
|
|
302
302
|
for (const line of out.split('\n').filter(Boolean)) {
|
|
@@ -348,6 +348,8 @@ function startServices(daemonize = false) {
|
|
|
348
348
|
spawnService('Terminal server', join(ROOT, 'lib', 'terminal-standalone.ts'));
|
|
349
349
|
spawnService('Telegram bot', join(ROOT, 'lib', 'telegram-standalone.ts'));
|
|
350
350
|
spawnService('Workspace daemon', join(ROOT, 'lib', 'workspace-standalone.ts'));
|
|
351
|
+
spawnService('Browser bridge', join(ROOT, 'lib', 'browser-bridge-standalone.ts'));
|
|
352
|
+
spawnService('Chat', join(ROOT, 'lib', 'chat-standalone.ts'));
|
|
351
353
|
|
|
352
354
|
const childPids = services.map(c => c.pid).filter(Boolean);
|
|
353
355
|
savePids(childPids);
|