mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
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:
parent
87f6e5eb05
commit
ee45cb4850
42 changed files with 1690 additions and 153 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ----------
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 } : {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
BIN
ts/packages/workbench/public/apple-touch-icon.png
Normal file
BIN
ts/packages/workbench/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
ts/packages/workbench/public/favicon-16.png
Normal file
BIN
ts/packages/workbench/public/favicon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 587 B |
BIN
ts/packages/workbench/public/favicon-32.png
Normal file
BIN
ts/packages/workbench/public/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
12
ts/packages/workbench/public/favicon.svg
Normal file
12
ts/packages/workbench/public/favicon.svg
Normal 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 |
BIN
ts/packages/workbench/public/icon-192.png
Normal file
BIN
ts/packages/workbench/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8 KiB |
BIN
ts/packages/workbench/public/icon-512.png
Normal file
BIN
ts/packages/workbench/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
28
ts/packages/workbench/public/manifest.json
Normal file
28
ts/packages/workbench/public/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
37
ts/packages/workbench/public/sw.js
Normal file
37
ts/packages/workbench/public/sw.js
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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[]),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,8 @@ export function useChat(): UseChatReturn {
|
||||||
onError,
|
onError,
|
||||||
// explainability
|
// explainability
|
||||||
onExplain,
|
onExplain,
|
||||||
|
// collection
|
||||||
|
collection,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: "",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue