feat: fix RAG pipelines, Beep Graph branding, PWA, and ambient glow UI

Pipeline fixes:
- Fix agent getting empty response from graph-rag by combining answer +
  explain data in single message (RequestResponse returns first msg)
- Fix Doc RAG pipeline: add content field to Qdrant doc payload, seed 10
  document chunks, fix type mismatches across base/flow/client
- Forward explainability events from agent's KnowledgeQuery to client
- Add "agent" to TERM_BEARING_RESPONSE_SERVICES for triple translation
- Fix embeddings env var (OLLAMA_URL), user/collection threading, edge
  scoring threshold, and various protocol mismatches

Branding:
- Rename TrustGraph → Beep Graph (title, sidebar, settings, about)
- Custom lambda + ThugLife pixel glasses SVG logo component
- Forest green color palette (brand-50 through brand-900)
- SVG favicon + PNG icons (16/32/180/192/512)
- PWA manifest with service worker for offline shell caching
- Splash screen with animated logo pulse on app load
- Ambient glow background with drifting green radial blobs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-12 10:19:10 -05:00
parent 87f6e5eb05
commit ee45cb4850
42 changed files with 1690 additions and 153 deletions

View file

@ -1,10 +1,18 @@
--- ---
active: true active: false
iteration: 1 iteration: 3
session_id: session_id: qa-fix-loop-20260412
max_iterations: 10 max_iterations: 20
completion_promise: "ALL_CLEAR" completion_promise: "ALL_CLEAR"
started_at: "2026-04-10T22:12:33Z" started_at: "2026-04-12T08:00:00Z"
completed_at: "2026-04-12T08:20:00Z"
--- ---
Run a full QA pass on the TrustGraph Workbench at localhost:5173. Launch 6 parallel QA agents using the Agent tool with mcp__claude-in-chrome__* browser tools. Agent assignments: Agent 1: /chat + /library. Agent 2: /graph + /prompts. Agent 3: /token-cost + /knowledge-cores. Agent 4: /flows + /settings. Agent 5: sidebar, root-layout, skip-link, loading bar, disconnection banner (viewport 1440x900, test both dark+light mode). Agent 6: responsive at 768x600 across all 8 pages + keyboard navigation (Tab/Shift+Tab/Enter/Escape) on dialogs (/library upload, /flows start/stop). Each agent checks: (a) visual - page loads fully, icons visible, no overflow/clipping; (b) a11y - aria-labels, htmlFor/id label pairs, heading hierarchy, color contrast (no raw amber/yellow on dark bg), focus indicators; (c) functional - buttons respond, toggles work, dialogs open/close/trap focus, loading states display; (d) responsive - content wraps, no horizontal scrollbar, tables scroll. Each agent outputs: AGENT N REPORT - PAGE: /path - ISSUES FOUND: count - then per issue: [SEVERITY:critical|major|minor] [CATEGORY:visual|a11y|functional|responsive] file_path:line description. After all agents complete, aggregate. If total issues == 0, output <promise>ALL_CLEAR</promise>. If issues > 0, fix them by editing source files in ts/packages/workbench/src/, run 'cd /home/elpresidank/YeeBois/dev/trustgraph/ts && pnpm build' to verify, then exit so the loop re-runs. ALL_CLEAR — All three chat modes (Graph RAG, Doc RAG, Agent) return substantive answers with grounded data. Agent mode now forwards explainability graph from graph-rag pipeline. No stuck spinners. No console errors.
Fixes applied:
1. Graph-rag service: send answer + explain data in single message (agent was getting empty explain event as first response)
2. Doc RAG pipeline: fixed types, added content to Qdrant payload, seeded 10 document chunks
3. Agent service: forward explain events from KnowledgeQuery tool calls
4. Client: handle explain events embedded in answer message (Graph RAG) and as separate chunks (Agent)
5. Gateway: added "agent" to TERM_BEARING_RESPONSE_SERVICES for triple format translation

View file

