Aria AI#3275
Conversation
|
@Zer0-deu is attempting to deploy a commit to the World Monitor Team on Vercel. A member of the Team first needs to authorize it. |
Greptile SummaryThis PR introduces ARIA, a JARVIS-like AI assistant panel that streams LLM responses over SSE, assembles context from 30+ data sources, and integrates into the WorldMonitor panel grid. Several P1 bugs remain across the server handler, client panel, and service layer in addition to those already flagged in the prior review thread.
Confidence Score: 3/5Not safe to merge — multiple P1 defects produce wrong LLM context, broken conversation persistence, and likely auth failures on every query. Between the issues in the prior review thread (SSE parsing, response.ok missing, hardcoded awareness data, active_alerts dead branch) and the three new P1s found here (domain-filter destructuring misalignment, missing aria-message save, unauthenticated fetch), the feature's primary user path has at least five confirmed P1 defects still present. server/worldmonitor/aria/v1/handler.ts and src/components/AriaPanel.ts require the most attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant U as User (Browser)
participant AP as AriaPanel.ts
participant API as api/aria.js (Edge)
participant H as handler.ts
participant LLM as LLM (callLlmReasoningStream)
participant Cache as Redis / cachedFetchJson
U->>AP: Type query + press QUERY
AP->>API: POST /api/aria/query (no auth header ⚠️)
API->>H: handleAriaQuery(request, env)
H->>H: validateApiKey → 401 if fails ⚠️
H->>Cache: assembleAriaContext() — fetches 8 data sources
Note over H: domain filter may corrupt<br/>destructuring order ⚠️
H->>LLM: callLlmReasoningStream(systemPrompt, query)
LLM-->>H: stream chunks
H-->>API: SSE: event:metadata / event:delta / event:action / event:done
API-->>AP: SSE stream (response.ok not checked ⚠️)
AP->>AP: Parse SSE — lines.find always returns first data: line ⚠️
AP->>AP: Update UI with delta content
AP->>AP: saveConversation(userMessage only) ⚠️
Note over AP: ariaMessage never saved to localStorage
Reviews (2): Last reviewed commit: "Merge branch 'koala73:main' into main" | Re-trigger Greptile |
| for (const line of lines.slice(0, -1)) { | ||
| if (line.startsWith("event:")) { | ||
| const eventType = line.substring(7).trim(); | ||
| const nextIdx = lines.indexOf(lines.find((l) => l.startsWith("data:")) || ""); | ||
| if (nextIdx > -1) { | ||
| const dataLine = lines[nextIdx]; | ||
| if (dataLine.startsWith("data:")) { | ||
| const data = JSON.parse(dataLine.substring(6)); | ||
|
|
||
| if (eventType === "metadata") { | ||
| if (!ariaMessage) { | ||
| ariaMessage = { | ||
| id: data.conversation_id, | ||
| role: "aria", | ||
| content: "", | ||
| timestamp: new Date(), | ||
| metadata: { | ||
| mode: data.mode, | ||
| sources: data.accessed_domains, | ||
| }, | ||
| }; | ||
| this.addMessage(messagesContainer, ariaMessage); | ||
| } | ||
| } else if (eventType === "delta" && ariaMessage) { | ||
| ariaMessage.content += data.delta; | ||
| this.updateLastMessage(messagesContainer, ariaMessage); | ||
| } else if (eventType === "action") { | ||
| if (!ariaMessage) ariaMessage = { | ||
| id: data.id || `aria-${Date.now()}`, | ||
| role: "aria", | ||
| content: "", | ||
| timestamp: new Date(), | ||
| }; | ||
| if (!ariaMessage.actions) ariaMessage.actions = []; | ||
| ariaMessage.actions.push(data); | ||
| this.updateLastMessage(messagesContainer, ariaMessage); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Broken SSE data-line lookup and missing response error check
lines.find((l) => l.startsWith("data:")) always searches the whole lines array and returns the first data: line regardless of which event is being processed. When multiple SSE events arrive in the same chunk (e.g. metadata + delta), every event ends up reading the same first data line, so delta content is silently discarded. Compare the correct index-based approach used in src/services/aria/index.ts:92.
Additionally, there is no response.ok check before consuming the body (line 403). A 401 or 500 error body is not SSE-formatted, so the inner JSON.parse silently catches and swallows every line, leaving the user with a hidden typing indicator and no ARIA response.
| export async function getAriaAlerts( | ||
| severity?: "info" | "warning" | "critical" | ||
| ): Promise<void> { | ||
| const params = new URLSearchParams(); | ||
| if (severity) { | ||
| params.set("severity", severity); | ||
| } | ||
|
|
||
| const response = await fetch(`/api/aria/stream-alerts?${params.toString()}`); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`Failed to get alerts: ${response.status}`); | ||
| } | ||
|
|
||
| // Handle WebSocket/SSE streaming | ||
| return response.body; |
There was a problem hiding this comment.
Return type mismatch in
getAriaAlerts
The function is typed as Promise<void> but returns response.body (a ReadableStream<Uint8Array> | null). TypeScript will accept this silently because the return value is untyped in the body, but any caller expecting void will receive a stream object they cannot use. The comment says "Handle WebSocket/SSE streaming" suggesting the intended signature should expose the stream.
| }, | ||
| ] | ||
| : [], | ||
| active_alerts: includeAlerts ? [] : [], |
There was a problem hiding this comment.
|
|
||
| const awareness = { | ||
| as_of: new Date().toISOString(), | ||
| focus_areas: [ | ||
| { | ||
| domain: "military", | ||
| region: "Eastern Europe", | ||
| topic: "Conflict Escalation", | ||
| intensity: 0.85, | ||
| status: "active", | ||
| }, | ||
| { | ||
| domain: "markets", | ||
| region: "Global", | ||
| topic: "Volatility Spike", | ||
| intensity: 0.65, | ||
| status: "monitoring", | ||
| }, | ||
| { | ||
| domain: "climate", | ||
| region: "Southeast Asia", | ||
| topic: "Severe Weather", | ||
| intensity: 0.75, | ||
| status: "developing", | ||
| }, | ||
| ], | ||
| recent_events: includeRecentEvents | ||
| ? [ | ||
| { | ||
| occurred_at: new Date(Date.now() - 3600000).toISOString(), | ||
| title: "Major Market Move", | ||
| description: "20% volatility spike in global markets", | ||
| affected_domains: ["markets", "economic"], | ||
| affected_regions: ["Global"], | ||
| impact_score: 0.8, | ||
| }, | ||
| ] | ||
| : [], | ||
| active_alerts: includeAlerts ? [] : [], | ||
| trending: includeTrending | ||
| ? [ | ||
| { | ||
| topic: "AI Regulation", | ||
| category: "policy", | ||
| momentum: 0.72, | ||
| mention_count: 1247, | ||
| related_topics: ["tech", "governance"], | ||
| }, | ||
| ] | ||
| : [], | ||
| system_confidence: 0.92, | ||
| data_freshness: 0.88, | ||
| sources_connected: 28, | ||
| }; |
There was a problem hiding this comment.
handleGetAwareness returns fully hardcoded data
The assembled context from assembleAriaContext is fetched but never used in the response — focus_areas, recent_events, trending, system_confidence, and sources_connected are all static literals. Clients relying on this endpoint for real awareness state will always receive the same canned response regardless of actual live data.
| if (domains.length > 0) { | ||
| for (const [key] of Object.entries(contextPromises)) { | ||
| if (!domains.includes(key)) { | ||
| delete contextPromises[key]; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const [ | ||
| markets, | ||
| military, | ||
| climate, | ||
| cyber, | ||
| economic, | ||
| maritime, | ||
| aviation, | ||
| news, | ||
| ] = await Promise.all(Object.values(contextPromises).map((p) => p.catch(() => null))); |
There was a problem hiding this comment.
Domain filter silently corrupts context destructuring
When the domains filter removes keys from contextPromises, Object.values() returns only the surviving entries in their original insertion order. The subsequent positional destructuring still expects all 8 values in the fixed order [markets, military, climate, cyber, economic, maritime, aviation, news]. So if a caller passes domains: ["cyber"], Object.values returns [cyberPromise], and the destructuring binds that result to markets while cyber gets undefined. The LLM prompt will then contain cyber data labelled as MARKETS and report CYBER: No data — silently feeding wrong information to the model for every filtered query.
The fix is to resolve the full set of promises unconditionally and then zero out keys the caller didn't request, or use named resolution (e.g. Promise.allSettled with a key-preserving map):
const results = await Promise.allSettled(
Object.entries(contextPromises).map(async ([k, p]) => [k, await p.catch(() => null)])
);
const resolved = Object.fromEntries(results.map(r => r.status === 'fulfilled' ? r.value : []));
| if (typingIndicator) typingIndicator.style.display = "none"; | ||
| this.saveConversation(userMessage); | ||
| } |
There was a problem hiding this comment.
ARIA responses never persisted to localStorage
After streaming completes, saveConversation(userMessage) is called but ariaMessage (which accumulated the full streamed reply) is never saved. On the next page load, loadConversation() will render a history of one-sided user messages with no ARIA responses. Since saveConversation appends to the stored array, ARIA's reply also needs to be saved once the stream is done:
if (typingIndicator) typingIndicator.style.display = "none";
this.saveConversation(userMessage);
if (ariaMessage) this.saveConversation(ariaMessage);
Aria implemented
Summary
Type of change
Affected areas
/api/*)Checklist
api/rss-proxy.jsallowlist (if adding feeds)npm run typecheck)Screenshots