@stackbilt/aegis-core 0.7.0 → 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/package.json +13 -3
- package/public/assets/index-CQHn03rW.css +1 -0
- package/public/assets/index-CTKpNJEr.js +74 -0
- package/public/index.html +14 -0
- package/src/agent-routing.ts +38 -0
- package/src/assets.ts +6 -0
- package/src/auth.ts +1 -0
- package/src/index.ts +7 -1
- package/src/routes/messages.ts +11 -6
- package/src/routes/pages.ts +7 -0
- package/src/types.ts +2 -0
- package/src/ui/index.html +13 -0
- package/src/ui/main.tsx +356 -0
- package/src/ui/styles.css +391 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="theme-color" content="#101317" />
|
|
7
|
+
<title>AEGIS</title>
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CTKpNJEr.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CQHn03rW.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { routeAgentRequest } from 'agents';
|
|
2
|
+
import type { Env } from './types.js';
|
|
3
|
+
|
|
4
|
+
export async function routeAegisAgentRequest(
|
|
5
|
+
request: Request,
|
|
6
|
+
env: Env,
|
|
7
|
+
): Promise<Response | undefined> {
|
|
8
|
+
const { pathname } = new URL(request.url);
|
|
9
|
+
if (!pathname.startsWith('/agents/')) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const token = extractBearer(request.headers.get('Authorization'))
|
|
14
|
+
?? getCookie(request.headers.get('Cookie') ?? '', 'aegis_token')
|
|
15
|
+
?? new URL(request.url).searchParams.get('token');
|
|
16
|
+
|
|
17
|
+
if (!token || token !== env.AEGIS_TOKEN) {
|
|
18
|
+
return new Response('Unauthorized', { status: 401 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const response = await routeAgentRequest(request, env);
|
|
23
|
+
return response ?? new Response('Agent route not found', { status: 404 });
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
26
|
+
return new Response(`Agent route failed: ${message}`, { status: 503 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractBearer(header: string | null): string | null {
|
|
31
|
+
if (!header?.startsWith('Bearer ')) return null;
|
|
32
|
+
return header.slice(7);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getCookie(cookieHeader: string, name: string): string | null {
|
|
36
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
|
37
|
+
return match?.[1] ?? null;
|
|
38
|
+
}
|
package/src/assets.ts
ADDED
package/src/auth.ts
CHANGED
|
@@ -7,6 +7,7 @@ export async function bearerAuth(c: Context<{ Bindings: Env }>, next: Next): Pro
|
|
|
7
7
|
c.req.path === '/health' ||
|
|
8
8
|
c.req.path === '/pulse' ||
|
|
9
9
|
((c.req.path === '/' || c.req.path === '/chat' || c.req.path === '/manifest.json' || c.req.path === '/sw.js') && c.req.method === 'GET') ||
|
|
10
|
+
(c.req.method === 'GET' && (c.req.path.startsWith('/assets/') || c.req.path === '/favicon.svg')) ||
|
|
10
11
|
c.req.path.startsWith('/tech') ||
|
|
11
12
|
c.req.path === '/api/feedback' ||
|
|
12
13
|
c.req.path === '/observe' ||
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { Hono } from 'hono';
|
|
10
10
|
import OAuthProvider from '@cloudflare/workers-oauth-provider';
|
|
11
11
|
import { bearerAuth } from './auth.js';
|
|
12
|
+
import { routeAegisAgentRequest } from './agent-routing.js';
|
|
12
13
|
import { runScheduledTasks } from './kernel/scheduled/index.js';
|
|
13
14
|
import type { Env } from './types.js';
|
|
14
15
|
import { handleMcpRequest } from './mcp-server.js';
|
|
@@ -84,10 +85,15 @@ const oauthProvider = new OAuthProvider<Env>({
|
|
|
84
85
|
});
|
|
85
86
|
|
|
86
87
|
export default {
|
|
87
|
-
fetch:
|
|
88
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
89
|
+
const agentResponse = await routeAegisAgentRequest(request, env);
|
|
90
|
+
if (agentResponse) return agentResponse;
|
|
91
|
+
return oauthProvider.fetch(request, env, ctx);
|
|
92
|
+
},
|
|
88
93
|
async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
89
94
|
ctx.waitUntil(runScheduledTasks(buildEdgeEnv(env)));
|
|
90
95
|
},
|
|
91
96
|
};
|
|
92
97
|
|
|
93
98
|
export { ChatSession } from './durable-objects/chat-session.js';
|
|
99
|
+
export { AegisVoiceAdapter } from './adapters/voice/cloudflare-agent.js';
|
package/src/routes/messages.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { bodyLimit } from 'hono/body-limit';
|
|
|
3
3
|
import { createIntent, dispatch, dispatchStream } from '../kernel/dispatch.js';
|
|
4
4
|
import { askGroq } from '../groq.js';
|
|
5
5
|
import type { Env, MessageMetadata } from '../types.js';
|
|
6
|
+
import type { Executor } from '../kernel/types.js';
|
|
6
7
|
import { buildEdgeEnv } from '../edge-env.js';
|
|
7
8
|
|
|
8
9
|
// 100 KB — generous for chat text, blocks payload abuse
|
|
@@ -47,7 +48,7 @@ async function generateConversationTitle(
|
|
|
47
48
|
|
|
48
49
|
// ─── Send Message (edge-native kernel) ───────────────────────
|
|
49
50
|
messages.post('/api/message', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async (c) => {
|
|
50
|
-
const body = await c.req.json<{ text: string; conversationId?: string }>();
|
|
51
|
+
const body = await c.req.json<{ text: string; conversationId?: string; executor?: Executor }>();
|
|
51
52
|
if (!body.text?.trim()) {
|
|
52
53
|
return c.json({ error: 'text is required' }, 400);
|
|
53
54
|
}
|
|
@@ -80,7 +81,7 @@ messages.post('/api/message', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async
|
|
|
80
81
|
).bind(userMessageId, conversationId, 'user', text).run();
|
|
81
82
|
|
|
82
83
|
// Fire-and-forget title generation for new conversations (#21)
|
|
83
|
-
if (isNewConversation && c.executionCtx) {
|
|
84
|
+
if (isNewConversation && c.executionCtx && c.env.GROQ_API_KEY) {
|
|
84
85
|
c.executionCtx.waitUntil(
|
|
85
86
|
generateConversationTitle(c.env.DB, conversationId, text, c.env.GROQ_API_KEY, c.env.GROQ_MODEL || 'llama-3.3-70b-versatile', groqBaseUrl(c.env)),
|
|
86
87
|
);
|
|
@@ -88,7 +89,9 @@ messages.post('/api/message', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async
|
|
|
88
89
|
|
|
89
90
|
// Dispatch through edge kernel
|
|
90
91
|
const edgeEnv = buildEdgeEnv(c.env, c.executionCtx);
|
|
91
|
-
const intent = createIntent(conversationId, text
|
|
92
|
+
const intent = createIntent(conversationId, text, {
|
|
93
|
+
forcedExecutor: body.executor,
|
|
94
|
+
});
|
|
92
95
|
|
|
93
96
|
try {
|
|
94
97
|
const result = await dispatch(intent, edgeEnv);
|
|
@@ -146,7 +149,7 @@ messages.post('/api/message', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async
|
|
|
146
149
|
|
|
147
150
|
// ─── Streaming Message (SSE) ─────────────────────────────────
|
|
148
151
|
messages.post('/api/message/stream', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async (c) => {
|
|
149
|
-
const body = await c.req.json<{ text: string; conversationId?: string }>();
|
|
152
|
+
const body = await c.req.json<{ text: string; conversationId?: string; executor?: Executor }>();
|
|
150
153
|
if (!body.text?.trim()) {
|
|
151
154
|
return c.json({ error: 'text is required' }, 400);
|
|
152
155
|
}
|
|
@@ -169,14 +172,16 @@ messages.post('/api/message/stream', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }),
|
|
|
169
172
|
await c.env.DB.prepare('INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)').bind(userMessageId, conversationId, 'user', text).run();
|
|
170
173
|
|
|
171
174
|
// Fire-and-forget title generation for new conversations (#21)
|
|
172
|
-
if (isNewConvStream && c.executionCtx) {
|
|
175
|
+
if (isNewConvStream && c.executionCtx && c.env.GROQ_API_KEY) {
|
|
173
176
|
c.executionCtx.waitUntil(
|
|
174
177
|
generateConversationTitle(c.env.DB, conversationId, text, c.env.GROQ_API_KEY, c.env.GROQ_MODEL || 'llama-3.3-70b-versatile', groqBaseUrl(c.env)),
|
|
175
178
|
);
|
|
176
179
|
}
|
|
177
180
|
|
|
178
181
|
const edgeEnv = buildEdgeEnv(c.env, c.executionCtx);
|
|
179
|
-
const intent = createIntent(conversationId, text
|
|
182
|
+
const intent = createIntent(conversationId, text, {
|
|
183
|
+
forcedExecutor: body.executor,
|
|
184
|
+
});
|
|
180
185
|
|
|
181
186
|
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
|
|
182
187
|
const writer = writable.getWriter();
|
package/src/routes/pages.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
4
|
import { bodyLimit } from 'hono/body-limit';
|
|
5
5
|
import type { Env } from '../types.js';
|
|
6
|
+
import { serveSpaIndex } from '../assets.js';
|
|
6
7
|
import { chatPage } from '../ui.js';
|
|
7
8
|
import { landingPage } from '../landing.js';
|
|
8
9
|
import { dashboardPage, getDashboardData } from '../dashboard.js';
|
|
@@ -15,6 +16,9 @@ const pages = new Hono<{ Bindings: Env }>();
|
|
|
15
16
|
// ─── Landing ────────────────────────────────────────────────
|
|
16
17
|
|
|
17
18
|
pages.get('/', (c) => {
|
|
19
|
+
if (c.env.ASSETS) {
|
|
20
|
+
return serveSpaIndex(c.req.raw, c.env.ASSETS);
|
|
21
|
+
}
|
|
18
22
|
return c.html(chatPage());
|
|
19
23
|
});
|
|
20
24
|
|
|
@@ -25,6 +29,9 @@ pages.get('/about', (c) => {
|
|
|
25
29
|
// ─── Chat ───────────────────────────────────────────────────
|
|
26
30
|
|
|
27
31
|
pages.get('/chat', (c) => {
|
|
32
|
+
if (c.env.ASSETS) {
|
|
33
|
+
return serveSpaIndex(c.req.raw, c.env.ASSETS);
|
|
34
|
+
}
|
|
28
35
|
return c.html(chatPage());
|
|
29
36
|
});
|
|
30
37
|
|
package/src/types.ts
CHANGED
|
@@ -4,7 +4,9 @@ export interface Env {
|
|
|
4
4
|
DB: D1Database;
|
|
5
5
|
AI: Ai;
|
|
6
6
|
AEGIS_TOKEN: string;
|
|
7
|
+
ASSETS?: Fetcher;
|
|
7
8
|
CHAT_SESSION?: DurableObjectNamespace;
|
|
9
|
+
AegisVoiceAdapter?: DurableObjectNamespace;
|
|
8
10
|
|
|
9
11
|
// OAuth 2.1 (injected by OAuthProvider wrapper at runtime)
|
|
10
12
|
OAUTH_PROVIDER: OAuthHelpers;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="theme-color" content="#101317" />
|
|
7
|
+
<title>AEGIS</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/src/ui/main.tsx
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { useVoiceAgent } from '@cloudflare/voice/react';
|
|
4
|
+
import './styles.css';
|
|
5
|
+
|
|
6
|
+
type Conversation = {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string | null;
|
|
9
|
+
created_at?: string;
|
|
10
|
+
updated_at?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Message = {
|
|
14
|
+
id: string;
|
|
15
|
+
role: 'user' | 'assistant' | string;
|
|
16
|
+
content: string;
|
|
17
|
+
metadata?: Record<string, unknown> | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type HealthPayload = {
|
|
21
|
+
status: string;
|
|
22
|
+
version: string;
|
|
23
|
+
mode: string;
|
|
24
|
+
kernel?: Record<string, number>;
|
|
25
|
+
tasks_24h?: Array<{ task_name: string; runs: number; ok: number; errors: number }>;
|
|
26
|
+
docs_sync_status?: { status: string; lastSyncAge: number | null };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TOKEN_KEY = 'aegis_token';
|
|
30
|
+
|
|
31
|
+
function App() {
|
|
32
|
+
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
|
|
33
|
+
const [tokenDraft, setTokenDraft] = useState(token);
|
|
34
|
+
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
35
|
+
const [conversationId, setConversationId] = useState<string | null>(null);
|
|
36
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
37
|
+
const [prompt, setPrompt] = useState('');
|
|
38
|
+
const [status, setStatus] = useState('Ready');
|
|
39
|
+
const [health, setHealth] = useState<HealthPayload | null>(null);
|
|
40
|
+
const [voiceText, setVoiceText] = useState('');
|
|
41
|
+
const messagesRef = useRef<HTMLDivElement | null>(null);
|
|
42
|
+
|
|
43
|
+
const voice = useVoiceAgent({
|
|
44
|
+
agent: 'AegisVoiceAdapter',
|
|
45
|
+
name: 'operator',
|
|
46
|
+
query: token ? { token } : undefined,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const authHeaders = useMemo<Record<string, string>>(
|
|
50
|
+
() => {
|
|
51
|
+
const headers: Record<string, string> = {};
|
|
52
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
53
|
+
return headers;
|
|
54
|
+
},
|
|
55
|
+
[token],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
document.cookie = token
|
|
60
|
+
? `aegis_token=${encodeURIComponent(token)};path=/;max-age=31536000;SameSite=Strict;Secure`
|
|
61
|
+
: 'aegis_token=;path=/;max-age=0;SameSite=Strict;Secure';
|
|
62
|
+
}, [token]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
void refreshHealth();
|
|
66
|
+
if (token) void refreshConversations();
|
|
67
|
+
}, [token]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
messagesRef.current?.scrollTo({ top: messagesRef.current.scrollHeight });
|
|
71
|
+
}, [messages]);
|
|
72
|
+
|
|
73
|
+
async function refreshHealth() {
|
|
74
|
+
const res = await fetch('/health?format=json', {
|
|
75
|
+
headers: { Accept: 'application/json' },
|
|
76
|
+
});
|
|
77
|
+
if (res.ok) {
|
|
78
|
+
setHealth(await res.json());
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function refreshConversations() {
|
|
83
|
+
const res = await fetch('/api/conversations', { headers: authHeaders });
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
setStatus(res.status === 401 ? 'Add your AEGIS token' : `Conversations failed: ${res.status}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const data = await res.json() as { conversations: Conversation[] };
|
|
89
|
+
setConversations(data.conversations);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function loadConversation(id: string) {
|
|
93
|
+
setConversationId(id);
|
|
94
|
+
const res = await fetch(`/api/conversations/${encodeURIComponent(id)}/messages`, {
|
|
95
|
+
headers: authHeaders,
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
setStatus(`History failed: ${res.status}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const data = await res.json() as { messages: Message[] };
|
|
102
|
+
setMessages(data.messages);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function saveToken() {
|
|
106
|
+
const next = tokenDraft.trim();
|
|
107
|
+
localStorage.setItem(TOKEN_KEY, next);
|
|
108
|
+
setToken(next);
|
|
109
|
+
setStatus(next ? 'Token saved' : 'Token cleared');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function sendMessage(text = prompt.trim()) {
|
|
113
|
+
if (!text) return;
|
|
114
|
+
if (!token) {
|
|
115
|
+
setStatus('Add your AEGIS token');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const optimisticUser: Message = { id: crypto.randomUUID(), role: 'user', content: text };
|
|
120
|
+
const assistantId = crypto.randomUUID();
|
|
121
|
+
setMessages((current) => [
|
|
122
|
+
...current,
|
|
123
|
+
optimisticUser,
|
|
124
|
+
{ id: assistantId, role: 'assistant', content: '' },
|
|
125
|
+
]);
|
|
126
|
+
setPrompt('');
|
|
127
|
+
setStatus('Streaming');
|
|
128
|
+
|
|
129
|
+
const res = await fetch('/api/message/stream', {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {
|
|
132
|
+
...authHeaders,
|
|
133
|
+
'Content-Type': 'application/json',
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify({ text, conversationId, executor: 'workers_ai' }),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!res.ok || !res.body) {
|
|
139
|
+
setStatus(`Message failed: ${res.status}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let assistantText = '';
|
|
144
|
+
for await (const frame of readSse(res.body)) {
|
|
145
|
+
if (frame.type === 'start' && typeof frame.conversationId === 'string') {
|
|
146
|
+
setConversationId(frame.conversationId);
|
|
147
|
+
}
|
|
148
|
+
if (frame.type === 'delta' && typeof frame.text === 'string') {
|
|
149
|
+
assistantText += frame.text;
|
|
150
|
+
setMessages((current) => current.map((message) => (
|
|
151
|
+
message.id === assistantId ? { ...message, content: assistantText } : message
|
|
152
|
+
)));
|
|
153
|
+
}
|
|
154
|
+
if (frame.type === 'error') {
|
|
155
|
+
setStatus(typeof frame.error === 'string' ? frame.error : 'Stream error');
|
|
156
|
+
}
|
|
157
|
+
if (frame.type === 'done') {
|
|
158
|
+
setStatus('Ready');
|
|
159
|
+
void refreshConversations();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sendVoiceText() {
|
|
165
|
+
const text = voiceText.trim();
|
|
166
|
+
if (!text) return;
|
|
167
|
+
voice.sendText(text);
|
|
168
|
+
setVoiceText('');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const activeConversation = conversations.find((conversation) => conversation.id === conversationId);
|
|
172
|
+
const taskErrors = health?.tasks_24h?.reduce((sum, task) => sum + Number(task.errors ?? 0), 0) ?? 0;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<main className="shell">
|
|
176
|
+
<aside className="rail" aria-label="AEGIS navigation">
|
|
177
|
+
<section className="brand">
|
|
178
|
+
<div className="brand-mark" aria-hidden="true">A</div>
|
|
179
|
+
<div>
|
|
180
|
+
<h1>AEGIS</h1>
|
|
181
|
+
<p>Edge-native operator console</p>
|
|
182
|
+
</div>
|
|
183
|
+
</section>
|
|
184
|
+
|
|
185
|
+
<section className="token-panel" aria-label="Access token">
|
|
186
|
+
<label htmlFor="token">Access token</label>
|
|
187
|
+
<div className="token-row">
|
|
188
|
+
<input
|
|
189
|
+
id="token"
|
|
190
|
+
type="password"
|
|
191
|
+
value={tokenDraft}
|
|
192
|
+
onChange={(event) => setTokenDraft(event.target.value)}
|
|
193
|
+
autoComplete="off"
|
|
194
|
+
/>
|
|
195
|
+
<button type="button" onClick={saveToken} aria-label="Save token">OK</button>
|
|
196
|
+
</div>
|
|
197
|
+
</section>
|
|
198
|
+
|
|
199
|
+
<section className="health-panel" aria-label="Health dashboard">
|
|
200
|
+
<div className="panel-title">
|
|
201
|
+
<span>Health</span>
|
|
202
|
+
<button type="button" onClick={refreshHealth} aria-label="Refresh health">R</button>
|
|
203
|
+
</div>
|
|
204
|
+
<dl className="health-grid">
|
|
205
|
+
<div>
|
|
206
|
+
<dt>Status</dt>
|
|
207
|
+
<dd>{health?.status ?? 'unknown'}</dd>
|
|
208
|
+
</div>
|
|
209
|
+
<div>
|
|
210
|
+
<dt>Version</dt>
|
|
211
|
+
<dd>{health?.version ?? '-'}</dd>
|
|
212
|
+
</div>
|
|
213
|
+
<div>
|
|
214
|
+
<dt>Kernel</dt>
|
|
215
|
+
<dd>{health?.kernel ? Object.values(health.kernel).reduce((a, b) => a + b, 0) : '-'}</dd>
|
|
216
|
+
</div>
|
|
217
|
+
<div>
|
|
218
|
+
<dt>Task errors</dt>
|
|
219
|
+
<dd>{taskErrors}</dd>
|
|
220
|
+
</div>
|
|
221
|
+
</dl>
|
|
222
|
+
</section>
|
|
223
|
+
|
|
224
|
+
<section className="conversation-panel" aria-label="Conversations">
|
|
225
|
+
<div className="panel-title">
|
|
226
|
+
<span>Conversations</span>
|
|
227
|
+
<button type="button" onClick={refreshConversations} aria-label="Refresh conversations">R</button>
|
|
228
|
+
</div>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
className="new-chat"
|
|
232
|
+
onClick={() => {
|
|
233
|
+
setConversationId(null);
|
|
234
|
+
setMessages([]);
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
New session
|
|
238
|
+
</button>
|
|
239
|
+
<div className="conversation-list">
|
|
240
|
+
{conversations.map((conversation) => (
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
key={conversation.id}
|
|
244
|
+
className={conversation.id === conversationId ? 'conversation active' : 'conversation'}
|
|
245
|
+
onClick={() => loadConversation(conversation.id)}
|
|
246
|
+
>
|
|
247
|
+
<span>{conversation.title || 'Untitled'}</span>
|
|
248
|
+
<small>{conversation.updated_at ? new Date(conversation.updated_at).toLocaleString() : 'recent'}</small>
|
|
249
|
+
</button>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
</section>
|
|
253
|
+
</aside>
|
|
254
|
+
|
|
255
|
+
<section className="workspace">
|
|
256
|
+
<header className="topbar">
|
|
257
|
+
<div>
|
|
258
|
+
<p className="eyebrow">Self-sufficient deploy</p>
|
|
259
|
+
<h2>{activeConversation?.title || 'Operator session'}</h2>
|
|
260
|
+
</div>
|
|
261
|
+
<div className="runtime-status" aria-live="polite">
|
|
262
|
+
<span className={token ? 'dot online' : 'dot'} aria-hidden="true" />
|
|
263
|
+
{status}
|
|
264
|
+
</div>
|
|
265
|
+
</header>
|
|
266
|
+
|
|
267
|
+
<section className="message-surface" ref={messagesRef} aria-label="Messages">
|
|
268
|
+
{messages.length === 0 ? (
|
|
269
|
+
<div className="empty-state">
|
|
270
|
+
<p>Ask the agent to inspect memory, explain its health, or plan the next deployment step.</p>
|
|
271
|
+
</div>
|
|
272
|
+
) : messages.map((message) => (
|
|
273
|
+
<article key={message.id} className={`message ${message.role}`}>
|
|
274
|
+
<div className="message-role">{message.role}</div>
|
|
275
|
+
<div className="message-content">{message.content || '...'}</div>
|
|
276
|
+
</article>
|
|
277
|
+
))}
|
|
278
|
+
</section>
|
|
279
|
+
|
|
280
|
+
<form
|
|
281
|
+
className="composer"
|
|
282
|
+
onSubmit={(event) => {
|
|
283
|
+
event.preventDefault();
|
|
284
|
+
void sendMessage();
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
<textarea
|
|
288
|
+
value={prompt}
|
|
289
|
+
onChange={(event) => setPrompt(event.target.value)}
|
|
290
|
+
placeholder="Message AEGIS"
|
|
291
|
+
rows={3}
|
|
292
|
+
/>
|
|
293
|
+
<button type="submit" aria-label="Send message">Send</button>
|
|
294
|
+
</form>
|
|
295
|
+
</section>
|
|
296
|
+
|
|
297
|
+
<aside className="voice-panel" aria-label="Voice call">
|
|
298
|
+
<div className="panel-title">
|
|
299
|
+
<span>Voice</span>
|
|
300
|
+
<span className="voice-state">{voice.status}</span>
|
|
301
|
+
</div>
|
|
302
|
+
<div className="meter" aria-label="Audio level">
|
|
303
|
+
<span style={{ width: `${Math.round(voice.audioLevel * 100)}%` }} />
|
|
304
|
+
</div>
|
|
305
|
+
<div className="voice-actions">
|
|
306
|
+
<button type="button" onClick={() => voice.connected ? voice.endCall() : void voice.startCall()}>
|
|
307
|
+
{voice.connected ? 'End call' : 'Start call'}
|
|
308
|
+
</button>
|
|
309
|
+
<button type="button" onClick={voice.toggleMute} disabled={!voice.connected}>
|
|
310
|
+
{voice.isMuted ? 'Unmute' : 'Mute'}
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
{voice.error ? <p className="error">{voice.error}</p> : null}
|
|
314
|
+
<div className="voice-transcript">
|
|
315
|
+
{voice.transcript.slice(-8).map((entry, index) => (
|
|
316
|
+
<p key={`${entry.role}-${index}`}>
|
|
317
|
+
<strong>{entry.role}</strong>
|
|
318
|
+
<span>{entry.text}</span>
|
|
319
|
+
</p>
|
|
320
|
+
))}
|
|
321
|
+
{voice.interimTranscript ? <p><strong>interim</strong><span>{voice.interimTranscript}</span></p> : null}
|
|
322
|
+
</div>
|
|
323
|
+
<div className="voice-text">
|
|
324
|
+
<input
|
|
325
|
+
value={voiceText}
|
|
326
|
+
onChange={(event) => setVoiceText(event.target.value)}
|
|
327
|
+
placeholder="Send text to voice agent"
|
|
328
|
+
/>
|
|
329
|
+
<button type="button" onClick={sendVoiceText} disabled={!voice.connected}>Send</button>
|
|
330
|
+
</div>
|
|
331
|
+
</aside>
|
|
332
|
+
</main>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function* readSse(body: ReadableStream<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
|
|
337
|
+
const reader = body.getReader();
|
|
338
|
+
const decoder = new TextDecoder();
|
|
339
|
+
let buffer = '';
|
|
340
|
+
|
|
341
|
+
while (true) {
|
|
342
|
+
const { value, done } = await reader.read();
|
|
343
|
+
if (done) break;
|
|
344
|
+
buffer += decoder.decode(value, { stream: true });
|
|
345
|
+
const frames = buffer.split('\n\n');
|
|
346
|
+
buffer = frames.pop() ?? '';
|
|
347
|
+
|
|
348
|
+
for (const frame of frames) {
|
|
349
|
+
const dataLine = frame.split('\n').find((line) => line.startsWith('data: '));
|
|
350
|
+
if (!dataLine) continue;
|
|
351
|
+
yield JSON.parse(dataLine.slice(6));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
createRoot(document.getElementById('root') as HTMLElement).render(<App />);
|