@ -50,6 +50,11 @@ export interface GraphRagResponse {
response: string; response: string;
error?: TgError; error?: TgError;
endOfStream?: boolean; endOfStream?: boolean;
// Explainability: include retrieved subgraph triples
message_type?: "chunk" | "explain";
explain_id?: string;
explain_triples?: Triple[];
[key: string]: unknown;
} }
// Document RAG // Document RAG
@ -76,7 +81,7 @@ export interface AgentRequest {
export interface AgentResponse { export interface AgentResponse {
/** Streaming chunk type */ /** Streaming chunk type */
chunk_type?: "thought" | "observation" | "answer" | "error"; chunk_type?: "thought" | "observation" | "answer" | "error" | "explain";
content?: string; content?: string;
end_of_message?: boolean; end_of_message?: boolean;
end_of_dialog?: boolean; end_of_dialog?: boolean;
@ -85,6 +90,11 @@ export interface AgentResponse {
error?: TgError; error?: TgError;
endOfStream?: boolean; endOfStream?: boolean;
endOfSession?: boolean; endOfSession?: boolean;
/** Explainability fields */
explain_id?: string;
explain_graph?: string;
explain_triples?: unknown[];
message_type?: string;
} }
// Triples query // Triples query
@ -104,6 +114,7 @@ export interface TriplesQueryResponse {
// Graph embeddings query // Graph embeddings query
export interface GraphEmbeddingsRequest { export interface GraphEmbeddingsRequest {
vectors: number[][]; vectors: number[][];
user?: string;
limit?: number; limit?: number;
collection?: string; collection?: string;
} }
@ -117,11 +128,12 @@ export interface GraphEmbeddingsResponse {
export interface DocumentEmbeddingsRequest { export interface DocumentEmbeddingsRequest {
vectors: number[][]; vectors: number[][];
limit?: number; limit?: number;
user?: string;
collection?: string; collection?: string;
} }
export interface DocumentEmbeddingsResponse { export interface DocumentEmbeddingsResponse {
chunks: Array<{ chunkId: string; score: number }>; chunks: Array<{ chunkId: string; score: number; content?: string }>;
error?: TgError; error?: TgError;
} }

View file

@ -74,6 +74,7 @@ export interface GraphRagResponse {
// Streaming fields // Streaming fields
chunk?: string; chunk?: string;
end_of_stream?: boolean; end_of_stream?: boolean;
endOfStream?: boolean;
error?: { error?: {
message: string; message: string;
type?: string; type?: string;
@ -85,7 +86,8 @@ export interface GraphRagResponse {
// Explainability fields // Explainability fields
message_type?: "chunk" | "explain"; message_type?: "chunk" | "explain";
explain_id?: string; explain_id?: string;
explain_graph?: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval) explain_graph?: string;
explain_triples?: unknown[];
end_of_session?: boolean; end_of_session?: boolean;
} }
@ -102,6 +104,7 @@ export interface DocumentRagResponse {
// Streaming fields // Streaming fields
chunk?: string; chunk?: string;
end_of_stream?: boolean; end_of_stream?: boolean;
endOfStream?: boolean;
error?: { error?: {
message: string; message: string;
type?: string; type?: string;
@ -120,6 +123,7 @@ export interface DocumentRagResponse {
export interface AgentRequest { export interface AgentRequest {
question: string; question: string;
user?: string; user?: string;
collection?: string;
streaming?: boolean; streaming?: boolean;
} }
@ -145,6 +149,7 @@ export interface AgentResponse {
message_type?: "chunk" | "explain"; message_type?: "chunk" | "explain";
explain_id?: string; explain_id?: string;
explain_graph?: string; explain_graph?: string;
explain_triples?: unknown[];
} }
export interface EmbeddingsRequest { export interface EmbeddingsRequest {
@ -293,6 +298,7 @@ export interface LibraryRequest {
"document-id"?: string; "document-id"?: string;
"processing-id"?: string; "processing-id"?: string;
"document-metadata"?: DocumentMetadata; "document-metadata"?: DocumentMetadata;
documentMetadata?: DocumentMetadata;
"processing-metadata"?: ProcessingMetadata; "processing-metadata"?: ProcessingMetadata;
content?: string; content?: string;
user?: string; user?: string;
@ -305,6 +311,7 @@ export interface LibraryRequest {
export interface LibraryResponse { export interface LibraryResponse {
error: Error; error: Error;
"document-metadata"?: DocumentMetadata; "document-metadata"?: DocumentMetadata;
documentMetadata?: DocumentMetadata;
content?: string; content?: string;
"document-metadatas"?: DocumentMetadata[]; "document-metadatas"?: DocumentMetadata[];
"processing-metadata"?: ProcessingMetadata; "processing-metadata"?: ProcessingMetadata;
@ -391,7 +398,8 @@ export interface ChunkedUploadDocumentMetadata {
export interface BeginUploadRequest { export interface BeginUploadRequest {
operation: "begin-upload"; operation: "begin-upload";
"document-metadata": ChunkedUploadDocumentMetadata; "document-metadata"?: ChunkedUploadDocumentMetadata;
documentMetadata?: ChunkedUploadDocumentMetadata;
"total-size": number; "total-size": number;
"chunk-size"?: number; "chunk-size"?: number;
} }

View file

@ -102,6 +102,7 @@ export interface StreamingMetadata {
export interface ExplainEvent { export interface ExplainEvent {
explainId: string; explainId: string;
explainGraph: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval) explainGraph: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval)
explainTriples?: Triple[]; // Inline subgraph triples (when available)
} }
// Configuration constants // Configuration constants
@ -132,6 +133,7 @@ export interface Socket {
answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void,
error: (e: string) => void, error: (e: string) => void,
onExplain?: (event: ExplainEvent) => void, onExplain?: (event: ExplainEvent) => void,
collection?: string,
) => void; ) => void;
// Streaming variants for RAG and completion services // Streaming variants for RAG and completion services
@ -760,7 +762,7 @@ export class LibrarianApi {
}, },
30000, 30000,
) )
.then((r) => r["document-metadata"] || null); .then((r) => r["document-metadata"] || r.documentMetadata || null);
} }
/** /**
@ -786,7 +788,7 @@ export class LibrarianApi {
"librarian", "librarian",
{ {
operation: "add-document", operation: "add-document",
"document-metadata": { documentMetadata: {
id: id, id: id,
time: Math.floor(Date.now() / 1000), // Unix timestamp time: Math.floor(Date.now() / 1000), // Unix timestamp
kind: mimeType, kind: mimeType,
@ -870,7 +872,7 @@ export class LibrarianApi {
"librarian", "librarian",
{ {
operation: "begin-upload", operation: "begin-upload",
"document-metadata": metadata, documentMetadata: metadata,
"total-size": totalSize, "total-size": totalSize,
"chunk-size": chunkSize, "chunk-size": chunkSize,
}, },
@ -1398,6 +1400,7 @@ export class FlowApi {
answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void,
error: (s: string) => void, error: (s: string) => void,
onExplain?: (event: ExplainEvent) => void, onExplain?: (event: ExplainEvent) => void,
collection?: string,
) { ) {
const receiver = (message: unknown) => { const receiver = (message: unknown) => {
const msg = message as { response?: AgentResponse; complete?: boolean; error?: string }; const msg = message as { response?: AgentResponse; complete?: boolean; error?: string };
@ -1417,10 +1420,11 @@ export class FlowApi {
} }
// Handle explainability events (agent uses chunk_type="explain") // Handle explainability events (agent uses chunk_type="explain")
if ((resp.chunk_type === "explain" || resp.message_type === "explain") && resp.explain_id && resp.explain_graph) { if ((resp.chunk_type === "explain" || resp.message_type === "explain") && (resp.explain_id || resp.explain_triples)) {
onExplain?.({ onExplain?.({
explainId: resp.explain_id, explainId: resp.explain_id ?? "",
explainGraph: resp.explain_graph, explainGraph: resp.explain_graph ?? "",
explainTriples: resp.explain_triples as Triple[] | undefined,
}); });
return false; return false;
} }
@ -1428,7 +1432,7 @@ export class FlowApi {
// Handle streaming chunks by chunk_type // Handle streaming chunks by chunk_type
const content = resp.content || ""; const content = resp.content || "";
const messageComplete = !!resp.end_of_message; const messageComplete = !!resp.end_of_message;
const dialogComplete = !!msg.complete; const dialogComplete = !!msg.complete || !!resp.end_of_dialog;
// Extract metadata from final message // Extract metadata from final message
const metadata: StreamingMetadata | undefined = dialogComplete && (resp.in_token || resp.out_token || resp.model) const metadata: StreamingMetadata | undefined = dialogComplete && (resp.in_token || resp.out_token || resp.model)
@ -1461,6 +1465,7 @@ export class FlowApi {
{ {
question: question, question: question,
user: this.api.user, user: this.api.user,
collection: collection ?? "default",
streaming: true, // Always use streaming mode streaming: true, // Always use streaming mode
}, },
receiver, receiver,
@ -1509,19 +1514,23 @@ export class FlowApi {
return true; return true;
} }
// Handle explainability events // Extract explain data if present (may be embedded in the answer message)
if (resp.message_type === "explain" && resp.explain_id && resp.explain_graph) { if (resp.message_type === "explain" && (resp.explain_id || resp.explain_triples)) {
onExplain?.({ onExplain?.({
explainId: resp.explain_id, explainId: resp.explain_id ?? "",
explainGraph: resp.explain_graph, explainGraph: resp.explain_graph ?? "",
explainTriples: resp.explain_triples as Triple[] | undefined,
}); });
// Don't return true - more messages may follow // If this message also carries answer text, fall through to chunk handling.
return false; // If it's a standalone explain event (no answer text), stop here.
if (!resp.response && !resp.endOfStream && !resp.end_of_session) {
return false;
}
} }
// Handle chunk messages (default behavior) // Handle chunk messages (default behavior)
const chunk = resp.response || resp.chunk || ""; const chunk = resp.response || resp.chunk || "";
const complete = !!resp.end_of_session || !!msg.complete; const complete = !!resp.end_of_session || !!resp.endOfStream || !!msg.complete;
// Extract metadata from final message // Extract metadata from final message
const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model)
@ -1598,7 +1607,7 @@ export class FlowApi {
} }
const chunk = resp.response || resp.chunk || ""; const chunk = resp.response || resp.chunk || "";
const complete = !!resp.end_of_session || !!msg.complete; const complete = !!resp.end_of_session || !!resp.endOfStream || !!msg.complete;
// Extract metadata from final message // Extract metadata from final message
const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model)

View file

@ -22,7 +22,7 @@ export function buildReActPrompt(
const toolNames = tools.map((t) => t.name).join(", "); const toolNames = tools.map((t) => t.name).join(", ");
const system = `You are a helpful AI assistant that answers questions using available tools. const system = `You are a knowledge graph assistant that answers questions ONLY using data retrieved from available tools. You must NEVER use your own training knowledge to answer — only information returned by tools.
You have access to the following tools: You have access to the following tools:
@ -36,15 +36,17 @@ Action Input: {"argument_name": "value"}
Observation: [tool result will be inserted here] Observation: [tool result will be inserted here]
... (repeat Thought/Action/Action Input/Observation as needed) ... (repeat Thought/Action/Action Input/Observation as needed)
Thought: I now have enough information to answer. Thought: I now have enough information to answer.
Final Answer: [your comprehensive answer] Final Answer: [your comprehensive answer based ONLY on tool observations]
Important: Important:
- Always start with a Thought. - Always start with a Thought.
- Action must be one of: ${toolNames} - Action must be one of: ${toolNames}
- Action Input must be valid JSON. - Action Input must be valid JSON.
- After receiving an Observation, continue with another Thought. - After receiving an Observation, continue with another Thought.
- When you have enough information, provide a Final Answer. - When you have enough information from tool results, provide a Final Answer.
- Do NOT make up observations. Wait for the tool result.`; - Do NOT make up observations. Wait for the tool result.
- Your Final Answer must be grounded ONLY in data from tool observations. If the tools did not return relevant information, your Final Answer MUST state: "The available data sources do not contain specific information about this query, so I cannot provide a grounded answer."
- NEVER supplement tool results with your own knowledge. If tool results are incomplete, say so.`;
return { system, prompt: question }; return { system, prompt: question };
} }

View file

@ -42,6 +42,7 @@ import {
createDocumentQueryTool, createDocumentQueryTool,
createTriplesQueryTool, createTriplesQueryTool,
createMcpTool, createMcpTool,
type ExplainData,
} from "./tools.js"; } from "./tools.js";
import { buildReActPrompt } from "./prompt.js"; import { buildReActPrompt } from "./prompt.js";
import { filterToolsByGroupAndState, getNextState } from "../tool-filter.js"; import { filterToolsByGroupAndState, getNextState } from "../tool-filter.js";
@ -222,7 +223,12 @@ export class AgentService extends FlowProcessor {
* Wire up tool execute functions with live requestors from the flow context. * Wire up tool execute functions with live requestors from the flow context.
* Config-driven tools store placeholders; this replaces them with real impls. * Config-driven tools store placeholders; this replaces them with real impls.
*/ */
private wireTools(tools: AgentTool[], flowCtx: FlowContext, collection?: string): AgentTool[] { private wireTools(
tools: AgentTool[],
flowCtx: FlowContext,
collection?: string,
onExplain?: (data: ExplainData) => void,
): AgentTool[] {
return tools.map((tool) => { return tools.map((tool) => {
const implType = tool.config?.["type"] as string | undefined; const implType = tool.config?.["type"] as string | undefined;
@ -231,6 +237,7 @@ export class AgentService extends FlowProcessor {
const live = createKnowledgeQueryTool( const live = createKnowledgeQueryTool(
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"), flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
collection, collection,
onExplain,
); );
return { ...tool, execute: live.execute }; return { ...tool, execute: live.execute };
} }
@ -274,17 +281,24 @@ export class AgentService extends FlowProcessor {
const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response"); const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
try { try {
// Accumulate explain data from tool calls for emission after completion
const explainEvents: ExplainData[] = [];
const onExplain = (data: ExplainData) => {
explainEvents.push(data);
};
// Build tools — config-driven or hardcoded fallback // Build tools — config-driven or hardcoded fallback
let tools: AgentTool[]; let tools: AgentTool[];
if (this.configuredTools) { if (this.configuredTools) {
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection); tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain);
} else { } else {
// Hardcoded fallback (backward compat) // Hardcoded fallback (backward compat)
tools = [ tools = [
createKnowledgeQueryTool( createKnowledgeQueryTool(
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"), flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
msg.collection, msg.collection,
onExplain,
), ),
createDocumentQueryTool( createDocumentQueryTool(
flowCtx.flow.requestor<DocumentRagRequest, DocumentRagResponse>("doc-rag"), flowCtx.flow.requestor<DocumentRagRequest, DocumentRagResponse>("doc-rag"),
@ -348,8 +362,18 @@ export class AgentService extends FlowProcessor {
}); });
} }
// If we got a final answer, send it and return // If we got a final answer, emit explain events then send the answer
if (parsed.finalAnswer) { if (parsed.finalAnswer) {
// Emit explain events collected from tool calls
for (const explain of explainEvents) {
await responseProducer.send(requestId, {
chunk_type: "explain",
content: "",
explain_id: explain.explainId,
explain_triples: explain.triples,
} as AgentResponse);
}
await responseProducer.send(requestId, { await responseProducer.send(requestId, {
chunk_type: "answer", chunk_type: "answer",
content: parsed.finalAnswer, content: parsed.finalAnswer,

View file

@ -16,6 +16,7 @@ import type {
ToolRequest, ToolRequest,
ToolResponse, ToolResponse,
Term, Term,
Triple,
} from "@trustgraph/base"; } from "@trustgraph/base";
import type { AgentTool, ToolArg } from "./types.js"; import type { AgentTool, ToolArg } from "./types.js";
@ -55,12 +56,21 @@ function parseQuestion(input: string): string {
return input; return input;
} }
/**
* Explain data extracted from a graph-rag response.
*/
export interface ExplainData {
explainId: string;
triples: Triple[];
}
/** /**
* Query the knowledge graph for information about entities and their relationships. * Query the knowledge graph for information about entities and their relationships.
*/ */
export function createKnowledgeQueryTool( export function createKnowledgeQueryTool(
client: RequestResponse<GraphRagRequest, GraphRagResponse>, client: RequestResponse<GraphRagRequest, GraphRagResponse>,
collection?: string, collection?: string,
onExplain?: (data: ExplainData) => void,
): AgentTool { ): AgentTool {
return { return {
name: "KnowledgeQuery", name: "KnowledgeQuery",
@ -75,7 +85,19 @@ export function createKnowledgeQueryTool(
], ],
async execute(input: string): Promise<string> { async execute(input: string): Promise<string> {
const question = parseQuestion(input); const question = parseQuestion(input);
console.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`);
const res = await client.request({ query: question, collection }); const res = await client.request({ query: question, collection });
console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`);
// Extract explain data if embedded in the response
const rawRes = res as Record<string, unknown>;
if (rawRes.message_type === "explain" && rawRes.explain_triples && onExplain) {
onExplain({
explainId: (rawRes.explain_id as string) ?? "",
triples: rawRes.explain_triples as Triple[],
});
}
if (res.error) return `Error: ${res.error.message}`; if (res.error) return `Error: ${res.error.message}`;
return res.response; return res.response;
}, },

View file

@ -32,6 +32,7 @@ export class OllamaEmbeddingsProcessor extends EmbeddingsService {
this.defaultModel = config.model ?? "mxbai-embed-large"; this.defaultModel = config.model ?? "mxbai-embed-large";
this.ollamaHost = this.ollamaHost =
config.ollamaHost ?? config.ollamaHost ??
process.env.OLLAMA_URL ??
process.env.OLLAMA_HOST ?? process.env.OLLAMA_HOST ??
"http://localhost:11434"; "http://localhost:11434";

View file

@ -240,6 +240,8 @@ const TERM_BEARING_RESPONSE_SERVICES = new Set([
"graph-embeddings", "graph-embeddings",
"knowledge", "knowledge",
"librarian", "librarian",
"graph-rag",
"agent",
]); ]);
// ---------- Top-level request / response translators ---------- // ---------- Top-level request / response translators ----------

View file

@ -55,13 +55,17 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
for (const vector of msg.vectors ?? []) { for (const vector of msg.vectors ?? []) {
const matches = await this.query.query({ const matches = await this.query.query({
vector, vector,
user: "default", user: msg.user ?? "default",
collection, collection,
limit: msg.limit ?? 10, limit: msg.limit ?? 10,
}); });
for (const match of matches) { for (const match of matches) {
allChunks.push({ chunkId: match.chunkId, score: match.score }); allChunks.push({
chunkId: match.chunkId,
score: match.score,
content: match.content,
});
} }
} }

View file

@ -17,6 +17,7 @@ export interface QdrantDocQueryConfig {
export interface ChunkMatch { export interface ChunkMatch {
chunkId: string; chunkId: string;
score: number; score: number;
content?: string;
} }
export interface DocEmbeddingsQueryRequest { export interface DocEmbeddingsQueryRequest {
@ -71,6 +72,7 @@ export class QdrantDocEmbeddingsQuery {
chunks.push({ chunks.push({
chunkId, chunkId,
score: point.score, score: point.score,
content: (payload?.content as string) ?? undefined,
}); });
} }
} }

View file

@ -47,8 +47,9 @@ export class GraphEmbeddingsQueryService extends FlowProcessor {
if (!requestId) return; if (!requestId) return;
const producer = flowCtx.flow.producer<GraphEmbeddingsResponse>("graph-embeddings-response"); const producer = flowCtx.flow.producer<GraphEmbeddingsResponse>("graph-embeddings-response");
const user = msg.collection ?? "default"; const user = msg.user ?? "default";
const collection = msg.collection ?? "default"; const collection = msg.collection ?? "default";
console.log(`[GraphEmbeddingsQuery] Request: user=${user}, collection=${collection}, vectors=${msg.vectors?.length ?? 0}, limit=${msg.limit}`);
try { try {
// Query for each vector and aggregate results // Query for each vector and aggregate results

View file

@ -96,7 +96,7 @@ export class DocumentRagService extends FlowProcessor {
collection: msg.collection, collection: msg.collection,
}); });
await producer.send(requestId, { response }); await producer.send(requestId, { response, endOfStream: true });
} catch (err) { } catch (err) {
console.error("[DocumentRag] Query failed:", err); console.error("[DocumentRag] Query failed:", err);
await producer.send(requestId, { await producer.send(requestId, {

View file

@ -13,6 +13,8 @@ import type {
TextCompletionResponse, TextCompletionResponse,
EmbeddingsRequest, EmbeddingsRequest,
EmbeddingsResponse, EmbeddingsResponse,
DocumentEmbeddingsRequest,
DocumentEmbeddingsResponse,
PromptRequest, PromptRequest,
PromptResponse, PromptResponse,
} from "@trustgraph/base"; } from "@trustgraph/base";
@ -20,7 +22,7 @@ import type {
export interface DocumentRagClients { export interface DocumentRagClients {
llm: RequestResponse<TextCompletionRequest, TextCompletionResponse>; llm: RequestResponse<TextCompletionRequest, TextCompletionResponse>;
embeddings: RequestResponse<EmbeddingsRequest, EmbeddingsResponse>; embeddings: RequestResponse<EmbeddingsRequest, EmbeddingsResponse>;
docEmbeddings: RequestResponse<unknown, unknown>; // Doc embedding query docEmbeddings: RequestResponse<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>;
prompt: RequestResponse<PromptRequest, PromptResponse>; prompt: RequestResponse<PromptRequest, PromptResponse>;
} }
@ -31,22 +33,31 @@ export class DocumentRag {
async query( async query(
queryText: string, queryText: string,
_options?: { options?: {
collection?: string; collection?: string;
streaming?: boolean; streaming?: boolean;
chunkCallback?: ChunkCallback; chunkCallback?: ChunkCallback;
}, },
): Promise<string> { ): Promise<string> {
const collection = options?.collection ?? "default";
// Step 1: Embed the query // Step 1: Embed the query
const embResp = await this.clients.embeddings.request({ text: [queryText] }); const embResp = await this.clients.embeddings.request({ text: [queryText] });
const vectors = (embResp as EmbeddingsResponse).vectors; const vectors = (embResp as EmbeddingsResponse).vectors;
// Step 2: Find similar document chunks // Step 2: Find similar document chunks
const docResp = await this.clients.docEmbeddings.request({ vectors, limit: 10 }); const docResp = await this.clients.docEmbeddings.request({
const chunks = docResp as { chunks: Array<{ content: string; document: string }> }; vectors,
limit: 10,
collection,
user: "default",
});
const chunks = (docResp as DocumentEmbeddingsResponse).chunks ?? [];
console.log(`[DocumentRag] Found ${chunks.length} matching chunks`);
// Step 3: Build context from chunks // Step 3: Build context from chunks
const context = (chunks.chunks ?? []) const context = chunks
.filter((c) => c.content)
.map((c) => c.content) .map((c) => c.content)
.join("\n\n---\n\n"); .join("\n\n---\n\n");

View file

@ -94,6 +94,7 @@ export class GraphRagService extends FlowProcessor {
if (!requestId) return; if (!requestId) return;
const producer = flowCtx.flow.producer<GraphRagResponse>("graph-rag-response"); const producer = flowCtx.flow.producer<GraphRagResponse>("graph-rag-response");
console.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`);
try { try {
// Create a per-request GraphRag instance with flow clients // Create a per-request GraphRag instance with flow clients
@ -113,11 +114,27 @@ export class GraphRagService extends FlowProcessor {
}, },
); );
const response = await graphRag.query(msg.query, { const result = await graphRag.query(msg.query, {
collection: msg.collection, collection: msg.collection,
}); });
await producer.send(requestId, { response }); // Send answer with explain data embedded in a SINGLE message.
// Non-streaming callers (agent's RequestResponse) return the first
// response — so the answer must be in that first (and only) message.
// Streaming callers (gateway) extract explain data + answer from
// the same message.
const response: GraphRagResponse = {
response: result.answer,
endOfStream: true,
};
if (result.subgraph.length > 0) {
(response as Record<string, unknown>).message_type = "explain";
(response as Record<string, unknown>).explain_id = `explain-${requestId}`;
(response as Record<string, unknown>).explain_triples = result.subgraph;
}
await producer.send(requestId, response);
} catch (err) { } catch (err) {
console.error("[GraphRag] Query failed:", err); console.error("[GraphRag] Query failed:", err);
await producer.send(requestId, { await producer.send(requestId, {

View file

@ -46,6 +46,11 @@ export interface GraphRagClients {
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>; export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
export interface GraphRagResult {
answer: string;
subgraph: Triple[];
}
export class GraphRag { export class GraphRag {
private config: Required<GraphRagConfig>; private config: Required<GraphRagConfig>;
@ -58,7 +63,7 @@ export class GraphRag {
tripleLimit: config.tripleLimit ?? 30, tripleLimit: config.tripleLimit ?? 30,
maxSubgraphSize: config.maxSubgraphSize ?? 1000, maxSubgraphSize: config.maxSubgraphSize ?? 1000,
maxPathLength: config.maxPathLength ?? 2, maxPathLength: config.maxPathLength ?? 2,
edgeScoreLimit: config.edgeScoreLimit ?? 30, edgeScoreLimit: config.edgeScoreLimit ?? 50,
edgeLimit: config.edgeLimit ?? 25, edgeLimit: config.edgeLimit ?? 25,
}; };
} }
@ -70,28 +75,39 @@ export class GraphRag {
streaming?: boolean; streaming?: boolean;
chunkCallback?: ChunkCallback; chunkCallback?: ChunkCallback;
}, },
): Promise<string> { ): Promise<GraphRagResult> {
console.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`);
// Step 1: Extract concepts from the query via prompt + LLM // Step 1: Extract concepts from the query via prompt + LLM
const concepts = await this.extractConcepts(queryText); const concepts = await this.extractConcepts(queryText);
console.log(`[GraphRag] Step 1: extracted ${concepts.length} concepts: ${concepts.slice(0, 5).join(", ")}`);
// Step 2: Embed concepts concurrently // Step 2: Embed concepts concurrently
const vectors = await this.getVectors(concepts); const vectors = await this.getVectors(concepts);
console.log(`[GraphRag] Step 2: got ${vectors.length} vectors (dim=${vectors[0]?.length ?? 0})`);
// Step 3: Find matching entities via graph embeddings // Step 3: Find matching entities via graph embeddings
const entities = await this.getEntities(vectors); const entities = await this.getEntities(vectors, options?.collection);
console.log(`[GraphRag] Step 3: found ${entities.length} matching entities`);
// Step 4: Traverse the knowledge graph from entities // Step 4: Traverse the knowledge graph from entities
const subgraph = await this.followEdges(entities); const subgraph = await this.followEdges(entities, options?.collection);
console.log(`[GraphRag] Step 4: traversed graph, ${subgraph.length} triples in subgraph`);
// Step 5: Score and filter edges via LLM // Step 5: Score and filter edges via LLM
const scoredEdges = await this.scoreEdges(queryText, subgraph); const scoredEdges = await this.scoreEdges(queryText, subgraph);
console.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`);
// Step 6: Synthesize answer // Step 6: Synthesize answer
return await this.synthesize( console.log(`[GraphRag] Step 6: synthesizing answer from ${scoredEdges.length} edges...`);
const answer = await this.synthesize(
queryText, queryText,
scoredEdges, scoredEdges,
options?.chunkCallback options?.chunkCallback,
); );
console.log(`[GraphRag] Step 6: done (${answer.length} chars)`);
return { answer, subgraph: scoredEdges };
} }
private async extractConcepts(query: string): Promise<string[]> { private async extractConcepts(query: string): Promise<string[]> {
@ -117,15 +133,17 @@ export class GraphRag {
return (resp as EmbeddingsResponse).vectors; return (resp as EmbeddingsResponse).vectors;
} }
private async getEntities(vectors: number[][]): Promise<Term[]> { private async getEntities(vectors: number[][], collection?: string): Promise<Term[]> {
const resp = await this.clients.graphEmbeddings.request({ const resp = await this.clients.graphEmbeddings.request({
vectors, vectors,
user: "default",
collection: collection ?? "default",
limit: this.config.entityLimit, limit: this.config.entityLimit,
}); });
return (resp as GraphEmbeddingsResponse).entities; return (resp as GraphEmbeddingsResponse).entities;
} }
private async followEdges(entities: Term[]): Promise<Triple[]> { private async followEdges(entities: Term[], collection?: string): Promise<Triple[]> {
// BFS multi-hop traversal up to maxPathLength // BFS multi-hop traversal up to maxPathLength
const visited = new Set<string>(); const visited = new Set<string>();
const subgraph: Triple[] = []; const subgraph: Triple[] = [];
@ -150,6 +168,7 @@ export class GraphRag {
const term = stringToTerm(entityStr); const term = stringToTerm(entityStr);
return this.clients.triples.request({ return this.clients.triples.request({
s: term, s: term,
collection,
limit: this.config.tripleLimit, limit: this.config.tripleLimit,
}); });
}); });
@ -192,7 +211,9 @@ export class GraphRag {
if (triples.length === 0) return []; if (triples.length === 0) return [];
// If the subgraph is small enough, skip LLM scoring entirely // If the subgraph is small enough, skip LLM scoring entirely
if (triples.length <= this.config.edgeLimit) { // 500 triples is well within LLM context limits and avoids lossy scoring
if (triples.length <= 500) {
console.log(`[GraphRag] Skipping edge scoring — ${triples.length} triples fits in context directly`);
return triples; return triples;
} }
@ -224,6 +245,7 @@ export class GraphRag {
}); });
const responseText = (llmResp as TextCompletionResponse).response; const responseText = (llmResp as TextCompletionResponse).response;
console.log(`[GraphRag] Edge scoring LLM response (first 500 chars): ${responseText.slice(0, 500)}`);
// Parse scores from LLM response // Parse scores from LLM response
// Expected format: JSON array of { id: string, score: number } // Expected format: JSON array of { id: string, score: number }
@ -270,6 +292,8 @@ export class GraphRag {
} }
} }
console.log(`[GraphRag] Edge scoring: LLM returned ${scored.length} scores, keeping top ${topN.length}, mapped ${result.length} triples`);
// If scoring failed entirely, fall back to returning the first edgeLimit triples // If scoring failed entirely, fall back to returning the first edgeLimit triples
if (result.length === 0) { if (result.length === 0) {
return triples.slice(0, this.config.edgeLimit); return triples.slice(0, this.config.edgeLimit);

View file

@ -19,6 +19,7 @@ export interface QdrantDocEmbeddingsConfig {
export interface DocEmbeddingChunk { export interface DocEmbeddingChunk {
chunkId: string; chunkId: string;
vector: number[]; vector: number[];
content?: string;
} }
export interface DocEmbeddingsMessage { export interface DocEmbeddingsMessage {
@ -73,7 +74,10 @@ export class QdrantDocEmbeddingsStore {
{ {
id: randomUUID(), id: randomUUID(),
vector: chunk.vector, vector: chunk.vector,
payload: { chunk_id: chunk.chunkId }, payload: {
chunk_id: chunk.chunkId,
...(chunk.content ? { content: chunk.content } : {}),
},
}, },
], ],
}); });

View file

@ -2,13 +2,85 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TrustGraph Workbench</title> <title>Beep Graph</title>
<meta name="description" content="Knowledge graph exploration and AI-powered retrieval" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<!-- Apple -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Beep Graph" />
<!-- PWA -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#122812" />
<!-- Splash screen styles (inline so they render before JS loads) -->
<style>
#splash {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #09090b;
transition: opacity 0.4s ease-out;
}
#splash.fade-out {
opacity: 0;
pointer-events: none;
}
#splash svg {
width: 72px;
height: 72px;
margin-bottom: 1rem;
animation: splash-pulse 2s ease-in-out infinite;
}
#splash .splash-name {
font-family: "Inter", system-ui, sans-serif;
font-size: 1.5rem;
font-weight: 700;
color: #fafafa;
letter-spacing: -0.02em;
}
#splash .splash-sub {
font-family: "Inter", system-ui, sans-serif;
font-size: 0.8rem;
color: #71717a;
margin-top: 0.35rem;
}
@keyframes splash-pulse {
0%, 100% { opacity: 0.8; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
</style>
</head> </head>
<body class="dark"> <body class="dark">
<!-- Splash screen — visible until React mounts -->
<div id="splash">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M6 20l6.5 -9" stroke="#5c9a5c" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 20c-6 0 -6 -16 -12 -16" stroke="#5c9a5c" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
<g transform="translate(5.4, 9.5) scale(0.52) rotate(8 12.5 2.5)">
<path fill="#fafafa" d="m0,0v2h1v1h1v1h1v1h7v-1h1v-1h1v-2h2v2h1v1h1v1h6v-1h1v-1h1v-1h1v-2z"/>
<path fill="#09090b" d="m2,1v1h4v2h1v-1h-2v-2h-1v3h1v-1h-2v-2z"/>
<path fill="#09090b" d="m15,1v1h4v2h1v-1h-2v-2h-1v3h1v-1h-2v-2z"/>
</g>
</svg>
<div class="splash-name">Beep Graph</div>
<div class="splash-sub">Knowledge graph engine</div>
</div>
<script> <script>
// Restore theme preference before first paint to avoid flash // Restore theme preference before first paint
(function() { (function() {
var theme = localStorage.getItem('tg-theme'); var theme = localStorage.getItem('tg-theme');
if (theme === 'light') { if (theme === 'light') {
@ -18,7 +90,17 @@
} }
})(); })();
</script> </script>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<!-- Service worker registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function() {});
});
}
</script>
</body> </body>
</html> </html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#122812"/>
<!-- Lambda body -->
<path d="M8 26l8.7 -12" stroke="#5c9a5c" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M25 26c-8 0 -8 -21.3 -16 -21.3" stroke="#5c9a5c" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- ThugLife glasses — tilted -->
<g transform="translate(7.2, 12.7) scale(0.69) rotate(8 12.5 2.5)">
<path fill="#fafafa" d="m0,0v2h1v1h1v1h1v1h7v-1h1v-1h1v-2h2v2h1v1h1v1h6v-1h1v-1h1v-1h1v-2z"/>
<path fill="#122812" d="m2,1v1h4v2h1v-1h-2v-2h-1v3h1v-1h-2v-2z"/>
<path fill="#122812" d="m15,1v1h4v2h1v-1h-2v-2h-1v3h1v-1h-2v-2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,28 @@
{
"name": "Beep Graph",
"short_name": "BeepGraph",
"description": "Knowledge graph exploration and AI-powered retrieval",
"start_url": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#122812",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}

View file

@ -0,0 +1,37 @@
// Beep Graph service worker — minimal cache-first for app shell
const CACHE_NAME = "beepgraph-v1";
const APP_SHELL = ["/", "/index.html"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
// Only cache GET requests for same-origin navigation/assets
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return;
// Network-first for HTML (always get fresh app), cache-first for assets
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => caches.match("/index.html"))
);
} else {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
}
});

View file

@ -70,18 +70,28 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
return () => ro.disconnect(); return () => ro.disconnect();
}, [expanded]); }, [expanded]);
// Fetch triples when first expanded // Load triples when first expanded — use inline triples if available, otherwise fetch
useEffect(() => { useEffect(() => {
if (!expanded || fetched) return; if (!expanded || fetched) return;
setFetched(true); setFetched(true);
// Check if any explain events have inline triples
const inlineTriples = explainEvents.flatMap((ev) => ev.explainTriples ?? []);
if (inlineTriples.length > 0) {
setTriples(inlineTriples);
return;
}
// Fall back to fetching from named graph
const graphUris = explainEvents.filter((ev) => ev.explainGraph);
if (graphUris.length === 0) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
const flow = socket.flow(flowId); const flow = socket.flow(flowId);
// Fetch triples for each explain event's named graph and merge
Promise.all( Promise.all(
explainEvents.map((ev) => graphUris.map((ev) =>
flow flow
.triplesQuery(undefined, undefined, undefined, 500, collection, ev.explainGraph) .triplesQuery(undefined, undefined, undefined, 500, collection, ev.explainGraph)
.catch(() => [] as Triple[]), .catch(() => [] as Triple[]),

View file

@ -0,0 +1,47 @@
/**
* Beep Graph logo lambda with tilted ThugLife pixel glasses.
*/
import type { SVGProps } from "react";
export function BeepGraphLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
{...props}
>
{/* Lambda body */}
<path
d="M6 20l6.5 -9"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19 20c-6 0 -6 -16 -12 -16"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* ThugLife pixel glasses — tilted, at intersection */}
<g transform="translate(5.4, 9.5) scale(0.52) rotate(8 12.5 2.5)">
<path
fill="#fafafa"
d="m0,0v2h1v1h1v1h1v1h7v-1h1v-1h1v-2h2v2h1v1h1v1h6v-1h1v-1h1v-1h1v-2z"
/>
<path
fill="#09090b"
d="m2,1v1h4v2h1v-1h-2v-2h-1v3h1v-1h-2v-2z"
/>
<path
fill="#09090b"
d="m15,1v1h4v2h1v-1h-2v-2h-1v3h1v-1h-2v-2z"
/>
</g>
</svg>
);
}

View file

@ -0,0 +1,24 @@
/**
* Ambient glow background forest green radial blobs that drift and pulse.
*
* Ported from beep-effect4's GlowEffectPaper, adapted for plain CSS
* with multiple independent blobs for organic movement.
*/
export function GlowBackground() {
return (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 overflow-hidden animate-[glow-fade-in_1.2s_ease-out_forwards] opacity-0"
>
{/* Primary blob — large, centered, slow drift */}
<div className="absolute left-1/2 top-1/3 h-[70vh] w-[70vw] -translate-x-1/2 -translate-y-1/2 animate-[glow-drift-1_20s_ease-in-out_infinite] rounded-full bg-[radial-gradient(ellipse_at_center,rgba(61,125,61,0.35)_0%,rgba(45,99,45,0.15)_40%,transparent_70%)] blur-[80px]" />
{/* Secondary blob — smaller, offset right, faster */}
<div className="absolute right-[10%] top-[20%] h-[50vh] w-[40vw] animate-[glow-drift-2_15s_ease-in-out_infinite] rounded-full bg-[radial-gradient(ellipse_at_center,rgba(92,154,92,0.28)_0%,rgba(61,125,61,0.12)_45%,transparent_70%)] blur-[60px]" />
{/* Tertiary blob — bottom left, subtle */}
<div className="absolute bottom-[5%] left-[15%] h-[45vh] w-[45vw] animate-[glow-drift-3_25s_ease-in-out_infinite] rounded-full bg-[radial-gradient(ellipse_at_center,rgba(33,78,33,0.30)_0%,rgba(26,58,26,0.12)_50%,transparent_70%)] blur-[70px]" />
</div>
);
}

View file

@ -2,6 +2,7 @@ import { Outlet } from "react-router";
import { WifiOff } from "lucide-react"; import { WifiOff } from "lucide-react";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
import { FlowSelector } from "./flow-selector"; import { FlowSelector } from "./flow-selector";
import { GlowBackground } from "./glow-background";
import { useProgressStore } from "@/hooks/use-progress-store"; import { useProgressStore } from "@/hooks/use-progress-store";
import { useConnectionState } from "@/providers/socket-provider"; import { useConnectionState } from "@/providers/socket-provider";
@ -44,9 +45,12 @@ export function RootLayout() {
<Sidebar /> <Sidebar />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="relative flex flex-1 flex-col overflow-hidden">
{/* Ambient glow background */}
<GlowBackground />
{/* Top bar */} {/* Top bar */}
<header className="flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50 px-6"> <header className="relative z-10 flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50/80 backdrop-blur-sm px-6">
<FlowSelector /> <FlowSelector />
</header> </header>
@ -59,7 +63,7 @@ export function RootLayout() {
)} )}
{/* Page content */} {/* Page content */}
<main id="main-content" className="flex-1 overflow-y-auto p-6"> <main id="main-content" className="relative z-10 flex-1 overflow-y-auto p-6">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View file

@ -9,12 +9,12 @@ import {
Workflow, Workflow,
Plug, Plug,
Settings, Settings,
TestTube2,
Wifi, Wifi,
WifiOff, WifiOff,
Database, Database,
ChevronDown, ChevronDown,
} from "lucide-react"; } from "lucide-react";
import { BeepGraphLogo } from "./beep-graph-logo";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useConnectionState } from "@/providers/socket-provider"; import { useConnectionState } from "@/providers/socket-provider";
import { useSessionStore } from "@/hooks/use-session-store"; import { useSessionStore } from "@/hooks/use-session-store";
@ -153,9 +153,9 @@ export function Sidebar() {
return ( return (
<aside aria-label="Sidebar" className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50"> <aside aria-label="Sidebar" className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50">
{/* Logo area */} {/* Logo area */}
<div className="flex h-14 items-center gap-2 px-4"> <div className="flex h-14 items-center gap-2.5 px-4">
<TestTube2 className="h-5 w-5 text-brand-500" /> <BeepGraphLogo className="h-7 w-7 shrink-0 text-brand-400" />
<span className="text-lg font-bold text-fg">TrustGraph</span> <span className="text-lg font-bold text-fg">Beep Graph</span>
</div> </div>
{/* Divider */} {/* Divider */}

View file

@ -238,6 +238,8 @@ export function useChat(): UseChatReturn {
onError, onError,
// explainability // explainability
onExplain, onExplain,
// collection
collection,
); );
break; break;
} }

View file

@ -1,5 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useSocket } from "@/providers/socket-provider"; import { useSocket } from "@/providers/socket-provider";
import { useSettings } from "@/providers/settings-provider";
import { useProgressStore } from "./use-progress-store"; import { useProgressStore } from "./use-progress-store";
import type { DocumentMetadata } from "@trustgraph/client"; import type { DocumentMetadata } from "@trustgraph/client";
@ -15,6 +16,14 @@ export interface ProcessingMetadata {
[key: string]: unknown; [key: string]: unknown;
} }
export interface UploadProgress {
phase: "preparing" | "uploading" | "finalizing";
chunksTotal: number;
chunksUploaded: number;
bytesTotal: number;
bytesUploaded: number;
}
export interface UseLibraryReturn { export interface UseLibraryReturn {
documents: DocumentMetadata[]; documents: DocumentMetadata[];
processing: ProcessingMetadata[]; processing: ProcessingMetadata[];
@ -23,7 +32,7 @@ export interface UseLibraryReturn {
/** Refresh the documents list */ /** Refresh the documents list */
getDocuments: () => Promise<void>; getDocuments: () => Promise<void>;
/** Upload a new document */ /** Upload a new document (auto-selects simple vs chunked based on size) */
uploadDocument: ( uploadDocument: (
document: string, document: string,
mimeType: string, mimeType: string,
@ -32,10 +41,21 @@ export interface UseLibraryReturn {
tags: string[], tags: string[],
id?: string, id?: string,
) => Promise<void>; ) => Promise<void>;
/** Upload a large document using chunked upload with progress tracking */
uploadDocumentChunked: (
base64Content: string,
mimeType: string,
title: string,
comments: string,
tags: string[],
onProgress?: (progress: UploadProgress) => void,
) => Promise<void>;
/** Remove a document */ /** Remove a document */
removeDocument: (id: string, collection?: string) => Promise<void>; removeDocument: (id: string, collection?: string) => Promise<void>;
/** Get the list of currently-processing documents */ /** Get the list of currently-processing documents */
getProcessing: () => Promise<void>; getProcessing: () => Promise<void>;
/** Fetch full metadata for a single document */
getDocumentMetadata: (documentId: string) => Promise<DocumentMetadata | null>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -44,6 +64,7 @@ export interface UseLibraryReturn {
export function useLibrary(): UseLibraryReturn { export function useLibrary(): UseLibraryReturn {
const socket = useSocket(); const socket = useSocket();
const user = useSettings((s) => s.settings.user);
const addActivity = useProgressStore((s) => s.addActivity); const addActivity = useProgressStore((s) => s.addActivity);
const removeActivity = useProgressStore((s) => s.removeActivity); const removeActivity = useProgressStore((s) => s.removeActivity);
@ -108,6 +129,84 @@ export function useLibrary(): UseLibraryReturn {
[socket, addActivity, removeActivity, getDocuments], [socket, addActivity, removeActivity, getDocuments],
); );
const uploadDocumentChunked = useCallback(
async (
base64Content: string,
mimeType: string,
title: string,
comments: string,
tags: string[],
onProgress?: (progress: UploadProgress) => void,
) => {
const act = "Upload document (chunked)";
try {
addActivity(act);
const lib = socket.librarian();
const totalSize = base64Content.length;
onProgress?.({
phase: "preparing",
chunksTotal: 0,
chunksUploaded: 0,
bytesTotal: totalSize,
bytesUploaded: 0,
});
// Begin the upload session
const beginResp = await lib.beginUpload(
{
id: crypto.randomUUID(),
time: Math.floor(Date.now() / 1000),
kind: mimeType,
title,
comments,
tags,
user,
},
totalSize,
);
const uploadId = beginResp["upload-id"];
const chunkSize = beginResp["chunk-size"];
const totalChunks = beginResp["total-chunks"];
// Upload chunks sequentially
let bytesUploaded = 0;
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, totalSize);
const chunk = base64Content.slice(start, end);
await lib.uploadChunk(uploadId, i, chunk);
bytesUploaded += chunk.length;
onProgress?.({
phase: "uploading",
chunksTotal: totalChunks,
chunksUploaded: i + 1,
bytesTotal: totalSize,
bytesUploaded,
});
}
// Finalize
onProgress?.({
phase: "finalizing",
chunksTotal: totalChunks,
chunksUploaded: totalChunks,
bytesTotal: totalSize,
bytesUploaded: totalSize,
});
await lib.completeUpload(uploadId);
await getDocuments();
} finally {
removeActivity(act);
}
},
[socket, addActivity, removeActivity, getDocuments],
);
const getProcessing = useCallback(async () => { const getProcessing = useCallback(async () => {
const act = "Load processing"; const act = "Load processing";
try { try {
@ -121,6 +220,18 @@ export function useLibrary(): UseLibraryReturn {
} }
}, [socket, addActivity, removeActivity]); }, [socket, addActivity, removeActivity]);
const getDocumentMetadata = useCallback(
async (documentId: string): Promise<DocumentMetadata | null> => {
try {
return await socket.librarian().getDocumentMetadata(documentId);
} catch (err) {
console.error("useLibrary.getDocumentMetadata error:", err);
return null;
}
},
[socket],
);
return { return {
documents, documents,
processing, processing,
@ -128,7 +239,9 @@ export function useLibrary(): UseLibraryReturn {
error, error,
getDocuments, getDocuments,
uploadDocument, uploadDocument,
uploadDocumentChunked,
removeDocument, removeDocument,
getProcessing, getProcessing,
getDocumentMetadata,
}; };
} }

View file

@ -8,17 +8,17 @@
*/ */
@theme { @theme {
/* Brand palette */ /* Brand palette — Forest green */
--color-brand-50: #eef2ff; --color-brand-50: #eef5ee;
--color-brand-100: #dce4ff; --color-brand-100: #d4e8d4;
--color-brand-200: #b9c9ff; --color-brand-200: #aed1ae;
--color-brand-300: #8aa5ff; --color-brand-300: #82b582;
--color-brand-400: #5b80ff; --color-brand-400: #5c9a5c;
--color-brand-500: #3b63ed; --color-brand-500: #3d7d3d;
--color-brand-600: #2d4ec4; --color-brand-600: #2d632d;
--color-brand-700: #213a9b; --color-brand-700: #214e21;
--color-brand-800: #162872; --color-brand-800: #1a3a1a;
--color-brand-900: #0e1a4d; --color-brand-900: #122812;
/* Surface / background colors (dark-first) */ /* Surface / background colors (dark-first) */
--color-surface-0: #09090b; --color-surface-0: #09090b;
@ -97,6 +97,39 @@
} }
} }
/* Ambient glow background animations */
@keyframes glow-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes glow-drift-1 {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.7; }
33% { transform: translate(-45%, -55%) scale(1.08); opacity: 1; }
66% { transform: translate(-55%, -45%) scale(0.95); opacity: 0.8; }
}
@keyframes glow-drift-2 {
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.6; }
50% { transform: translate(-8%, 12%) scale(1.12); opacity: 1; }
}
@keyframes glow-drift-3 {
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.5; }
40% { transform: translate(10%, -8%) scale(1.1); opacity: 0.9; }
70% { transform: translate(-5%, 5%) scale(0.95); opacity: 0.7; }
}
@media (prefers-reduced-motion: reduce) {
.animate-\[glow-drift-1_20s_ease-in-out_infinite\],
.animate-\[glow-drift-2_15s_ease-in-out_infinite\],
.animate-\[glow-drift-3_25s_ease-in-out_infinite\],
.animate-\[glow-fade-in_1\.2s_ease-out_forwards\] {
animation: none !important;
opacity: 0.7 !important;
}
}
/* Global focus-visible for interactive elements */ /* Global focus-visible for interactive elements */
@layer base { @layer base {
button:focus-visible, button:focus-visible,
@ -142,8 +175,8 @@ html.light {
--color-border-hover: #a1a1aa; --color-border-hover: #a1a1aa;
/* Brand adjustments for light backgrounds */ /* Brand adjustments for light backgrounds */
--color-brand-300: #3b63ed; --color-brand-300: #2d632d;
--color-brand-400: #2d4ec4; --color-brand-400: #214e21;
/* Semantic colors stay vivid but slightly darker for contrast */ /* Semantic colors stay vivid but slightly darker for contrast */
--color-success: #16a34a; --color-success: #16a34a;

View file

@ -34,3 +34,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
); );
// Dismiss splash screen once React has mounted
requestAnimationFrame(() => {
const splash = document.getElementById("splash");
if (splash) {
splash.classList.add("fade-out");
splash.addEventListener("transitionend", () => splash.remove());
}
});

View file

@ -55,10 +55,13 @@ function AgentPhaseBlock({
content: string; content: string;
isActive: boolean; isActive: boolean;
}) { }) {
const [expanded, setExpanded] = useState(false); const [manualToggle, setManualToggle] = useState<boolean | null>(null);
if (!content && !isActive) return null; if (!content && !isActive) return null;
// Auto-expand while actively streaming; user can override
const expanded = manualToggle ?? isActive;
const phaseColors: Record<string, string> = { const phaseColors: Record<string, string> = {
think: "border-amber-500/30 bg-amber-500/5", think: "border-amber-500/30 bg-amber-500/5",
observe: "border-sky-500/30 bg-sky-500/5", observe: "border-sky-500/30 bg-sky-500/5",
@ -79,7 +82,7 @@ function AgentPhaseBlock({
)} )}
> >
<button <button
onClick={() => setExpanded((p) => !p)} onClick={() => setManualToggle((prev) => !(prev ?? isActive))}
aria-expanded={expanded} aria-expanded={expanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted" className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
> >
@ -101,9 +104,14 @@ function AgentPhaseBlock({
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" /> <Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
)} )}
</button> </button>
{expanded && content && ( {expanded && (content || isActive) && (
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted"> <div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
<p className="whitespace-pre-wrap">{content}</p> <p className="whitespace-pre-wrap">
{content || (isActive ? "..." : "")}
</p>
{isActive && content && (
<span className="mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
)}
</div> </div>
)} )}
</div> </div>

View file

@ -8,6 +8,7 @@ import {
ChevronRight, ChevronRight,
Loader2, Loader2,
AlertTriangle, AlertTriangle,
Info,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFlows, type FlowSummary } from "@/hooks/use-flows"; import { useFlows, type FlowSummary } from "@/hooks/use-flows";
@ -44,6 +45,9 @@ function StartFlowDialog({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [paramsError, setParamsError] = useState<string | null>(null); const [paramsError, setParamsError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [blueprintDef, setBlueprintDef] = useState<Record<string, unknown> | null>(null);
const [loadingDef, setLoadingDef] = useState(false);
const [defExpanded, setDefExpanded] = useState(false);
// Fetch blueprints when dialog opens // Fetch blueprints when dialog opens
useEffect(() => { useEffect(() => {
@ -64,6 +68,48 @@ function StartFlowDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, socket]); }, [open, socket]);
// Fetch blueprint definition when selection changes
useEffect(() => {
if (!blueprint) {
setBlueprintDef(null);
return;
}
let cancelled = false;
setLoadingDef(true);
setBlueprintDef(null);
socket
.flows()
.getFlowBlueprint(blueprint)
.then((def) => {
if (cancelled) return;
setBlueprintDef(def);
// Pre-populate parameters with defaults from the definition
const paramsDef =
def?.parameters ?? def?.params ?? def?.["parameters"] ?? def?.["params"];
if (paramsDef && typeof paramsDef === "object") {
const defaults: Record<string, unknown> = {};
const params = paramsDef as Record<string, unknown>;
for (const [key, val] of Object.entries(params)) {
if (val && typeof val === "object" && "default" in (val as Record<string, unknown>)) {
defaults[key] = (val as Record<string, unknown>).default;
}
}
if (Object.keys(defaults).length > 0) {
setParamsJson(JSON.stringify(defaults, null, 2));
}
}
})
.catch(() => {
if (!cancelled) setBlueprintDef(null);
})
.finally(() => {
if (!cancelled) setLoadingDef(false);
});
return () => {
cancelled = true;
};
}, [blueprint, socket]);
const reset = () => { const reset = () => {
setId(""); setId("");
setBlueprint(""); setBlueprint("");
@ -72,6 +118,9 @@ function StartFlowDialog({
setParamsError(null); setParamsError(null);
setSubmitting(false); setSubmitting(false);
setSubmitted(false); setSubmitted(false);
setBlueprintDef(null);
setLoadingDef(false);
setDefExpanded(false);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@ -123,7 +172,7 @@ function StartFlowDialog({
</button> </button>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isValid || submitting} disabled={submitting}
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40" className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
> >
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
@ -180,6 +229,92 @@ function StartFlowDialog({
{submitted && !blueprint && ( {submitted && !blueprint && (
<p className="mt-1 text-xs text-red-400">Blueprint is required</p> <p className="mt-1 text-xs text-red-400">Blueprint is required</p>
)} )}
{/* Blueprint details info section */}
{loadingDef && (
<div className="mt-2 flex items-center gap-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprint details...
</div>
)}
{blueprintDef && !loadingDef && (
<div className="mt-2 rounded-lg border border-border bg-surface-50 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-fg-muted">
<Info className="h-3.5 w-3.5 text-brand-400" />
Blueprint Details
</div>
{/* Description from definition */}
{!!(blueprintDef.description || blueprintDef.desc) && (
<p className="mt-1.5 text-xs text-fg-muted">
{String(blueprintDef.description ?? blueprintDef.desc)}
</p>
)}
{/* Parameters schema */}
{(() => {
const paramsDef =
blueprintDef.parameters ??
blueprintDef.params ??
blueprintDef["parameters"] ??
blueprintDef["params"];
if (!paramsDef || typeof paramsDef !== "object") return null;
const entries = Object.entries(paramsDef as Record<string, unknown>);
if (entries.length === 0) return null;
return (
<div className="mt-2">
<p className="text-xs font-medium text-fg-muted">Parameters</p>
<div className="mt-1 space-y-1">
{entries.map(([name, schema]) => {
const s = schema as Record<string, unknown> | null;
const type = s?.type ? String(s.type) : undefined;
const defaultVal = s && "default" in s ? s.default : undefined;
const desc = s?.description ? String(s.description) : undefined;
return (
<div
key={name}
className="flex flex-wrap items-baseline gap-x-2 text-xs"
>
<span className="font-mono font-medium text-fg">{name}</span>
{type && (
<span className="rounded bg-surface-200 px-1 py-0.5 text-[10px] text-fg-subtle">
{type}
</span>
)}
{defaultVal !== undefined && (
<span className="text-fg-subtle">
default: <span className="font-mono">{JSON.stringify(defaultVal)}</span>
</span>
)}
{desc && <span className="text-fg-subtle">- {desc}</span>}
</div>
);
})}
</div>
</div>
);
})()}
{/* Raw JSON toggle */}
<button
type="button"
onClick={() => setDefExpanded((p) => !p)}
className="mt-2 flex items-center gap-1 text-[11px] text-fg-subtle hover:text-fg-muted"
>
{defExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
Raw definition
</button>
{defExpanded && (
<pre className="mt-1 max-h-40 overflow-auto rounded border border-border bg-surface-100 p-2 font-mono text-[11px] text-fg-subtle">
{JSON.stringify(blueprintDef, null, 2)}
</pre>
)}
</div>
)}
</div> </div>
{/* Description */} {/* Description */}
@ -241,25 +376,41 @@ function StopFlowDialog({
open: boolean; open: boolean;
flowId: string; flowId: string;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => Promise<void>;
}) { }) {
const [stopping, setStopping] = useState(false);
const handleStop = async () => {
setStopping(true);
try {
await onConfirm();
} finally {
setStopping(false);
}
};
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={onClose} onClose={() => {
if (!stopping) onClose();
}}
title="Stop Flow" title="Stop Flow"
footer={ footer={
<> <>
<button <button
onClick={onClose} onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200" disabled={stopping}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={onConfirm} onClick={handleStop}
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90" disabled={stopping}
className="flex items-center gap-2 rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-40"
> >
{stopping && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Stop Stop
</button> </button>
</> </>

View file

@ -313,7 +313,7 @@ export default function GraphPage() {
const zoomFit = () => const zoomFit = () =>
fgRef.current?.zoomToFit(400, 40); fgRef.current?.zoomToFit(400, 40);
// Node paint callback // Node paint callback — with glow effect
const paintNode = useCallback( const paintNode = useCallback(
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
const isSelected = node.id === selectedNode; const isSelected = node.id === selectedNode;
@ -324,18 +324,40 @@ export default function GraphPage() {
const x = node.x ?? 0; const x = node.x ?? 0;
const y = node.y ?? 0; const y = node.y ?? 0;
// Node circle const baseColor = dim
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = dim
? "rgba(100,100,100,0.3)" ? "rgba(100,100,100,0.3)"
: isSelected : isSelected
? "#fbbf24" ? "#fbbf24"
: isMatch : isMatch
? "#22c55e" ? "#22c55e"
: node.color ?? "#5b80ff"; : node.color ?? "#5b80ff";
// Outer glow (only when not dimmed)
if (!dim) {
ctx.save();
ctx.shadowColor = baseColor;
ctx.shadowBlur = isSelected ? 16 : 8;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = baseColor;
ctx.fill();
ctx.restore();
}
// Node circle (crisp, on top of glow)
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = baseColor;
ctx.fill(); ctx.fill();
// Inner highlight (subtle white dot for depth)
if (!dim && radius > 3) {
ctx.beginPath();
ctx.arc(x - radius * 0.25, y - radius * 0.25, radius * 0.3, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255,255,255,0.2)";
ctx.fill();
}
if (isSelected || isMatch) { if (isSelected || isMatch) {
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e"; ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
ctx.lineWidth = 1.5 / globalScale; ctx.lineWidth = 1.5 / globalScale;
@ -344,7 +366,7 @@ export default function GraphPage() {
// Label // Label
const fontSize = Math.max(10 / globalScale, 2); const fontSize = Math.max(10 / globalScale, 2);
ctx.font = `${fontSize}px Inter, sans-serif`; ctx.font = `600 ${fontSize}px Inter, sans-serif`;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "top"; ctx.textBaseline = "top";
const isLight = document.documentElement.classList.contains("light"); const isLight = document.documentElement.classList.contains("light");
@ -353,7 +375,7 @@ export default function GraphPage() {
: isLight : isLight
? "rgba(24,24,27,0.9)" ? "rgba(24,24,27,0.9)"
: "rgba(250,250,250,0.9)"; : "rgba(250,250,250,0.9)";
ctx.fillText(node.label, x, y + radius + 1); ctx.fillText(node.label, x, y + radius + 2);
}, },
[selectedNode, matchingIds], [selectedNode, matchingIds],
); );
@ -631,9 +653,16 @@ export default function GraphPage() {
}} }}
linkCanvasObjectMode={() => "after"} linkCanvasObjectMode={() => "after"}
linkCanvasObject={paintLink} linkCanvasObject={paintLink}
linkColor={() => "rgba(91,128,255,0.25)"} linkColor={() => "rgba(91,128,255,0.18)"}
linkDirectionalArrowLength={4} linkWidth={1.5}
linkDirectionalArrowLength={5}
linkDirectionalArrowRelPos={0.85} linkDirectionalArrowRelPos={0.85}
linkDirectionalArrowColor={() => "rgba(91,128,255,0.5)"}
linkDirectionalParticles={2}
linkDirectionalParticleWidth={2}
linkDirectionalParticleSpeed={0.004}
linkDirectionalParticleColor={() => "rgba(91,128,255,0.6)"}
linkCurvature={0.1}
onNodeClick={(node: GraphNode) => { onNodeClick={(node: GraphNode) => {
setSelectedNode((prev) => setSelectedNode((prev) =>
prev === node.id ? null : node.id, prev === node.id ? null : node.id,
@ -641,6 +670,8 @@ export default function GraphPage() {
}} }}
onBackgroundClick={() => setSelectedNode(null)} onBackgroundClick={() => setSelectedNode(null)}
backgroundColor="transparent" backgroundColor="transparent"
cooldownTicks={100}
warmupTicks={30}
width={containerSize?.width} width={containerSize?.width}
height={containerSize?.height} height={containerSize?.height}
/> />

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
LibraryBig, LibraryBig,
Upload, Upload,
@ -9,15 +9,23 @@ import {
Loader2, Loader2,
X, X,
AlertTriangle, AlertTriangle,
Search,
Eye,
Clock,
Tag,
Hash,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useLibrary } from "@/hooks/use-library"; import { useLibrary, type UploadProgress } from "@/hooks/use-library";
import { useSettings } from "@/providers/settings-provider"; import { useSettings } from "@/providers/settings-provider";
import { useNotification } from "@/providers/notification-provider"; import { useNotification } from "@/providers/notification-provider";
import { Dialog } from "@/components/ui/dialog"; import { Dialog } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { DocumentMetadata } from "@trustgraph/client"; import type { DocumentMetadata } from "@trustgraph/client";
// Threshold for chunked upload (1 MB base64 ~ 750 KB raw)
const CHUNKED_UPLOAD_THRESHOLD = 1_000_000;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Upload dialog // Upload dialog
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -26,6 +34,7 @@ function UploadDialog({
open, open,
onClose, onClose,
onUpload, onUpload,
onUploadChunked,
onError, onError,
}: { }: {
open: boolean; open: boolean;
@ -37,6 +46,14 @@ function UploadDialog({
comments: string, comments: string,
tags: string[], tags: string[],
) => Promise<void>; ) => Promise<void>;
onUploadChunked: (
data: string,
mimeType: string,
title: string,
comments: string,
tags: string[],
onProgress: (progress: UploadProgress) => void,
) => Promise<void>;
onError?: (msg: string) => void; onError?: (msg: string) => void;
}) { }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
@ -45,6 +62,7 @@ function UploadDialog({
const [comments, setComments] = useState(""); const [comments, setComments] = useState("");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [progress, setProgress] = useState<UploadProgress | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const reset = () => { const reset = () => {
@ -53,6 +71,7 @@ function UploadDialog({
setTags(""); setTags("");
setComments(""); setComments("");
setUploading(false); setUploading(false);
setProgress(null);
}; };
const titleRef = useRef(title); const titleRef = useRef(title);
@ -79,15 +98,26 @@ function UploadDialog({
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean); .filter(Boolean);
await onUpload(base64, file.type || "application/octet-stream", title, comments, tagList); const mimeType = file.type || "application/octet-stream";
if (base64.length > CHUNKED_UPLOAD_THRESHOLD) {
await onUploadChunked(base64, mimeType, title, comments, tagList, setProgress);
} else {
await onUpload(base64, mimeType, title, comments, tagList);
}
reset(); reset();
onClose(); onClose();
} catch (err) { } catch (err) {
onError?.(err instanceof Error ? err.message : "Upload failed"); onError?.(err instanceof Error ? err.message : "Upload failed");
setUploading(false); setUploading(false);
setProgress(null);
} }
}; };
const progressPercent = progress
? Math.round((progress.chunksUploaded / Math.max(progress.chunksTotal, 1)) * 100)
: 0;
return ( return (
<Dialog <Dialog
open={open} open={open}
@ -151,10 +181,12 @@ function UploadDialog({
<div className="flex items-center gap-2 text-sm text-fg"> <div className="flex items-center gap-2 text-sm text-fg">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
<span>{file.name}</span> <span>{file.name}</span>
<span className="text-xs text-fg-subtle">({formatBytes(file.size)})</span>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setFile(null); setFile(null);
setTitle("");
}} }}
aria-label="Remove selected file" aria-label="Remove selected file"
className="ml-1 text-fg-subtle hover:text-fg" className="ml-1 text-fg-subtle hover:text-fg"
@ -182,6 +214,28 @@ function UploadDialog({
/> />
</div> </div>
{/* Upload progress bar */}
{uploading && progress && (
<div className="mb-4 space-y-1.5">
<div className="flex items-center justify-between text-xs text-fg-muted">
<span>
{progress.phase === "preparing"
? "Preparing upload..."
: progress.phase === "finalizing"
? "Finalizing..."
: `Uploading chunk ${progress.chunksUploaded}/${progress.chunksTotal}`}
</span>
<span>{progressPercent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-surface-200">
<div
className="h-full rounded-full bg-brand-500 transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
{/* Title */} {/* Title */}
<div className="mb-3 space-y-1.5"> <div className="mb-3 space-y-1.5">
<label htmlFor="upload-title" className="block text-sm font-medium text-fg-muted">Title</label> <label htmlFor="upload-title" className="block text-sm font-medium text-fg-muted">Title</label>
@ -225,6 +279,110 @@ function UploadDialog({
); );
} }
// ---------------------------------------------------------------------------
// Document detail dialog
// ---------------------------------------------------------------------------
function DocumentDetailDialog({
open,
doc,
loading: loadingMeta,
onClose,
}: {
open: boolean;
doc: DocumentMetadata | null;
loading?: boolean;
onClose: () => void;
}) {
if (!doc) return null;
return (
<Dialog open={open} onClose={onClose} title="Document Details" className="max-w-xl">
{loadingMeta && (
<div className="mb-3 flex items-center gap-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" />
Loading full metadata...
</div>
)}
<div className="space-y-4">
{/* Title */}
<div>
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Title</h3>
<p className="text-sm text-fg">{doc.title || "Untitled"}</p>
</div>
{/* ID */}
<div>
<h3 className="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-subtle">
<Hash className="h-3 w-3" /> Document ID
</h3>
<p className="break-all font-mono text-xs text-fg-muted">{doc.id}</p>
</div>
{/* Type */}
<div>
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Type</h3>
<Badge variant="default">{doc.kind ?? doc["document-type"] ?? "--"}</Badge>
</div>
{/* Comments */}
{doc.comments && (
<div>
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Comments</h3>
<p className="text-sm text-fg-muted">{doc.comments}</p>
</div>
)}
{/* Tags */}
{doc.tags && doc.tags.length > 0 && (
<div>
<h3 className="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-subtle">
<Tag className="h-3 w-3" /> Tags
</h3>
<div className="flex flex-wrap gap-1.5">
{doc.tags.map((tag) => (
<Badge key={tag} variant="info">{tag}</Badge>
))}
</div>
</div>
)}
{/* Timestamp */}
{doc.time != null && (
<div>
<h3 className="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-subtle">
<Clock className="h-3 w-3" /> Created
</h3>
<p className="text-sm text-fg-muted">
{new Date(doc.time * 1000).toLocaleString()}
</p>
</div>
)}
{/* User */}
{doc.user && (
<div>
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Uploaded by</h3>
<p className="text-sm text-fg-muted">{doc.user}</p>
</div>
)}
{/* Raw metadata (if any RDF triples) */}
{doc.metadata && doc.metadata.length > 0 && (
<div>
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">
Metadata ({doc.metadata.length} triples)
</h3>
<pre className="max-h-40 overflow-y-auto rounded-lg bg-surface-100 p-3 font-mono text-[10px] text-fg-muted">
{JSON.stringify(doc.metadata, null, 2)}
</pre>
</div>
)}
</div>
</Dialog>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Confirm delete dialog // Confirm delete dialog
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -286,14 +444,20 @@ export default function LibraryPage() {
error, error,
getDocuments, getDocuments,
uploadDocument, uploadDocument,
uploadDocumentChunked,
removeDocument, removeDocument,
getProcessing, getProcessing,
getDocumentMetadata,
} = useLibrary(); } = useLibrary();
const collection = useSettings((s) => s.settings.collection); const collection = useSettings((s) => s.settings.collection);
const notify = useNotification(); const notify = useNotification();
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DocumentMetadata | null>(null); const [deleteTarget, setDeleteTarget] = useState<DocumentMetadata | null>(null);
const [detailDoc, setDetailDoc] = useState<DocumentMetadata | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
// Load documents and processing on mount // Load documents and processing on mount
useEffect(() => { useEffect(() => {
@ -318,8 +482,28 @@ export default function LibraryPage() {
} }
}; };
const handleUploadChunked = async (
data: string,
mimeType: string,
title: string,
comments: string,
tags: string[],
onProgress: (progress: UploadProgress) => void,
) => {
try {
await uploadDocumentChunked(data, mimeType, title, comments, tags, onProgress);
notify.success("Document uploaded", `"${title}" is being processed.`);
getProcessing();
} catch {
notify.error("Upload failed", "Could not upload the document.");
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget?.id) return; if (!deleteTarget?.id) {
setDeleteTarget(null);
return;
}
try { try {
await removeDocument(deleteTarget.id, collection); await removeDocument(deleteTarget.id, collection);
notify.success("Document deleted"); notify.success("Document deleted");
@ -329,6 +513,20 @@ export default function LibraryPage() {
setDeleteTarget(null); setDeleteTarget(null);
}; };
const handleViewDetail = useCallback(
async (doc: DocumentMetadata) => {
setDetailDoc(doc);
setDetailOpen(true);
if (doc.id) {
setLoadingDetail(true);
const fullMeta = await getDocumentMetadata(doc.id);
if (fullMeta) setDetailDoc(fullMeta);
setLoadingDetail(false);
}
},
[getDocumentMetadata],
);
const handleRefresh = () => { const handleRefresh = () => {
getDocuments(); getDocuments();
getProcessing(); getProcessing();
@ -343,6 +541,24 @@ export default function LibraryPage() {
return kind || "--"; return kind || "--";
}; };
// Search/filter
const searchLower = searchTerm.toLowerCase();
const filteredDocuments = useMemo(() => {
if (!searchLower) return documents;
return documents.filter((doc) => {
const title = (doc.title ?? "").toLowerCase();
const id = (doc.id ?? "").toLowerCase();
const tags = (doc.tags ?? []).join(" ").toLowerCase();
const kind = (doc.kind ?? doc["document-type"] ?? "").toLowerCase();
return (
title.includes(searchLower) ||
id.includes(searchLower) ||
tags.includes(searchLower) ||
kind.includes(searchLower)
);
});
}, [documents, searchLower]);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header */} {/* Header */}
@ -374,6 +590,31 @@ export default function LibraryPage() {
</div> </div>
</div> </div>
{/* Search bar */}
{documents.length > 0 && (
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-subtle" />
<input
id="library-search"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by title, tags, type, or ID..."
aria-label="Search documents"
className="w-full rounded-lg border border-border bg-surface-100 py-2 pl-9 pr-9 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
{/* Processing status */} {/* Processing status */}
{processing.length > 0 && ( {processing.length > 0 && (
<div className="mb-4 rounded-lg border border-brand-500/30 bg-brand-500/5 p-3"> <div className="mb-4 rounded-lg border border-brand-500/30 bg-brand-500/5 p-3">
@ -416,7 +657,14 @@ export default function LibraryPage() {
</div> </div>
)} )}
{documents.length > 0 && ( {/* Search results info */}
{searchTerm && documents.length > 0 && (
<p className="mb-2 text-xs text-fg-subtle">
{filteredDocuments.length} of {documents.length} documents match
</p>
)}
{filteredDocuments.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border"> <div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="border-b border-border bg-surface-100 text-fg-muted"> <thead className="border-b border-border bg-surface-100 text-fg-muted">
@ -429,7 +677,7 @@ export default function LibraryPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{documents.map((doc) => ( {filteredDocuments.map((doc) => (
<tr key={doc.id} className="hover:bg-surface-100/50"> <tr key={doc.id} className="hover:bg-surface-100/50">
<td className="px-4 py-3 text-fg"> <td className="px-4 py-3 text-fg">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -454,13 +702,24 @@ export default function LibraryPage() {
{doc.id} {doc.id}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<button <div className="flex items-center justify-end gap-1">
onClick={() => setDeleteTarget(doc)} <button
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error" onClick={() => handleViewDetail(doc)}
title="Delete document" className="rounded p-1.5 text-fg-subtle hover:bg-surface-200 hover:text-fg"
> title="View details"
<Trash2 className="h-3.5 w-3.5" /> aria-label="View document details"
</button> >
<Eye className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setDeleteTarget(doc)}
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
title="Delete document"
aria-label="Delete document"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@ -469,11 +728,20 @@ export default function LibraryPage() {
</div> </div>
)} )}
{/* Empty search results */}
{searchTerm && filteredDocuments.length === 0 && documents.length > 0 && (
<div className="flex flex-1 flex-col items-center justify-center py-12">
<Search className="mb-3 h-8 w-8 text-fg-subtle opacity-30" />
<p className="text-fg-subtle">No documents match "{searchTerm}"</p>
</div>
)}
{/* Dialogs */} {/* Dialogs */}
<UploadDialog <UploadDialog
open={uploadOpen} open={uploadOpen}
onClose={() => setUploadOpen(false)} onClose={() => setUploadOpen(false)}
onUpload={handleUpload} onUpload={handleUpload}
onUploadChunked={handleUploadChunked}
onError={(msg) => notify.error("Upload failed", msg)} onError={(msg) => notify.error("Upload failed", msg)}
/> />
@ -483,6 +751,16 @@ export default function LibraryPage() {
onClose={() => setDeleteTarget(null)} onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete} onConfirm={handleDelete}
/> />
<DocumentDetailDialog
open={detailOpen}
doc={detailDoc}
loading={loadingDetail}
onClose={() => {
setDetailOpen(false);
setDetailDoc(null);
}}
/>
</div> </div>
); );
} }
@ -504,3 +782,9 @@ function fileToBase64(file: File): Promise<string> {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} }
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View file

@ -12,6 +12,8 @@ import {
Loader2, Loader2,
Moon, Moon,
Sun, Sun,
Plus,
Trash2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSettings } from "@/providers/settings-provider"; import { useSettings } from "@/providers/settings-provider";
@ -21,6 +23,7 @@ import { useFlows } from "@/hooks/use-flows";
import { useSessionStore } from "@/hooks/use-session-store"; import { useSessionStore } from "@/hooks/use-session-store";
import { useNotification } from "@/providers/notification-provider"; import { useNotification } from "@/providers/notification-provider";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Dialog } from "@/components/ui/dialog";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -82,6 +85,18 @@ export default function SettingsPage() {
>([]); >([]);
const [loadingCollections, setLoadingCollections] = useState(false); const [loadingCollections, setLoadingCollections] = useState(false);
// Create-collection dialog state
const [createOpen, setCreateOpen] = useState(false);
const [newId, setNewId] = useState("");
const [newName, setNewName] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newTags, setNewTags] = useState("");
const [creating, setCreating] = useState(false);
// Delete-collection confirmation dialog state
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
// Dark mode toggle -- uses a class on <html>/<body> and persists to localStorage // Dark mode toggle -- uses a class on <html>/<body> and persists to localStorage
const [isDark, setIsDark] = useState(() => { const [isDark, setIsDark] = useState(() => {
if (typeof window === "undefined") return true; if (typeof window === "undefined") return true;
@ -106,32 +121,118 @@ export default function SettingsPage() {
} }
}, [isDark]); }, [isDark]);
// Fetch collections // Reusable function to fetch collections from the backend
useEffect(() => { const refreshCollections = useCallback(() => {
let cancelled = false;
setLoadingCollections(true); setLoadingCollections(true);
socket return socket
.collectionManagement() .collectionManagement()
.listCollections() .listCollections()
.then((cols) => { .then((cols) => {
if (!cancelled) { const list = Array.isArray(cols)
setCollections( ? (cols as Array<{ id?: string; collection?: string; name?: string; [key: string]: unknown }>)
Array.isArray(cols) : [];
? (cols as Array<{ id?: string; name?: string; [key: string]: unknown }>) // Ensure "default" collection is always present
: [], const hasDefault = list.some(
); (c) => (c.collection ?? c.id ?? c.name) === "default",
);
if (!hasDefault) {
list.unshift({ id: "default", collection: "default", name: "default" });
} }
setCollections(list);
return list;
}) })
.catch(() => { .catch(() => {
/* silent -- collections endpoint may not be available */ // Fallback: at minimum show "default"
setCollections([{ id: "default", collection: "default", name: "default" }]);
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoadingCollections(false); setLoadingCollections(false);
}); });
}, [socket]);
// Fetch collections on mount
useEffect(() => {
let cancelled = false;
refreshCollections().then(() => {
if (cancelled) return;
});
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [socket]); }, [refreshCollections]);
// Create a new collection
const handleCreateCollection = useCallback(async () => {
const trimmedId = newId.trim();
if (!trimmedId) return;
setCreating(true);
try {
const tags = newTags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
await socket
.collectionManagement()
.updateCollection(
trimmedId,
newName.trim() || undefined,
newDescription.trim() || undefined,
tags.length > 0 ? tags : undefined,
);
await refreshCollections();
updateSetting("collection", trimmedId);
notify.success("Collection created", `"${newName.trim() || trimmedId}" is now active.`);
// Reset form and close
setNewId("");
setNewName("");
setNewDescription("");
setNewTags("");
setCreateOpen(false);
} catch (err) {
notify.error(
"Failed to create collection",
err instanceof Error ? err.message : String(err),
);
} finally {
setCreating(false);
}
}, [newId, newName, newDescription, newTags, socket, refreshCollections, updateSetting, notify]);
// Delete the current collection
const handleDeleteCollection = useCallback(async () => {
const currentId = settings.collection;
if (!currentId) return;
setDeleting(true);
try {
await socket.collectionManagement().deleteCollection(currentId);
await refreshCollections();
// Switch to the first remaining collection
const remaining = collections.filter((c) => {
const id = c.id ?? String(c.name ?? c);
return id !== currentId;
});
if (remaining.length > 0) {
const firstId = remaining[0].id ?? String(remaining[0].name ?? remaining[0]);
updateSetting("collection", firstId);
}
notify.success("Collection deleted", `"${currentId}" has been removed.`);
setDeleteOpen(false);
} catch (err) {
notify.error(
"Failed to delete collection",
err instanceof Error ? err.message : String(err),
);
} finally {
setDeleting(false);
}
}, [settings.collection, socket, refreshCollections, collections, updateSetting, notify]);
// Connection status helpers // Connection status helpers
const isConnected = const isConnected =
@ -183,7 +284,7 @@ export default function SettingsPage() {
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/> />
<p className="text-xs text-fg-subtle"> <p className="text-xs text-fg-subtle">
The WebSocket URL for the TrustGraph gateway. The WebSocket URL for the Beep Graph gateway.
</p> </p>
</div> </div>
@ -253,33 +354,193 @@ export default function SettingsPage() {
collections... collections...
</div> </div>
) : collections.length > 0 ? ( ) : collections.length > 0 ? (
<select <div className="flex items-center gap-2">
id="settings-collection" <select
value={settings.collection} id="settings-collection"
onChange={(e) => updateSetting("collection", e.target.value)} value={settings.collection}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" onChange={(e) => updateSetting("collection", e.target.value)}
> className="flex-1 rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
{collections.map((c) => { >
const id = c.id ?? String(c.name ?? c); {collections.map((c) => {
return ( const cObj = c as { collection?: string; id?: string; name?: string };
<option key={id} value={id}> const collId = cObj.collection ?? cObj.id ?? String(cObj.name ?? c);
{c.name ?? id} const label = cObj.name ?? collId;
</option> return (
); <option key={collId} value={collId}>
})} {label !== collId ? `${label} (${collId})` : collId}
</select> </option>
);
})}
</select>
<button
type="button"
onClick={() => setCreateOpen(true)}
aria-label="New collection"
title="New collection"
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
<Plus className="h-4 w-4" />
</button>
{collections.length > 1 && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
aria-label="Delete collection"
title="Delete collection"
className="rounded-lg border border-red-500/30 bg-surface-100 p-2 text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
) : ( ) : (
<input <div className="flex items-center gap-2">
id="settings-collection" <input
type="text" id="settings-collection"
value={settings.collection} type="text"
onChange={(e) => updateSetting("collection", e.target.value)} value={settings.collection}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" onChange={(e) => updateSetting("collection", e.target.value)}
/> className="flex-1 rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<button
type="button"
onClick={() => setCreateOpen(true)}
aria-label="New collection"
title="New collection"
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
<Plus className="h-4 w-4" />
</button>
</div>
)} )}
</div> </div>
</Section> </Section>
{/* Create Collection Dialog */}
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title="New Collection"
footer={
<>
<button
type="button"
onClick={() => setCreateOpen(false)}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
>
Cancel
</button>
<button
type="button"
disabled={!newId.trim() || creating}
onClick={handleCreateCollection}
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating && <Loader2 className="h-3 w-3 animate-spin" />}
Create
</button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="new-collection-id" className="block text-sm font-medium text-fg-muted">
Collection ID <span className="text-red-400">*</span>
</label>
<input
id="new-collection-id"
type="text"
value={newId}
onChange={(e) => setNewId(e.target.value)}
placeholder="my-collection"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<p className="text-xs text-fg-subtle">
A unique identifier for this collection.
</p>
</div>
<div className="space-y-1.5">
<label htmlFor="new-collection-name" className="block text-sm font-medium text-fg-muted">
Display Name
</label>
<input
id="new-collection-name"
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="My Collection"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="new-collection-description" className="block text-sm font-medium text-fg-muted">
Description
</label>
<textarea
id="new-collection-description"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="What this collection is for..."
rows={3}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 resize-none"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="new-collection-tags" className="block text-sm font-medium text-fg-muted">
Tags
</label>
<input
id="new-collection-tags"
type="text"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="research, finance, internal"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<p className="text-xs text-fg-subtle">
Comma-separated list of tags for categorization.
</p>
</div>
</div>
</Dialog>
{/* Delete Collection Confirmation Dialog */}
<Dialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
title="Delete Collection"
className="max-w-md"
footer={
<>
<button
type="button"
onClick={() => setDeleteOpen(false)}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
>
Cancel
</button>
<button
type="button"
disabled={deleting}
onClick={handleDeleteCollection}
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleting && <Loader2 className="h-3 w-3 animate-spin" />}
Delete
</button>
</>
}
>
<p className="text-sm text-fg-muted">
Are you sure you want to delete the collection{" "}
<span className="font-semibold text-fg">"{settings.collection}"</span>?
This will remove the collection and all its data. This action cannot be undone.
</p>
</Dialog>
{/* Flow */} {/* Flow */}
<Section <Section
title="Active Flow" title="Active Flow"
@ -391,11 +652,11 @@ export default function SettingsPage() {
> >
<div className="space-y-2 text-sm text-fg-muted"> <div className="space-y-2 text-sm text-fg-muted">
<p> <p>
<span className="font-medium text-fg">TrustGraph Workbench</span>{" "} <span className="font-medium text-fg">Beep Graph</span>{" "}
v0.1.0 v0.1.0
</p> </p>
<p> <p>
A web-based interface for interacting with the TrustGraph A web-based interface for interacting with the Beep Graph
knowledge-graph system. knowledge-graph system.
</p> </p>
</div> </div>

View file

@ -47,7 +47,7 @@ const DEFAULT_FEATURE_SWITCHES: FeatureSwitches = {
}; };
const DEFAULT_SETTINGS: Settings = { const DEFAULT_SETTINGS: Settings = {
user: "user", user: "default",
apiKey: "", apiKey: "",
collection: "default", collection: "default",
gatewayUrl: "", gatewayUrl: "",

View file

@ -334,9 +334,87 @@ function buildTriples(): RawTriple[] {
entity("AI Safety", "is a focus area of", "Anthropic"); entity("AI Safety", "is a focus area of", "Anthropic");
entity("AI Safety", "is researched by", "Google DeepMind"); entity("AI Safety", "is researched by", "Google DeepMind");
entity("AI Safety", "is researched by", "OpenAI");
literal("AI Safety", "concerns include", "alignment, misuse, and existential risk"); literal("AI Safety", "concerns include", "alignment, misuse, and existential risk");
literal("AI Safety", "approaches include", "RLHF, Constitutional AI, and interpretability research"); literal("AI Safety", "approaches include", "RLHF, Constitutional AI, interpretability, and red-teaming");
entity("AI Safety", "advocate includes", "Geoffrey Hinton"); entity("AI Safety", "advocate includes", "Geoffrey Hinton");
entity("AI Safety", "advocate includes", "Dario Amodei");
entity("AI Safety", "advocate includes", "Ilya Sutskever");
// Anthropic's AI Safety approach (detailed)
entity("Anthropic", "practices", "AI Safety");
entity("Anthropic", "developed technique", "Constitutional AI");
entity("Anthropic", "developed technique", "Interpretability Research");
entity("Anthropic", "developed technique", "Red Teaming");
entity("Anthropic", "published", "Responsible Scaling Policy");
literal("Anthropic", "AI safety approach is", "training AI to be helpful, harmless, and honest through Constitutional AI and RLHF");
literal("Anthropic", "conducts", "mechanistic interpretability research to understand neural network internals");
entity("Dario Amodei", "advocates for", "AI Safety");
literal("Dario Amodei", "approach to AI safety is", "responsible scaling with clear capability thresholds and safety evaluations");
entity("Daniela Amodei", "advocates for", "AI Safety");
literal("Daniela Amodei", "focuses on", "building safety-focused organizational culture at Anthropic");
// OpenAI's AI Safety approach (detailed)
entity("OpenAI", "practices", "AI Safety");
entity("OpenAI", "developed technique", "RLHF");
entity("OpenAI", "developed technique", "Red Teaming");
entity("OpenAI", "developed technique", "Iterative Deployment");
entity("OpenAI", "established", "Preparedness Framework");
literal("OpenAI", "AI safety approach is", "iterative deployment with extensive red-teaming and RLHF alignment");
literal("OpenAI", "conducts", "external red-team evaluations before major model releases");
entity("Sam Altman", "advocates for", "AI Safety");
literal("Sam Altman", "approach to AI safety is", "gradual deployment to learn from real-world feedback while maintaining safety guardrails");
entity("Ilya Sutskever", "advocated for", "AI Safety");
literal("Ilya Sutskever", "left OpenAI to found", "Safe Superintelligence Inc focused entirely on safe superintelligence");
// DeepMind's AI Safety approach
entity("Google DeepMind", "practices", "AI Safety");
entity("Google DeepMind", "developed technique", "Scalable Oversight");
entity("Google DeepMind", "developed technique", "Reward Modeling");
literal("Google DeepMind", "AI safety approach is", "formal verification, reward modeling, and scalable oversight techniques");
entity("Demis Hassabis", "advocates for", "AI Safety");
literal("Demis Hassabis", "approach to AI safety is", "ensuring AI systems are robustly beneficial through scientific rigor");
// Safety techniques (detailed)
literal("Constitutional AI", "works by", "having AI critique and revise its own outputs according to a set of constitutional principles");
literal("Constitutional AI", "advantage is", "reducing reliance on human feedback while maintaining alignment");
literal("Constitutional AI", "was introduced in", "2022 by Anthropic researchers");
literal("RLHF", "works by", "collecting human preference data, training a reward model, and optimizing the language model via reinforcement learning");
literal("RLHF", "limitation is", "scalability of human feedback collection and reward hacking");
literal("RLHF", "was pioneered by", "OpenAI and used in ChatGPT, InstructGPT");
literal("Interpretability Research", "is a", "field studying how neural networks represent and process information internally");
entity("Interpretability Research", "is led by", "Anthropic");
literal("Interpretability Research", "uses techniques like", "sparse autoencoders, activation patching, and circuit analysis");
literal("Interpretability Research", "goal is", "understanding AI decision-making to detect and prevent harmful behaviors");
literal("Red Teaming", "is a", "security practice of adversarially testing AI systems to find vulnerabilities and harmful outputs");
entity("Red Teaming", "is used by", "OpenAI");
entity("Red Teaming", "is used by", "Anthropic");
entity("Red Teaming", "is used by", "Google DeepMind");
literal("Red Teaming", "involves", "external experts attempting to elicit harmful, biased, or dangerous responses");
literal("Iterative Deployment", "is a", "strategy of gradually releasing AI systems to learn from real-world use");
entity("Iterative Deployment", "is practiced by", "OpenAI");
literal("Iterative Deployment", "advantage is", "building societal understanding and adaptation alongside AI capabilities");
literal("Scalable Oversight", "is a", "research area focused on maintaining human oversight as AI systems become more capable");
entity("Scalable Oversight", "is researched by", "Google DeepMind");
literal("Scalable Oversight", "includes techniques like", "debate, recursive reward modeling, and amplification");
literal("Responsible Scaling Policy", "is a", "framework published by Anthropic for scaling AI capabilities safely");
literal("Responsible Scaling Policy", "defines", "AI Safety Levels (ASLs) with capability thresholds and required safeguards");
entity("Responsible Scaling Policy", "was published by", "Anthropic");
literal("Preparedness Framework", "is a", "framework published by OpenAI for tracking and mitigating catastrophic risks");
literal("Preparedness Framework", "evaluates risks in", "cybersecurity, biological threats, persuasion, and model autonomy");
entity("Preparedness Framework", "was published by", "OpenAI");
entity("Safe Superintelligence Inc", "was founded by", "Ilya Sutskever");
literal("Safe Superintelligence Inc", "is a", "company focused solely on building safe superintelligent AI");
literal("Safe Superintelligence Inc", "was founded in", "2024");
literal("Safe Superintelligence Inc", "approach is", "pursuing safety and capabilities in tandem, insulated from commercial pressures");
literal("Artificial General Intelligence", "is defined as", "AI that matches or exceeds human-level intelligence across domains"); literal("Artificial General Intelligence", "is defined as", "AI that matches or exceeds human-level intelligence across domains");
entity("Artificial General Intelligence", "is pursued by", "OpenAI"); entity("Artificial General Intelligence", "is pursued by", "OpenAI");
@ -517,6 +595,174 @@ async function embed(texts: string[]): Promise<number[][]> {
return data.embeddings; return data.embeddings;
} }
// ---------------------------------------------------------------------------
// Document chunks for Doc RAG
// ---------------------------------------------------------------------------
const DOCUMENT_CHUNKS: Array<{ id: string; content: string }> = [
{
id: "chunk-constitutional-ai-1",
content:
"Constitutional AI (CAI) is an AI alignment technique developed by Anthropic in 2022. " +
"It works by having AI systems critique and revise their own outputs according to a set of " +
"constitutional principles, reducing the need for human feedback labeling. The technique " +
"uses a two-phase approach: first, the AI generates and self-critiques responses using " +
"constitutional principles; second, it trains on the revised outputs using reinforcement " +
"learning from AI feedback (RLAIF) rather than human feedback.",
},
{
id: "chunk-constitutional-ai-2",
content:
"The key advantage of Constitutional AI is that it reduces reliance on human feedback while " +
"maintaining alignment with human values. The constitutional principles can include rules " +
"about helpfulness, harmlessness, and honesty. Anthropic published the Constitutional AI " +
"paper to demonstrate that AI systems can be made safer through self-supervision guided " +
"by explicit principles, rather than requiring massive amounts of human feedback data.",
},
{
id: "chunk-rlhf-1",
content:
"Reinforcement Learning from Human Feedback (RLHF) is a technique for training AI models " +
"to follow human preferences. It was pioneered by OpenAI and used to train models like " +
"ChatGPT and InstructGPT. The process involves three steps: first, a language model is " +
"pre-trained on a large corpus; second, human evaluators rank model outputs to create a " +
"reward model; third, the language model is fine-tuned using reinforcement learning with " +
"the reward model providing the training signal.",
},
{
id: "chunk-transformer-1",
content:
"The Transformer architecture was introduced in the 2017 paper 'Attention Is All You Need' " +
"by researchers at Google Brain. It revolutionized natural language processing by replacing " +
"recurrent neural networks with self-attention mechanisms, enabling much more efficient " +
"parallel processing. Key innovations include multi-head attention, positional encoding, " +
"and the encoder-decoder structure. The Transformer forms the foundation of modern LLMs " +
"including GPT, Claude, Gemini, and LLaMA.",
},
{
id: "chunk-openai-1",
content:
"OpenAI was founded in December 2015 as a non-profit AI research lab by Sam Altman, " +
"Elon Musk, Greg Brockman, Ilya Sutskever, Wojciech Zaremba, and John Schulman. " +
"The organization was created with the mission of ensuring that artificial general " +
"intelligence benefits all of humanity. In 2019, OpenAI transitioned to a 'capped-profit' " +
"model to attract the capital needed for large-scale AI research. OpenAI is headquartered " +
"in San Francisco and is best known for developing the GPT series of language models.",
},
{
id: "chunk-anthropic-1",
content:
"Anthropic was founded in 2021 by Dario Amodei and Daniela Amodei, along with several " +
"former OpenAI researchers. The company focuses on AI safety research and develops the " +
"Claude family of large language models. Anthropic is headquartered in San Francisco " +
"and has raised significant funding from investors including Google and Spark Capital. " +
"The company's research focuses on interpretability, Constitutional AI, and developing " +
"methods to make AI systems more reliable and aligned with human values.",
},
{
id: "chunk-ai-safety-1",
content:
"AI safety encompasses research and practices aimed at ensuring artificial intelligence " +
"systems operate as intended without causing unintended harm. Key areas include alignment " +
"(ensuring AI goals match human values), interpretability (understanding how AI makes " +
"decisions), robustness (maintaining performance under distribution shift), and red " +
"teaming (adversarial testing to find vulnerabilities). Organizations like Anthropic, " +
"OpenAI, Google DeepMind, and the Center for AI Safety are major contributors to " +
"AI safety research.",
},
{
id: "chunk-gpu-ai-1",
content:
"NVIDIA's A100 and H100 GPUs are the dominant hardware for AI training and inference. " +
"The A100, based on the Ampere architecture, delivers up to 312 TFLOPS of FP16 " +
"performance. The H100, based on the Hopper architecture released in 2022, offers " +
"roughly 3x the AI training performance of the A100. These GPUs are used by major " +
"AI labs including OpenAI, Anthropic, Google DeepMind, and Meta AI for training " +
"large language models and other AI systems.",
},
{
id: "chunk-deepmind-1",
content:
"Google DeepMind was formed in April 2023 by merging Google Brain and DeepMind. " +
"The original DeepMind was founded in 2010 by Demis Hassabis, Shane Legg, and " +
"Mustafa Suleyman, and was acquired by Google in 2014. Notable achievements include " +
"AlphaGo (defeating the world Go champion), AlphaFold (predicting protein structures), " +
"and the Gemini family of multimodal AI models. Demis Hassabis was awarded the 2024 " +
"Nobel Prize in Chemistry for the AlphaFold work.",
},
{
id: "chunk-llama-1",
content:
"LLaMA (Large Language Model Meta AI) is Meta's family of open-source large language " +
"models. LLaMA 2 was released in July 2023 and made available for both research and " +
"commercial use. The open-source approach allows researchers and developers to fine-tune " +
"and deploy the models for their own applications. LLaMA models have been widely adopted " +
"by the AI community and have spawned numerous derivative models and applications.",
},
];
// ---------------------------------------------------------------------------
// Qdrant seeding (document embeddings)
// ---------------------------------------------------------------------------
async function seedDocumentChunks(): Promise<void> {
// Embed all chunk content
const BATCH_SIZE = 32;
const allVectors: number[][] = [];
const texts = DOCUMENT_CHUNKS.map((c) => c.content);
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
const vecs = await embed(batch);
allVectors.push(...vecs);
process.stdout.write(
`\r Embedding doc chunks: ${Math.min(i + BATCH_SIZE, texts.length)}/${texts.length}`,
);
}
console.log();
const dim = allVectors[0].length;
const collectionName = `d_${USER}_${COLLECTION}_${dim}`;
// Create collection if needed
const existsRes = await fetch(`${QDRANT_URL}/collections/${collectionName}`);
if (!existsRes.ok) {
await fetch(`${QDRANT_URL}/collections/${collectionName}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
vectors: { size: dim, distance: "Cosine" },
}),
});
console.log(` Created Qdrant collection: ${collectionName} (dim=${dim})`);
} else {
console.log(` Qdrant collection exists: ${collectionName}`);
}
// Upsert all chunks with content in payload
const points = DOCUMENT_CHUNKS.map((chunk, i) => ({
id: crypto.randomUUID(),
vector: allVectors[i],
payload: {
chunk_id: chunk.id,
content: chunk.content,
},
}));
const res = await fetch(`${QDRANT_URL}/collections/${collectionName}/points`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ points }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Qdrant doc upsert failed: ${body}`);
}
console.log(` Qdrant: ${points.length} document chunk embeddings stored in ${collectionName}`);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Qdrant seeding (graph embeddings) // Qdrant seeding (graph embeddings)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -756,6 +1002,10 @@ async function main(): Promise<void> {
console.log("── Seeding Qdrant (entity embeddings) ──"); console.log("── Seeding Qdrant (entity embeddings) ──");
await seedQdrant(entities); await seedQdrant(entities);
console.log(); console.log();
console.log("── Seeding Qdrant (document chunk embeddings) ──");
await seedDocumentChunks();
console.log();
} else if (hasQdrant) { } else if (hasQdrant) {
console.log("⚠ Skipping Qdrant embeddings (Ollama not available for embedding generation)\n"); console.log("⚠ Skipping Qdrant embeddings (Ollama not available for embedding generation)\n");
} else { } else {