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

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

View file

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

View file

@ -102,6 +102,7 @@ export interface StreamingMetadata {
export interface ExplainEvent {
explainId: string;
explainGraph: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval)
explainTriples?: Triple[]; // Inline subgraph triples (when available)
}
// Configuration constants
@ -132,6 +133,7 @@ export interface Socket {
answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void,
error: (e: string) => void,
onExplain?: (event: ExplainEvent) => void,
collection?: string,
) => void;
// Streaming variants for RAG and completion services
@ -760,7 +762,7 @@ export class LibrarianApi {
},
30000,
)
.then((r) => r["document-metadata"] || null);
.then((r) => r["document-metadata"] || r.documentMetadata || null);
}
/**
@ -786,7 +788,7 @@ export class LibrarianApi {
"librarian",
{
operation: "add-document",
"document-metadata": {
documentMetadata: {
id: id,
time: Math.floor(Date.now() / 1000), // Unix timestamp
kind: mimeType,
@ -870,7 +872,7 @@ export class LibrarianApi {
"librarian",
{
operation: "begin-upload",
"document-metadata": metadata,
documentMetadata: metadata,
"total-size": totalSize,
"chunk-size": chunkSize,
},
@ -1398,6 +1400,7 @@ export class FlowApi {
answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void,
error: (s: string) => void,
onExplain?: (event: ExplainEvent) => void,
collection?: string,
) {
const receiver = (message: unknown) => {
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")
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?.({
explainId: resp.explain_id,
explainGraph: resp.explain_graph,
explainId: resp.explain_id ?? "",
explainGraph: resp.explain_graph ?? "",
explainTriples: resp.explain_triples as Triple[] | undefined,
});
return false;
}
@ -1428,7 +1432,7 @@ export class FlowApi {
// Handle streaming chunks by chunk_type
const content = resp.content || "";
const messageComplete = !!resp.end_of_message;
const dialogComplete = !!msg.complete;
const dialogComplete = !!msg.complete || !!resp.end_of_dialog;
// Extract metadata from final message
const metadata: StreamingMetadata | undefined = dialogComplete && (resp.in_token || resp.out_token || resp.model)
@ -1461,6 +1465,7 @@ export class FlowApi {
{
question: question,
user: this.api.user,
collection: collection ?? "default",
streaming: true, // Always use streaming mode
},
receiver,
@ -1509,19 +1514,23 @@ export class FlowApi {
return true;
}
// Handle explainability events
if (resp.message_type === "explain" && resp.explain_id && resp.explain_graph) {
// Extract explain data if present (may be embedded in the answer message)
if (resp.message_type === "explain" && (resp.explain_id || resp.explain_triples)) {
onExplain?.({
explainId: resp.explain_id,
explainGraph: resp.explain_graph,
explainId: resp.explain_id ?? "",
explainGraph: resp.explain_graph ?? "",
explainTriples: resp.explain_triples as Triple[] | undefined,
});
// Don't return true - more messages may follow
return false;
// If this message also carries answer text, fall through to chunk handling.
// 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)
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
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 complete = !!resp.end_of_session || !!msg.complete;
const complete = !!resp.end_of_session || !!resp.endOfStream || !!msg.complete;
// Extract metadata from final message
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 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:
@ -36,15 +36,17 @@ Action Input: {"argument_name": "value"}
Observation: [tool result will be inserted here]
... (repeat Thought/Action/Action Input/Observation as needed)
Thought: I now have enough information to answer.
Final Answer: [your comprehensive answer]
Final Answer: [your comprehensive answer based ONLY on tool observations]
Important:
- Always start with a Thought.
- Action must be one of: ${toolNames}
- Action Input must be valid JSON.
- After receiving an Observation, continue with another Thought.
- When you have enough information, provide a Final Answer.
- Do NOT make up observations. Wait for the tool result.`;
- When you have enough information from tool results, provide a Final Answer.
- 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 };
}

View file

@ -42,6 +42,7 @@ import {
createDocumentQueryTool,
createTriplesQueryTool,
createMcpTool,
type ExplainData,
} from "./tools.js";
import { buildReActPrompt } from "./prompt.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.
* 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) => {
const implType = tool.config?.["type"] as string | undefined;
@ -231,6 +237,7 @@ export class AgentService extends FlowProcessor {
const live = createKnowledgeQueryTool(
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
collection,
onExplain,
);
return { ...tool, execute: live.execute };
}
@ -274,17 +281,24 @@ export class AgentService extends FlowProcessor {
const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
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
let tools: AgentTool[];
if (this.configuredTools) {
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection);
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain);
} else {
// Hardcoded fallback (backward compat)
tools = [
createKnowledgeQueryTool(
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
msg.collection,
onExplain,
),
createDocumentQueryTool(
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) {
// 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, {
chunk_type: "answer",
content: parsed.finalAnswer,

View file

@ -16,6 +16,7 @@ import type {
ToolRequest,
ToolResponse,
Term,
Triple,
} from "@trustgraph/base";
import type { AgentTool, ToolArg } from "./types.js";
@ -55,12 +56,21 @@ function parseQuestion(input: string): string {
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.
*/
export function createKnowledgeQueryTool(
client: RequestResponse<GraphRagRequest, GraphRagResponse>,
collection?: string,
onExplain?: (data: ExplainData) => void,
): AgentTool {
return {
name: "KnowledgeQuery",
@ -75,7 +85,19 @@ export function createKnowledgeQueryTool(
],
async execute(input: string): Promise<string> {
const question = parseQuestion(input);
console.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${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}`;
return res.response;
},

View file

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

View file

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

View file

@ -55,13 +55,17 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
for (const vector of msg.vectors ?? []) {
const matches = await this.query.query({
vector,
user: "default",
user: msg.user ?? "default",
collection,
limit: msg.limit ?? 10,
});
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 {
chunkId: string;
score: number;
content?: string;
}
export interface DocEmbeddingsQueryRequest {
@ -71,6 +72,7 @@ export class QdrantDocEmbeddingsQuery {
chunks.push({
chunkId,
score: point.score,
content: (payload?.content as string) ?? undefined,
});
}
}

View file

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

View file

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

View file

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

View file

@ -94,6 +94,7 @@ export class GraphRagService extends FlowProcessor {
if (!requestId) return;
const producer = flowCtx.flow.producer<GraphRagResponse>("graph-rag-response");
console.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`);
try {
// 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,
});
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) {
console.error("[GraphRag] Query failed:", err);
await producer.send(requestId, {

View file

@ -46,6 +46,11 @@ export interface GraphRagClients {
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
export interface GraphRagResult {
answer: string;
subgraph: Triple[];
}
export class GraphRag {
private config: Required<GraphRagConfig>;
@ -58,7 +63,7 @@ export class GraphRag {
tripleLimit: config.tripleLimit ?? 30,
maxSubgraphSize: config.maxSubgraphSize ?? 1000,
maxPathLength: config.maxPathLength ?? 2,
edgeScoreLimit: config.edgeScoreLimit ?? 30,
edgeScoreLimit: config.edgeScoreLimit ?? 50,
edgeLimit: config.edgeLimit ?? 25,
};
}
@ -70,28 +75,39 @@ export class GraphRag {
streaming?: boolean;
chunkCallback?: ChunkCallback;
},
): Promise<string> {
): Promise<GraphRagResult> {
console.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`);
// Step 1: Extract concepts from the query via prompt + LLM
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
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
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
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
const scoredEdges = await this.scoreEdges(queryText, subgraph);
console.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`);
// 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,
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[]> {
@ -117,15 +133,17 @@ export class GraphRag {
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({
vectors,
user: "default",
collection: collection ?? "default",
limit: this.config.entityLimit,
});
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
const visited = new Set<string>();
const subgraph: Triple[] = [];
@ -150,6 +168,7 @@ export class GraphRag {
const term = stringToTerm(entityStr);
return this.clients.triples.request({
s: term,
collection,
limit: this.config.tripleLimit,
});
});
@ -192,7 +211,9 @@ export class GraphRag {
if (triples.length === 0) return [];
// 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;
}
@ -224,6 +245,7 @@ export class GraphRag {
});
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
// 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 (result.length === 0) {
return triples.slice(0, this.config.edgeLimit);

View file

@ -19,6 +19,7 @@ export interface QdrantDocEmbeddingsConfig {
export interface DocEmbeddingChunk {
chunkId: string;
vector: number[];
content?: string;
}
export interface DocEmbeddingsMessage {
@ -73,7 +74,10 @@ export class QdrantDocEmbeddingsStore {
{
id: randomUUID(),
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">
<head>
<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" />
<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>
<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>
// Restore theme preference before first paint to avoid flash
// Restore theme preference before first paint
(function() {
var theme = localStorage.getItem('tg-theme');
if (theme === 'light') {
@ -18,7 +90,17 @@
}
})();
</script>
<div id="root"></div>
<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>
</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();
}, [expanded]);
// Fetch triples when first expanded
// Load triples when first expanded — use inline triples if available, otherwise fetch
useEffect(() => {
if (!expanded || fetched) return;
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);
setError(null);
const flow = socket.flow(flowId);
// Fetch triples for each explain event's named graph and merge
Promise.all(
explainEvents.map((ev) =>
graphUris.map((ev) =>
flow
.triplesQuery(undefined, undefined, undefined, 500, collection, ev.explainGraph)
.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 { Sidebar } from "./sidebar";
import { FlowSelector } from "./flow-selector";
import { GlowBackground } from "./glow-background";
import { useProgressStore } from "@/hooks/use-progress-store";
import { useConnectionState } from "@/providers/socket-provider";
@ -44,9 +45,12 @@ export function RootLayout() {
<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 */}
<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 />
</header>
@ -59,7 +63,7 @@ export function RootLayout() {
)}
{/* 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 />
</main>
</div>

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { useCallback, useState } from "react";
import { useSocket } from "@/providers/socket-provider";
import { useSettings } from "@/providers/settings-provider";
import { useProgressStore } from "./use-progress-store";
import type { DocumentMetadata } from "@trustgraph/client";
@ -15,6 +16,14 @@ export interface ProcessingMetadata {
[key: string]: unknown;
}
export interface UploadProgress {
phase: "preparing" | "uploading" | "finalizing";
chunksTotal: number;
chunksUploaded: number;
bytesTotal: number;
bytesUploaded: number;
}
export interface UseLibraryReturn {
documents: DocumentMetadata[];
processing: ProcessingMetadata[];
@ -23,7 +32,7 @@ export interface UseLibraryReturn {
/** Refresh the documents list */
getDocuments: () => Promise<void>;
/** Upload a new document */
/** Upload a new document (auto-selects simple vs chunked based on size) */
uploadDocument: (
document: string,
mimeType: string,
@ -32,10 +41,21 @@ export interface UseLibraryReturn {
tags: string[],
id?: string,
) => 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 */
removeDocument: (id: string, collection?: string) => Promise<void>;
/** Get the list of currently-processing documents */
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 {
const socket = useSocket();
const user = useSettings((s) => s.settings.user);
const addActivity = useProgressStore((s) => s.addActivity);
const removeActivity = useProgressStore((s) => s.removeActivity);
@ -108,6 +129,84 @@ export function useLibrary(): UseLibraryReturn {
[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 act = "Load processing";
try {
@ -121,6 +220,18 @@ export function useLibrary(): UseLibraryReturn {
}
}, [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 {
documents,
processing,
@ -128,7 +239,9 @@ export function useLibrary(): UseLibraryReturn {
error,
getDocuments,
uploadDocument,
uploadDocumentChunked,
removeDocument,
getProcessing,
getDocumentMetadata,
};
}

View file

@ -8,17 +8,17 @@
*/
@theme {
/* Brand palette */
--color-brand-50: #eef2ff;
--color-brand-100: #dce4ff;
--color-brand-200: #b9c9ff;
--color-brand-300: #8aa5ff;
--color-brand-400: #5b80ff;
--color-brand-500: #3b63ed;
--color-brand-600: #2d4ec4;
--color-brand-700: #213a9b;
--color-brand-800: #162872;
--color-brand-900: #0e1a4d;
/* Brand palette — Forest green */
--color-brand-50: #eef5ee;
--color-brand-100: #d4e8d4;
--color-brand-200: #aed1ae;
--color-brand-300: #82b582;
--color-brand-400: #5c9a5c;
--color-brand-500: #3d7d3d;
--color-brand-600: #2d632d;
--color-brand-700: #214e21;
--color-brand-800: #1a3a1a;
--color-brand-900: #122812;
/* Surface / background colors (dark-first) */
--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 */
@layer base {
button:focus-visible,
@ -142,8 +175,8 @@ html.light {
--color-border-hover: #a1a1aa;
/* Brand adjustments for light backgrounds */
--color-brand-300: #3b63ed;
--color-brand-400: #2d4ec4;
--color-brand-300: #2d632d;
--color-brand-400: #214e21;
/* Semantic colors stay vivid but slightly darker for contrast */
--color-success: #16a34a;

View file

@ -34,3 +34,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
</QueryClientProvider>
</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;
isActive: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
if (!content && !isActive) return null;
// Auto-expand while actively streaming; user can override
const expanded = manualToggle ?? isActive;
const phaseColors: Record<string, string> = {
think: "border-amber-500/30 bg-amber-500/5",
observe: "border-sky-500/30 bg-sky-500/5",
@ -79,7 +82,7 @@ function AgentPhaseBlock({
)}
>
<button
onClick={() => setExpanded((p) => !p)}
onClick={() => setManualToggle((prev) => !(prev ?? isActive))}
aria-expanded={expanded}
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" />
)}
</button>
{expanded && content && (
{expanded && (content || isActive) && (
<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>

View file

@ -8,6 +8,7 @@ import {
ChevronRight,
Loader2,
AlertTriangle,
Info,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFlows, type FlowSummary } from "@/hooks/use-flows";
@ -44,6 +45,9 @@ function StartFlowDialog({
const [submitting, setSubmitting] = useState(false);
const [paramsError, setParamsError] = useState<string | null>(null);
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
useEffect(() => {
@ -64,6 +68,48 @@ function StartFlowDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 = () => {
setId("");
setBlueprint("");
@ -72,6 +118,9 @@ function StartFlowDialog({
setParamsError(null);
setSubmitting(false);
setSubmitted(false);
setBlueprintDef(null);
setLoadingDef(false);
setDefExpanded(false);
};
const handleSubmit = async () => {
@ -123,7 +172,7 @@ function StartFlowDialog({
</button>
<button
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"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
@ -180,6 +229,92 @@ function StartFlowDialog({
{submitted && !blueprint && (
<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>
{/* Description */}
@ -241,25 +376,41 @@ function StopFlowDialog({
open: boolean;
flowId: string;
onClose: () => void;
onConfirm: () => void;
onConfirm: () => Promise<void>;
}) {
const [stopping, setStopping] = useState(false);
const handleStop = async () => {
setStopping(true);
try {
await onConfirm();
} finally {
setStopping(false);
}
};
return (
<Dialog
open={open}
onClose={onClose}
onClose={() => {
if (!stopping) onClose();
}}
title="Stop Flow"
footer={
<>
<button
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
</button>
<button
onClick={onConfirm}
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
onClick={handleStop}
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
</button>
</>

View file

@ -313,7 +313,7 @@ export default function GraphPage() {
const zoomFit = () =>
fgRef.current?.zoomToFit(400, 40);
// Node paint callback
// Node paint callback — with glow effect
const paintNode = useCallback(
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
const isSelected = node.id === selectedNode;
@ -324,18 +324,40 @@ export default function GraphPage() {
const x = node.x ?? 0;
const y = node.y ?? 0;
// Node circle
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = dim
const baseColor = dim
? "rgba(100,100,100,0.3)"
: isSelected
? "#fbbf24"
: isMatch
? "#22c55e"
: 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();
// 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) {
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
ctx.lineWidth = 1.5 / globalScale;
@ -344,7 +366,7 @@ export default function GraphPage() {
// Label
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.textBaseline = "top";
const isLight = document.documentElement.classList.contains("light");
@ -353,7 +375,7 @@ export default function GraphPage() {
: isLight
? "rgba(24,24,27,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],
);
@ -631,9 +653,16 @@ export default function GraphPage() {
}}
linkCanvasObjectMode={() => "after"}
linkCanvasObject={paintLink}
linkColor={() => "rgba(91,128,255,0.25)"}
linkDirectionalArrowLength={4}
linkColor={() => "rgba(91,128,255,0.18)"}
linkWidth={1.5}
linkDirectionalArrowLength={5}
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) => {
setSelectedNode((prev) =>
prev === node.id ? null : node.id,
@ -641,6 +670,8 @@ export default function GraphPage() {
}}
onBackgroundClick={() => setSelectedNode(null)}
backgroundColor="transparent"
cooldownTicks={100}
warmupTicks={30}
width={containerSize?.width}
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 {
LibraryBig,
Upload,
@ -9,15 +9,23 @@ import {
Loader2,
X,
AlertTriangle,
Search,
Eye,
Clock,
Tag,
Hash,
} from "lucide-react";
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 { useNotification } from "@/providers/notification-provider";
import { Dialog } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
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
// ---------------------------------------------------------------------------
@ -26,6 +34,7 @@ function UploadDialog({
open,
onClose,
onUpload,
onUploadChunked,
onError,
}: {
open: boolean;
@ -37,6 +46,14 @@ function UploadDialog({
comments: string,
tags: string[],
) => Promise<void>;
onUploadChunked: (
data: string,
mimeType: string,
title: string,
comments: string,
tags: string[],
onProgress: (progress: UploadProgress) => void,
) => Promise<void>;
onError?: (msg: string) => void;
}) {
const [file, setFile] = useState<File | null>(null);
@ -45,6 +62,7 @@ function UploadDialog({
const [comments, setComments] = useState("");
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [progress, setProgress] = useState<UploadProgress | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const reset = () => {
@ -53,6 +71,7 @@ function UploadDialog({
setTags("");
setComments("");
setUploading(false);
setProgress(null);
};
const titleRef = useRef(title);
@ -79,15 +98,26 @@ function UploadDialog({
.split(",")
.map((t) => t.trim())
.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();
onClose();
} catch (err) {
onError?.(err instanceof Error ? err.message : "Upload failed");
setUploading(false);
setProgress(null);
}
};
const progressPercent = progress
? Math.round((progress.chunksUploaded / Math.max(progress.chunksTotal, 1)) * 100)
: 0;
return (
<Dialog
open={open}
@ -151,10 +181,12 @@ function UploadDialog({
<div className="flex items-center gap-2 text-sm text-fg">
<FileText className="h-4 w-4" />
<span>{file.name}</span>
<span className="text-xs text-fg-subtle">({formatBytes(file.size)})</span>
<button
onClick={(e) => {
e.stopPropagation();
setFile(null);
setTitle("");
}}
aria-label="Remove selected file"
className="ml-1 text-fg-subtle hover:text-fg"
@ -182,6 +214,28 @@ function UploadDialog({
/>
</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 */}
<div className="mb-3 space-y-1.5">
<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
// ---------------------------------------------------------------------------
@ -286,14 +444,20 @@ export default function LibraryPage() {
error,
getDocuments,
uploadDocument,
uploadDocumentChunked,
removeDocument,
getProcessing,
getDocumentMetadata,
} = useLibrary();
const collection = useSettings((s) => s.settings.collection);
const notify = useNotification();
const [uploadOpen, setUploadOpen] = useState(false);
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
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 () => {
if (!deleteTarget?.id) return;
if (!deleteTarget?.id) {
setDeleteTarget(null);
return;
}
try {
await removeDocument(deleteTarget.id, collection);
notify.success("Document deleted");
@ -329,6 +513,20 @@ export default function LibraryPage() {
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 = () => {
getDocuments();
getProcessing();
@ -343,6 +541,24 @@ export default function LibraryPage() {
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 (
<div className="flex h-full flex-col">
{/* Header */}
@ -374,6 +590,31 @@ export default function LibraryPage() {
</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.length > 0 && (
<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>
)}
{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">
<table className="w-full text-left text-sm">
<thead className="border-b border-border bg-surface-100 text-fg-muted">
@ -429,7 +677,7 @@ export default function LibraryPage() {
</tr>
</thead>
<tbody className="divide-y divide-border">
{documents.map((doc) => (
{filteredDocuments.map((doc) => (
<tr key={doc.id} className="hover:bg-surface-100/50">
<td className="px-4 py-3 text-fg">
<div className="flex items-center gap-2">
@ -454,13 +702,24 @@ export default function LibraryPage() {
{doc.id}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setDeleteTarget(doc)}
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
title="Delete document"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleViewDetail(doc)}
className="rounded p-1.5 text-fg-subtle hover:bg-surface-200 hover:text-fg"
title="View details"
aria-label="View document details"
>
<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>
</tr>
))}
@ -469,11 +728,20 @@ export default function LibraryPage() {
</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 */}
<UploadDialog
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUpload={handleUpload}
onUploadChunked={handleUploadChunked}
onError={(msg) => notify.error("Upload failed", msg)}
/>
@ -483,6 +751,16 @@ export default function LibraryPage() {
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
/>
<DocumentDetailDialog
open={detailOpen}
doc={detailDoc}
loading={loadingDetail}
onClose={() => {
setDetailOpen(false);
setDetailDoc(null);
}}
/>
</div>
);
}
@ -504,3 +782,9 @@ function fileToBase64(file: File): Promise<string> {
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,
Moon,
Sun,
Plus,
Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettings } from "@/providers/settings-provider";
@ -21,6 +23,7 @@ import { useFlows } from "@/hooks/use-flows";
import { useSessionStore } from "@/hooks/use-session-store";
import { useNotification } from "@/providers/notification-provider";
import { Badge } from "@/components/ui/badge";
import { Dialog } from "@/components/ui/dialog";
// ---------------------------------------------------------------------------
// Helpers
@ -82,6 +85,18 @@ export default function SettingsPage() {
>([]);
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
const [isDark, setIsDark] = useState(() => {
if (typeof window === "undefined") return true;
@ -106,32 +121,118 @@ export default function SettingsPage() {
}
}, [isDark]);
// Fetch collections
useEffect(() => {
let cancelled = false;
// Reusable function to fetch collections from the backend
const refreshCollections = useCallback(() => {
setLoadingCollections(true);
socket
return socket
.collectionManagement()
.listCollections()
.then((cols) => {
if (!cancelled) {
setCollections(
Array.isArray(cols)
? (cols as Array<{ id?: string; name?: string; [key: string]: unknown }>)
: [],
);
const list = Array.isArray(cols)
? (cols as Array<{ id?: string; collection?: 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(() => {
/* silent -- collections endpoint may not be available */
// Fallback: at minimum show "default"
setCollections([{ id: "default", collection: "default", name: "default" }]);
})
.finally(() => {
if (!cancelled) setLoadingCollections(false);
setLoadingCollections(false);
});
}, [socket]);
// Fetch collections on mount
useEffect(() => {
let cancelled = false;
refreshCollections().then(() => {
if (cancelled) return;
});
return () => {
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
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"
/>
<p className="text-xs text-fg-subtle">
The WebSocket URL for the TrustGraph gateway.
The WebSocket URL for the Beep Graph gateway.
</p>
</div>
@ -253,33 +354,193 @@ export default function SettingsPage() {
collections...
</div>
) : collections.length > 0 ? (
<select
id="settings-collection"
value={settings.collection}
onChange={(e) => updateSetting("collection", e.target.value)}
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"
>
{collections.map((c) => {
const id = c.id ?? String(c.name ?? c);
return (
<option key={id} value={id}>
{c.name ?? id}
</option>
);
})}
</select>
<div className="flex items-center gap-2">
<select
id="settings-collection"
value={settings.collection}
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 cObj = c as { collection?: string; id?: string; name?: string };
const collId = cObj.collection ?? cObj.id ?? String(cObj.name ?? c);
const label = cObj.name ?? collId;
return (
<option key={collId} value={collId}>
{label !== collId ? `${label} (${collId})` : collId}
</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
id="settings-collection"
type="text"
value={settings.collection}
onChange={(e) => updateSetting("collection", e.target.value)}
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 className="flex items-center gap-2">
<input
id="settings-collection"
type="text"
value={settings.collection}
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>
</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 */}
<Section
title="Active Flow"
@ -391,11 +652,11 @@ export default function SettingsPage() {
>
<div className="space-y-2 text-sm text-fg-muted">
<p>
<span className="font-medium text-fg">TrustGraph Workbench</span>{" "}
<span className="font-medium text-fg">Beep Graph</span>{" "}
v0.1.0
</p>
<p>
A web-based interface for interacting with the TrustGraph
A web-based interface for interacting with the Beep Graph
knowledge-graph system.
</p>
</div>

View file

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