This commit is contained in:
elpresidank 2026-04-05 22:44:45 -05:00
parent c386f68743
commit b6536eca38
100 changed files with 17680 additions and 377 deletions

View file

@ -15,6 +15,7 @@
"prom-client": "^15.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
}

View file

@ -13,10 +13,11 @@ import {
type NatsConnection,
type JetStreamClient,
type JetStreamManager,
type ConsumerMessages,
type Consumer as NatsJsConsumer,
type JsMsg,
StringCodec,
AckPolicy,
DeliverPolicy,
} from "nats";
import type {
@ -31,17 +32,22 @@ import type {
const sc = StringCodec();
class NatsMessage<T> implements Message<T> {
/** Exposed so acknowledge/negativeAcknowledge can access the raw JsMsg */
readonly _jsMsg: JsMsg;
constructor(
private readonly msg: JsMsg,
msg: JsMsg,
private readonly decoded: T,
) {}
) {
this._jsMsg = msg;
}
value(): T {
return this.decoded;
}
properties(): Record<string, string> {
const headers = this.msg.headers;
const headers = this._jsMsg.headers;
const props: Record<string, string> = {};
if (headers) {
for (const [key, values] of headers) {
@ -84,7 +90,7 @@ class NatsProducer<T> implements BackendProducer<T> {
}
class NatsConsumer<T> implements BackendConsumer<T> {
private messages: ConsumerMessages | null = null;
private consumer: NatsJsConsumer | null = null;
constructor(
private readonly js: JetStreamClient,
@ -106,43 +112,57 @@ class NatsConsumer<T> implements BackendConsumer<T> {
});
}
// Create or bind to durable consumer
const consumer = await this.js.consumers.get(streamName, this.subscription);
this.messages = await consumer.consume();
// Create or bind to durable consumer.
// Try to get an existing durable consumer first; if it doesn't exist, create it.
try {
this.consumer = await this.js.consumers.get(streamName, this.subscription);
} catch {
const deliverPolicy =
this.initialPosition === "earliest"
? DeliverPolicy.All
: DeliverPolicy.New;
await this.jsm.consumers.add(streamName, {
durable_name: this.subscription,
ack_policy: AckPolicy.Explicit,
deliver_policy: deliverPolicy,
filter_subject: this.subject,
});
this.consumer = await this.js.consumers.get(streamName, this.subscription);
}
}
async receive(timeoutMs = 2000): Promise<Message<T> | null> {
if (!this.messages) throw new Error("Consumer not initialized");
if (!this.consumer) throw new Error("Consumer not initialized");
const deadline = Date.now() + timeoutMs;
for await (const msg of this.messages) {
const decoded = JSON.parse(sc.decode(msg.data)) as T;
return new NatsMessage(msg, decoded);
}
// Pull a single message with a timeout using the pull-based API.
// consumer.next() returns a JsMsg or null when the timeout expires.
const msg = await this.consumer.next({ expires: timeoutMs });
if (!msg) return null;
if (Date.now() >= deadline) return null;
return null;
const decoded = JSON.parse(sc.decode(msg.data)) as T;
return new NatsMessage(msg, decoded);
}
async acknowledge(message: Message<T>): Promise<void> {
const natsMsg = message as NatsMessage<T>;
// Access internal JsMsg for ack — in practice we'd store the ref
// This is a simplified version; real impl tracks msg refs
void natsMsg;
natsMsg._jsMsg.ack();
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
void message;
const natsMsg = message as NatsMessage<T>;
natsMsg._jsMsg.nak();
}
async unsubscribe(): Promise<void> {
// Drain and close consumer
// The pull-based consumer does not have a persistent subscription to drain.
// Clearing the reference is sufficient; the durable consumer persists server-side.
this.consumer = null;
}
async close(): Promise<void> {
if (this.messages) {
this.messages.stop();
}
this.consumer = null;
}
private streamNameFromSubject(subject: string): string {

View file

@ -5,6 +5,7 @@
*/
import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js";
import type { Flow } from "../processor/flow.js";
import { TooManyRequestsError } from "../errors.js";
export type MessageHandler<T> = (
@ -16,6 +17,8 @@ export type MessageHandler<T> = (
export interface FlowContext {
id: string;
name: string;
/** Reference to the owning Flow instance, giving handlers access to producers and parameters. */
flow: Flow;
}
export interface ConsumerOptions<T> {

View file

@ -34,9 +34,9 @@ export class Flow {
await spec.add(this, this.pubsub, this.definition);
}
// Start all consumers
// Start all consumers, passing this Flow instance via FlowContext
for (const consumer of this.consumers.values()) {
consumer.start({ id: this.processorId, name: this.name }).catch((err) => {
consumer.start({ id: this.processorId, name: this.name, flow: this }).catch((err) => {
console.error(`[Flow:${this.name}] Consumer error:`, err);
});
}

View file

@ -29,16 +29,24 @@ export abstract class EmbeddingsService extends FlowProcessor {
private async onRequest(
msg: EmbeddingsRequest,
properties: Record<string, string>,
_flowCtx: FlowContext,
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
const responseProducer = flowCtx.flow.producer<EmbeddingsResponse>("response");
try {
const vectors = await this.onEmbeddings(msg.text, msg.model);
void vectors; // Producer send would go here
await responseProducer.send(requestId, { vectors });
} catch (err) {
console.error(`[EmbeddingsService] Error processing request:`, err);
const message = err instanceof Error ? err.message : String(err);
await responseProducer.send(requestId, {
vectors: [],
error: { type: "embeddings-error", message },
});
}
}

View file

@ -10,7 +10,6 @@ import { ProducerSpec } from "../spec/producer-spec.js";
import { ParameterSpec } from "../spec/parameter-spec.js";
import type { ProcessorConfig } from "../processor/async-processor.js";
import type { FlowContext } from "../messaging/consumer.js";
import type { Flow } from "../processor/flow.js";
import type {
TextCompletionRequest,
TextCompletionResponse,
@ -37,12 +36,11 @@ export abstract class LlmService extends FlowProcessor {
properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
// We need the actual flow instance to access producers/parameters.
// In the full implementation, FlowContext would carry a flow reference.
// For now this shows the pattern.
const requestId = properties.id;
if (!requestId) return;
const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("response");
try {
if (msg.streaming && this.supportsStreaming()) {
for await (const chunk of this.generateContentStream(
@ -51,8 +49,13 @@ export abstract class LlmService extends FlowProcessor {
msg.model,
msg.temperature,
)) {
// Send each chunk as a response with the same request ID
void chunk; // Producer send would go here
await responseProducer.send(requestId, {
response: chunk.text,
model: chunk.model,
inToken: chunk.inToken ?? undefined,
outToken: chunk.outToken ?? undefined,
endOfStream: chunk.isFinal,
});
}
} else {
const result = await this.generateContent(
@ -61,10 +64,24 @@ export abstract class LlmService extends FlowProcessor {
msg.model,
msg.temperature,
);
void result; // Producer send would go here
await responseProducer.send(requestId, {
response: result.text,
model: result.model,
inToken: result.inToken,
outToken: result.outToken,
endOfStream: true,
});
}
} catch (err) {
console.error(`[LlmService] Error processing request:`, err);
const message = err instanceof Error ? err.message : String(err);
await responseProducer.send(requestId, {
response: "",
error: { type: "llm-error", message },
endOfStream: true,
});
}
}

View file

@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"composite": true
},
"include": ["src"]
}

View file

@ -13,7 +13,7 @@
},
"dependencies": {
"@trustgraph/base": "workspace:*",
"@trustgraph/mcp": "workspace:*",
"@trustgraph/client": "workspace:*",
"commander": "^13.1.0",
"ws": "^8.18.0"
},

View file

@ -17,18 +17,32 @@ export function registerAgentCommands(program: Command): void {
const socket = await createSocket(opts);
try {
const resp = await socket.request("agent", { question }, {
flowId: opts.flow,
onChunk: (chunk) => {
const c = chunk as { answer?: string };
if (c.answer) process.stdout.write(c.answer);
},
});
const flow = socket.flow(opts.flow);
const r = resp as { answer?: string };
if (r.answer) console.log(r.answer);
await new Promise<void>((resolve, reject) => {
flow.agent(
question,
(chunk) => {
// think — show thought process
if (chunk) process.stderr.write(chunk);
},
(chunk) => {
// observe — show observations
if (chunk) process.stderr.write(chunk);
},
(chunk, complete) => {
// answer — print to stdout
if (chunk) process.stdout.write(chunk);
if (complete) {
process.stdout.write("\n");
resolve();
}
},
(err) => reject(new Error(err)),
);
});
} finally {
await socket.close();
socket.close();
}
});
}

View file

@ -20,62 +20,96 @@ export function registerConfigCommands(program: Command): void {
const socket = await createSocket(opts);
try {
const resp = await socket.request("config", { operation: "config" });
const cfg = socket.config();
const resp = await cfg.getConfigAll();
console.log(JSON.stringify(resp, null, 2));
} finally {
await socket.close();
socket.close();
}
});
config
.command("get")
.description("Get a configuration value")
.argument("<key>", "Config key")
.argument("<key>", "Config key (format: type/key)")
.action(async (key: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const resp = await socket.request("config", { operation: "get", keys: [key] });
const cfg = socket.config();
// Support "type/key" format; fall back to using the whole string as key
const parts = key.split("/");
const configKey =
parts.length >= 2
? { type: parts[0], key: parts.slice(1).join("/") }
: { type: "config", key };
const resp = await cfg.getConfig([configKey]);
console.log(JSON.stringify(resp, null, 2));
} finally {
await socket.close();
socket.close();
}
});
config
.command("set")
.description("Set a configuration value")
.argument("<key>", "Config key")
.argument("<key>", "Config key (format: type/key)")
.argument("<value>", "Config value (JSON)")
.action(async (key: string, value: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const parsed = JSON.parse(value);
const resp = await socket.request("config", {
operation: "put",
values: { [key]: parsed },
});
const cfg = socket.config();
const parts = key.split("/");
const configEntry =
parts.length >= 2
? { type: parts[0], key: parts.slice(1).join("/"), value }
: { type: "config", key, value };
const resp = await cfg.putConfig([configEntry]);
console.log(JSON.stringify(resp, null, 2));
} finally {
await socket.close();
socket.close();
}
});
config
.command("list")
.description("List configuration keys")
.action(async (_opts, cmd) => {
.description("List configuration keys for a type")
.argument("[type]", "Config type to list", "config")
.action(async (type: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const resp = await socket.request("config", { operation: "list" });
const cfg = socket.config();
const resp = await cfg.list(type);
console.log(JSON.stringify(resp, null, 2));
} finally {
await socket.close();
socket.close();
}
});
config
.command("delete")
.description("Delete a configuration entry")
.argument("<key>", "Config key (format: type/key)")
.action(async (key: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const cfg = socket.config();
const parts = key.split("/");
const configKey =
parts.length >= 2
? { type: parts[0], key: parts.slice(1).join("/") }
: { type: "config", key };
const resp = await cfg.deleteConfig(configKey);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
}

View file

@ -0,0 +1,27 @@
/**
* Embeddings CLI commands.
*
* Generate text embeddings using the configured embedding model.
*/
import type { Command } from "commander";
import { createSocket, getOpts } from "./util.js";
export function registerEmbeddingsCommands(program: Command): void {
program
.command("embeddings")
.description("Generate text embeddings")
.argument("<text...>", "Text(s) to embed")
.action(async (texts: string[], _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const flow = socket.flow(opts.flow);
const vectors = await flow.embeddings(texts);
console.log(JSON.stringify(vectors, null, 2));
} finally {
socket.close();
}
});
}

View file

@ -20,61 +20,73 @@ export function registerFlowCommands(program: Command): void {
const socket = await createSocket(opts);
try {
const resp = await socket.request("flow", { operation: "list" });
console.log(JSON.stringify(resp, null, 2));
const flows = socket.flows();
const ids = await flows.getFlows();
console.log(JSON.stringify(ids, null, 2));
} finally {
await socket.close();
socket.close();
}
});
flow
.command("get")
.description("Get a flow definition")
.argument("<id>", "Flow ID")
.action(async (id: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const flows = socket.flows();
const def = await flows.getFlow(id);
console.log(JSON.stringify(def, null, 2));
} finally {
socket.close();
}
});
flow
.command("start")
.description("Start a flow")
.argument("<name>", "Flow name")
.action(async (name: string, _opts, cmd) => {
.argument("<id>", "Flow ID")
.requiredOption("-b, --blueprint <name>", "Blueprint name")
.option("-d, --description <text>", "Flow description", "")
.option("-p, --parameters <json>", "Parameters as JSON")
.action(async (id: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const resp = await socket.request("flow", { operation: "start", name });
const flows = socket.flows();
const params = cmdOpts.parameters
? JSON.parse(cmdOpts.parameters as string)
: undefined;
const resp = await flows.startFlow(
id,
cmdOpts.blueprint as string,
cmdOpts.description as string,
params as Record<string, unknown> | undefined,
);
console.log(JSON.stringify(resp, null, 2));
} finally {
await socket.close();
socket.close();
}
});
flow
.command("stop")
.description("Stop a flow")
.argument("<name>", "Flow name")
.action(async (name: string, _opts, cmd) => {
.argument("<id>", "Flow ID")
.action(async (id: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const resp = await socket.request("flow", { operation: "stop", name });
const flows = socket.flows();
const resp = await flows.stopFlow(id);
console.log(JSON.stringify(resp, null, 2));
} finally {
await socket.close();
}
});
flow
.command("status")
.description("Show flow status")
.argument("[name]", "Flow name (all if omitted)")
.action(async (name: string | undefined, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const resp = await socket.request("flow", {
operation: "status",
...(name ? { name } : {}),
});
console.log(JSON.stringify(resp, null, 2));
} finally {
await socket.close();
socket.close();
}
});
}

View file

@ -1,5 +1,5 @@
/**
* Graph RAG CLI commands.
* Graph RAG and Document RAG CLI commands.
*
* Python reference: trustgraph-cli/trustgraph/cli/invoke_graph_rag.py
*/
@ -14,24 +14,24 @@ export function registerGraphRagCommands(program: Command): void {
.argument("<query>", "Natural language query")
.option("--entity-limit <n>", "Max entities", "50")
.option("--triple-limit <n>", "Max triples per entity", "30")
.option("--collection <name>", "Collection name")
.action(async (query: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const resp = await socket.request(
"graph-rag",
const flow = socket.flow(opts.flow);
const response = await flow.graphRag(
query,
{
query,
entity_limit: parseInt(cmdOpts.entityLimit, 10),
triple_limit: parseInt(cmdOpts.tripleLimit, 10),
entityLimit: parseInt(cmdOpts.entityLimit, 10),
tripleLimit: parseInt(cmdOpts.tripleLimit, 10),
},
{ flowId: opts.flow },
) as { response?: string };
console.log(resp.response ?? JSON.stringify(resp, null, 2));
cmdOpts.collection,
);
console.log(response);
} finally {
await socket.close();
socket.close();
}
});
@ -39,20 +39,22 @@ export function registerGraphRagCommands(program: Command): void {
.command("document-rag")
.description("Query documents using RAG")
.argument("<query>", "Natural language query")
.action(async (query: string, _cmdOpts, cmd) => {
.option("--doc-limit <n>", "Max documents", "20")
.option("--collection <name>", "Collection name")
.action(async (query: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const resp = await socket.request(
"document-rag",
{ query },
{ flowId: opts.flow },
) as { response?: string };
console.log(resp.response ?? JSON.stringify(resp, null, 2));
const flow = socket.flow(opts.flow);
const response = await flow.documentRag(
query,
cmdOpts.docLimit ? parseInt(cmdOpts.docLimit, 10) : undefined,
cmdOpts.collection,
);
console.log(response);
} finally {
await socket.close();
socket.close();
}
});
}

View file

@ -0,0 +1,126 @@
/**
* Document library CLI commands.
*
* Manages documents stored in the TrustGraph library.
*/
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import type { Command } from "commander";
import { createSocket, getOpts } from "./util.js";
/** Simple MIME-type lookup by file extension. */
function guessMimeType(filepath: string): string {
const ext = filepath.split(".").pop()?.toLowerCase();
switch (ext) {
case "pdf":
return "application/pdf";
case "txt":
return "text/plain";
case "md":
return "text/markdown";
case "html":
case "htm":
return "text/html";
case "json":
return "application/json";
case "csv":
return "text/csv";
case "docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
default:
return "application/octet-stream";
}
}
export function registerLibraryCommands(program: Command): void {
const library = program
.command("library")
.description("Document library management");
library
.command("list")
.description("List documents in the library")
.action(async (_opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const lib = socket.librarian();
const docs = await lib.getDocuments();
console.log(JSON.stringify(docs, null, 2));
} finally {
socket.close();
}
});
library
.command("load")
.description("Load a document into the library")
.argument("<file>", "Path to the file to load")
.option("-t, --title <title>", "Document title")
.option("-m, --mime-type <type>", "MIME type (auto-detected if omitted)")
.option("-c, --comments <text>", "Comments", "")
.option("--tags <tags...>", "Document tags")
.option("--id <id>", "Optional document ID")
.action(async (file: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const lib = socket.librarian();
const data = readFileSync(file);
const b64 = data.toString("base64");
const mimeType = (cmdOpts.mimeType as string | undefined) ?? guessMimeType(file);
const title = (cmdOpts.title as string | undefined) ?? basename(file);
const comments = cmdOpts.comments as string;
const tags: string[] = (cmdOpts.tags as string[] | undefined) ?? [];
const resp = await lib.loadDocument(
b64,
mimeType,
title,
comments,
tags,
cmdOpts.id as string | undefined,
);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
library
.command("remove")
.description("Remove a document from the library")
.argument("<id>", "Document ID to remove")
.option("--collection <name>", "Collection name")
.action(async (id: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const lib = socket.librarian();
const resp = await lib.removeDocument(id, cmdOpts.collection as string | undefined);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
library
.command("processing")
.description("List documents currently being processed")
.action(async (_opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const lib = socket.librarian();
const items = await lib.getProcessing();
console.log(JSON.stringify(items, null, 2));
} finally {
socket.close();
}
});
}

View file

@ -0,0 +1,48 @@
/**
* Triples query CLI commands.
*
* Query the knowledge graph for subject-predicate-object triples.
*/
import type { Command } from "commander";
import type { Term } from "@trustgraph/client";
import { createSocket, getOpts } from "./util.js";
export function registerTriplesCommands(program: Command): void {
program
.command("triples")
.description("Query knowledge graph triples")
.option("-s, --subject <iri>", "Subject IRI")
.option("-p, --predicate <iri>", "Predicate IRI")
.option("-o, --object <iri>", "Object IRI or literal")
.option("-l, --limit <n>", "Max results", "20")
.option("--collection <name>", "Collection name")
.action(async (cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
const flow = socket.flow(opts.flow);
const s: Term | undefined = cmdOpts.subject
? { t: "i", i: cmdOpts.subject as string }
: undefined;
const p: Term | undefined = cmdOpts.predicate
? { t: "i", i: cmdOpts.predicate as string }
: undefined;
const o: Term | undefined = cmdOpts.object
? { t: "i", i: cmdOpts.object as string }
: undefined;
const triples = await flow.triplesQuery(
s,
p,
o,
parseInt(cmdOpts.limit as string, 10),
cmdOpts.collection as string | undefined,
);
console.log(JSON.stringify(triples, null, 2));
} finally {
socket.close();
}
});
}

View file

@ -3,10 +3,11 @@
*/
import type { Command } from "commander";
import { SocketManager } from "@trustgraph/mcp";
import { createTrustGraphSocket, type BaseApi } from "@trustgraph/client";
export interface CliOpts {
gateway: string;
user: string;
token?: string;
flow: string;
}
@ -18,11 +19,36 @@ export function getOpts(cmd: Command): CliOpts {
return root.opts() as CliOpts;
}
export async function createSocket(opts: CliOpts): Promise<SocketManager> {
const socket = new SocketManager({
gatewayUrl: opts.gateway,
token: opts.token,
/**
* Create a BaseApi socket client and wait for the connection to be established.
* The client auto-connects; we listen for the first "connected/authenticated"
* state before handing it back to the caller.
*/
export async function createSocket(opts: CliOpts): Promise<BaseApi> {
const socket = createTrustGraphSocket(opts.user, opts.token, opts.gateway);
// Wait for the socket to reach an open state
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
unsub();
reject(new Error("Timed out waiting for WebSocket connection"));
}, 15_000);
const unsub = socket.onConnectionStateChange((state) => {
if (
state.status === "authenticated" ||
state.status === "unauthenticated"
) {
clearTimeout(timeout);
unsub();
resolve();
} else if (state.status === "failed") {
clearTimeout(timeout);
unsub();
reject(new Error(state.lastError ?? "WebSocket connection failed"));
}
});
});
await socket.connect();
return socket;
}

View file

@ -14,6 +14,9 @@ import { registerAgentCommands } from "./commands/agent.js";
import { registerGraphRagCommands } from "./commands/graph-rag.js";
import { registerConfigCommands } from "./commands/config.js";
import { registerFlowCommands } from "./commands/flow.js";
import { registerLibraryCommands } from "./commands/library.js";
import { registerTriplesCommands } from "./commands/triples.js";
import { registerEmbeddingsCommands } from "./commands/embeddings.js";
const program = new Command();
@ -22,6 +25,7 @@ program
.description("TrustGraph CLI — interact with TrustGraph services")
.version("0.1.0")
.option("-g, --gateway <url>", "Gateway WebSocket URL", "ws://localhost:8088/api/v1/socket")
.option("-u, --user <id>", "User identifier", "cli")
.option("-t, --token <token>", "Authentication token")
.option("-f, --flow <id>", "Flow ID", "default");
@ -29,5 +33,8 @@ registerAgentCommands(program);
registerGraphRagCommands(program);
registerConfigCommands(program);
registerFlowCommands(program);
registerLibraryCommands(program);
registerTriplesCommands(program);
registerEmbeddingsCommands(program);
program.parse();

View file

@ -2,11 +2,12 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"composite": true
},
"include": ["src"],
"references": [
{ "path": "../base" },
{ "path": "../mcp" }
{ "path": "../client" }
]
}

View file

@ -0,0 +1,30 @@
{
"name": "@trustgraph/client",
"version": "0.1.0",
"description": "Vendored TrustGraph WebSocket client (forked from trustgraph-client v1.6.0)",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run"
},
"peerDependencies": {
"ws": "^8.0.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
}
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0",
"happy-dom": "^20.0.0"
},
"license": "Apache-2.0"
}

View file

@ -0,0 +1,221 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { FlowsApi } from "../socket/trustgraph-socket";
import { FlowResponse } from "../models/messages";
describe("FlowsApi", () => {
let mockApi: {
makeRequest: ReturnType<typeof vi.fn>;
};
let flowsApi: FlowsApi;
beforeEach(() => {
mockApi = {
makeRequest: vi.fn(),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
flowsApi = new FlowsApi(mockApi as any);
});
describe("startFlow", () => {
it("should call makeRequest with correct types and parameters", async () => {
const mockResponse: FlowResponse = {
flow: "started",
description: "Flow started successfully",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.startFlow(
"test-flow-id",
"test-class",
"Test description",
);
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "start-flow",
"flow-id": "test-flow-id",
"blueprint-name": "test-class",
description: "Test description",
},
30000,
);
expect(result).toEqual(mockResponse);
});
it("should use FlowRequest and FlowResponse types", async () => {
const mockResponse: FlowResponse = {};
mockApi.makeRequest.mockResolvedValue(mockResponse);
await flowsApi.startFlow("id", "class", "desc");
// Verify the call signature matches FlowRequest/FlowResponse types
const callArgs = mockApi.makeRequest.mock.calls[0];
const request = callArgs[1];
// These properties should match FlowRequest interface
expect(request).toHaveProperty("operation");
expect(request).toHaveProperty("flow-id");
expect(request).toHaveProperty("blueprint-name");
expect(request).toHaveProperty("description");
});
});
describe("stopFlow", () => {
it("should call makeRequest with correct types and parameters", async () => {
const mockResponse: FlowResponse = {
flow: "stopped",
description: "Flow stopped successfully",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.stopFlow("test-flow-id");
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "stop-flow",
"flow-id": "test-flow-id",
},
30000,
);
expect(result).toEqual(mockResponse);
});
it("should use FlowRequest and FlowResponse types", async () => {
const mockResponse: FlowResponse = {};
mockApi.makeRequest.mockResolvedValue(mockResponse);
await flowsApi.stopFlow("id");
// Verify the call signature matches FlowRequest/FlowResponse types
const callArgs = mockApi.makeRequest.mock.calls[0];
const request = callArgs[1];
// These properties should match FlowRequest interface
expect(request).toHaveProperty("operation");
expect(request).toHaveProperty("flow-id");
});
});
describe("getFlows", () => {
it("should return flow-ids array from response", async () => {
const mockResponse: FlowResponse = {
"flow-ids": ["flow1", "flow2", "flow3"],
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlows();
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "list-flows",
},
60000,
);
expect(result).toEqual(["flow1", "flow2", "flow3"]);
});
it("should return empty array when flow-ids is undefined", async () => {
const mockResponse: FlowResponse = {};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlows();
expect(result).toEqual([]);
});
it("should handle response with flow-ids property correctly", async () => {
// This test ensures we're accessing the hyphenated property name correctly
const mockResponse = {
"flow-ids": ["test-flow"],
"other-property": "should-be-ignored",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlows();
expect(result).toEqual(["test-flow"]);
});
});
describe("getFlowBlueprints", () => {
it("should return blueprint-names array from response", async () => {
const mockResponse: FlowResponse = {
"blueprint-names": ["class1", "class2"],
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlowBlueprints();
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "list-blueprints",
},
60000,
);
expect(result).toEqual(["class1", "class2"]);
});
it("should handle response with blueprint-names property correctly", async () => {
// This test ensures we're accessing the hyphenated property name correctly
const mockResponse = {
"blueprint-names": ["test-class"],
"other-property": "should-be-ignored",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlowBlueprints();
expect(result).toEqual(["test-class"]);
});
});
describe("getFlow", () => {
it("should call makeRequest with correct parameters and parse JSON", async () => {
const flowDefinition = { type: "flow", config: "test" };
const mockResponse: FlowResponse = {
flow: JSON.stringify(flowDefinition), // Must be valid JSON string
description: "Test flow",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlow("test-flow-id");
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "get-flow",
"flow-id": "test-flow-id",
},
60000,
);
expect(result).toEqual(flowDefinition); // Result should be parsed JSON
});
});
describe("getFlowBlueprint", () => {
it("should call makeRequest with correct parameters and parse JSON", async () => {
const blueprintDefinition = { type: "blueprint", name: "test-blueprint" };
const mockResponse: FlowResponse = {
"blueprint-definition": JSON.stringify(blueprintDefinition), // Must be valid JSON string
description: "Test blueprint",
};
mockApi.makeRequest.mockResolvedValue(mockResponse);
const result = await flowsApi.getFlowBlueprint("test-class");
expect(mockApi.makeRequest).toHaveBeenCalledWith(
"flow",
{
operation: "get-blueprint",
"blueprint-name": "test-class",
},
60000,
);
expect(result).toEqual(blueprintDefinition); // Result should be parsed JSON
});
});
});

View file

@ -0,0 +1,370 @@
import { describe, it, expect } from "vitest";
import type {
RequestMessage,
ApiResponse,
TextCompletionRequest,
TextCompletionResponse,
GraphRagRequest,
GraphRagResponse,
AgentRequest,
AgentResponse,
EmbeddingsRequest,
EmbeddingsResponse,
GraphEmbeddingsQueryRequest,
GraphEmbeddingsQueryResponse,
TriplesQueryRequest,
LoadDocumentRequest,
LoadTextRequest,
LibraryRequest,
LibraryResponse,
FlowRequest,
FlowResponse,
DocumentMetadata,
ProcessingMetadata,
} from "../models/messages";
describe("Message Types", () => {
describe("RequestMessage", () => {
it("should have correct structure", () => {
const message: RequestMessage = {
id: "test-id",
service: "test-service",
request: { test: "data" },
};
expect(message.id).toBe("test-id");
expect(message.service).toBe("test-service");
expect(message.request).toEqual({ test: "data" });
});
});
describe("ApiResponse", () => {
it("should have correct structure", () => {
const response: ApiResponse = {
id: "test-id",
response: { result: "success" },
};
expect(response.id).toBe("test-id");
expect(response.response).toEqual({ result: "success" });
});
});
describe("TextCompletionRequest", () => {
it("should have correct structure", () => {
const request: TextCompletionRequest = {
system: "You are a helpful assistant",
prompt: "Hello, world!",
};
expect(request.system).toBe("You are a helpful assistant");
expect(request.prompt).toBe("Hello, world!");
});
});
describe("TextCompletionResponse", () => {
it("should have correct structure", () => {
const response: TextCompletionResponse = {
response: "Hello! How can I help you today?",
};
expect(response.response).toBe("Hello! How can I help you today?");
});
});
describe("GraphRagRequest", () => {
it("should have correct structure with required query", () => {
const request: GraphRagRequest = {
query: "What is the capital of France?",
};
expect(request.query).toBe("What is the capital of France?");
});
it("should have correct structure with optional parameters", () => {
const request: GraphRagRequest = {
query: "What is the capital of France?",
"entity-limit": 100,
"triple-limit": 50,
"max-subgraph-size": 2000,
"max-path-length": 3,
};
expect(request.query).toBe("What is the capital of France?");
expect(request["entity-limit"]).toBe(100);
expect(request["triple-limit"]).toBe(50);
expect(request["max-subgraph-size"]).toBe(2000);
expect(request["max-path-length"]).toBe(3);
});
});
describe("GraphRagResponse", () => {
it("should have correct structure", () => {
const response: GraphRagResponse = {
response: "The capital of France is Paris.",
};
expect(response.response).toBe("The capital of France is Paris.");
});
});
describe("AgentRequest", () => {
it("should have correct structure", () => {
const request: AgentRequest = {
question: "What is the weather like today?",
};
expect(request.question).toBe("What is the weather like today?");
});
});
describe("AgentResponse", () => {
it("should have correct structure with all fields", () => {
const response: AgentResponse = {
thought: "I need to check the weather",
observation: "Weather API shows sunny conditions",
answer: "It is sunny today",
error: undefined,
};
expect(response.thought).toBe("I need to check the weather");
expect(response.observation).toBe("Weather API shows sunny conditions");
expect(response.answer).toBe("It is sunny today");
expect(response.error).toBeUndefined();
});
it("should handle error response", () => {
const response: AgentResponse = {
error: { type: "agent-error", message: "Weather service unavailable" },
};
expect(response.error?.message).toBe("Weather service unavailable");
expect(response.error?.type).toBe("agent-error");
});
});
describe("EmbeddingsRequest", () => {
it("should have correct structure", () => {
const request: EmbeddingsRequest = {
texts: ["This is a test sentence for embedding", "Another text"],
};
expect(request.texts).toEqual(["This is a test sentence for embedding", "Another text"]);
});
});
describe("EmbeddingsResponse", () => {
it("should have correct structure", () => {
// vectors[text_index][dimension_index] - one vector per input text
const response: EmbeddingsResponse = {
vectors: [
[0.1, 0.2, 0.3], // First text's vector
[0.4, 0.5, 0.6], // Second text's vector
],
};
expect(response.vectors).toEqual([
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
]);
});
});
describe("GraphEmbeddingsQueryRequest", () => {
it("should have correct structure", () => {
const request: GraphEmbeddingsQueryRequest = {
vector: [0.1, 0.2, 0.3],
limit: 10,
};
expect(request.vector).toEqual([0.1, 0.2, 0.3]);
expect(request.limit).toBe(10);
});
});
describe("GraphEmbeddingsQueryResponse", () => {
it("should have correct structure", () => {
const response: GraphEmbeddingsQueryResponse = {
entities: [
{ entity: { t: "i", i: "http://example.org/entity1" }, score: 0.95 },
{ entity: { t: "i", i: "http://example.org/entity2" }, score: 0.87 },
],
};
expect(response.entities).toHaveLength(2);
expect(response.entities[0].score).toBe(0.95);
expect(response.entities[0].entity?.t).toBe("i");
expect((response.entities[0].entity as { t: "i"; i: string }).i).toBe("http://example.org/entity1");
expect(response.entities[1].score).toBe(0.87);
});
});
describe("TriplesQueryRequest", () => {
it("should have correct structure with all fields", () => {
const request: TriplesQueryRequest = {
s: { t: "i", i: "http://example.org/subject" },
p: { t: "i", i: "http://example.org/predicate" },
o: { t: "l", v: "object value" },
limit: 100,
};
expect((request.s as { t: "i"; i: string }).i).toBe("http://example.org/subject");
expect((request.p as { t: "i"; i: string }).i).toBe("http://example.org/predicate");
expect((request.o as { t: "l"; v: string }).v).toBe("object value");
expect(request.limit).toBe(100);
});
it("should handle optional fields", () => {
const request: TriplesQueryRequest = {
limit: 50,
};
expect(request.s).toBeUndefined();
expect(request.p).toBeUndefined();
expect(request.o).toBeUndefined();
expect(request.limit).toBe(50);
});
});
describe("LoadDocumentRequest", () => {
it("should have correct structure", () => {
const request: LoadDocumentRequest = {
id: "doc-123",
data: "base64-encoded-document-data",
metadata: [
{
s: { t: "i", i: "http://example.org/doc-123" },
p: { t: "i", i: "http://example.org/title" },
o: { t: "l", v: "Test Document" },
},
],
};
expect(request.id).toBe("doc-123");
expect(request.data).toBe("base64-encoded-document-data");
expect(request.metadata).toHaveLength(1);
});
});
describe("LoadTextRequest", () => {
it("should have correct structure", () => {
const request: LoadTextRequest = {
id: "text-123",
text: "This is some text to load",
charset: "utf-8",
metadata: [],
};
expect(request.id).toBe("text-123");
expect(request.text).toBe("This is some text to load");
expect(request.charset).toBe("utf-8");
expect(request.metadata).toEqual([]);
});
});
describe("DocumentMetadata", () => {
it("should have correct structure", () => {
const metadata: DocumentMetadata = {
id: "doc-123",
time: 1640995200000,
kind: "pdf",
title: "Test Document",
comments: "A test document",
metadata: [],
user: "test-user",
tags: ["test", "document"],
};
expect(metadata.id).toBe("doc-123");
expect(metadata.time).toBe(1640995200000);
expect(metadata.kind).toBe("pdf");
expect(metadata.title).toBe("Test Document");
expect(metadata.comments).toBe("A test document");
expect(metadata.user).toBe("test-user");
expect(metadata.tags).toEqual(["test", "document"]);
});
});
describe("ProcessingMetadata", () => {
it("should have correct structure", () => {
const metadata: ProcessingMetadata = {
id: "proc-123",
"document-id": "doc-123",
time: 1640995200000,
flow: "default-flow",
user: "test-user",
collection: "test-collection",
tags: ["processing", "test"],
};
expect(metadata.id).toBe("proc-123");
expect(metadata["document-id"]).toBe("doc-123");
expect(metadata.time).toBe(1640995200000);
expect(metadata.flow).toBe("default-flow");
expect(metadata.user).toBe("test-user");
expect(metadata.collection).toBe("test-collection");
expect(metadata.tags).toEqual(["processing", "test"]);
});
});
describe("LibraryRequest", () => {
it("should have correct structure", () => {
const request: LibraryRequest = {
operation: "list_documents",
user: "test-user",
collection: "test-collection",
};
expect(request.operation).toBe("list_documents");
expect(request.user).toBe("test-user");
expect(request.collection).toBe("test-collection");
});
});
describe("LibraryResponse", () => {
it("should have correct structure", () => {
const response: LibraryResponse = {
error: new Error(),
"document-metadatas": [
{
id: "doc-1",
title: "Document 1",
time: 1640995200000,
},
],
};
expect(response.error).toBeInstanceOf(Error);
expect(response["document-metadatas"]).toHaveLength(1);
expect(response["document-metadatas"]![0].id).toBe("doc-1");
});
});
describe("FlowRequest", () => {
it("should have correct structure", () => {
const request: FlowRequest = {
operation: "get_flow",
"flow-id": "default-flow",
};
expect(request.operation).toBe("get_flow");
expect(request["flow-id"]).toBe("default-flow");
});
});
describe("FlowResponse", () => {
it("should have correct structure", () => {
const response: FlowResponse = {
"flow-ids": ["flow-1", "flow-2"],
flow: "flow-definition",
description: "A test flow",
error: undefined,
};
expect(response["flow-ids"]).toEqual(["flow-1", "flow-2"]);
expect(response.flow).toBe("flow-definition");
expect(response.description).toBe("A test flow");
expect(response.error).toBeUndefined();
});
});
});

View file

@ -0,0 +1,285 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ServiceCallMulti } from "../socket/service-call-multi";
// Mock WebSocket constants
vi.stubGlobal("WebSocket", {
OPEN: 1,
CONNECTING: 0,
CLOSING: 2,
CLOSED: 3,
});
// Mock Socket interface
const mockSocket = {
inflight: {} as Record<string, unknown>,
ws: {
send: vi.fn(),
readyState: 1, // WebSocket.OPEN
},
reopen: vi.fn(),
};
// Mock setTimeout and clearTimeout
const mockSetTimeout = vi.fn();
const mockClearTimeout = vi.fn();
vi.stubGlobal("setTimeout", mockSetTimeout);
vi.stubGlobal("clearTimeout", mockClearTimeout);
describe("ServiceCallMulti", () => {
let mockSuccess: ReturnType<typeof vi.fn>;
let mockError: ReturnType<typeof vi.fn>;
let mockReceiver: ReturnType<typeof vi.fn>;
let serviceCallMulti: ServiceCallMulti;
beforeEach(() => {
vi.clearAllMocks();
mockSuccess = vi.fn();
mockError = vi.fn();
mockReceiver = vi.fn();
mockSocket.inflight = {} as Record<string, unknown>;
mockSocket.ws = {
send: vi.fn(),
readyState: 1, // WebSocket.OPEN
};
mockSocket.reopen.mockClear();
serviceCallMulti = new ServiceCallMulti(
"test-mid",
{ id: "test-id", service: "test-service", request: { test: "data" } },
mockSuccess,
mockError,
5000, // 5 second timeout
3, // 3 retries
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockSocket as any,
mockReceiver,
);
});
it("should initialize with correct properties", () => {
expect(serviceCallMulti.mid).toBe("test-mid");
expect(serviceCallMulti.timeout).toBe(5000);
expect(serviceCallMulti.retries).toBe(3);
expect(serviceCallMulti.complete).toBe(false);
expect(serviceCallMulti.socket).toBe(mockSocket);
expect(serviceCallMulti.receiver).toBe(mockReceiver);
});
it("should register itself in socket inflight when started", () => {
serviceCallMulti.start();
expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti);
});
it("should send message on successful attempt", () => {
serviceCallMulti.start();
expect(mockSocket.ws.send).toHaveBeenCalledWith(
JSON.stringify({
id: "test-id",
service: "test-service",
request: { test: "data" },
}),
);
expect(mockSetTimeout).toHaveBeenCalled();
});
it("should handle response when receiver returns true (completion)", () => {
mockReceiver.mockReturnValue(true); // Signal completion
const response = { result: "success" };
serviceCallMulti.start();
serviceCallMulti.onReceived(response);
expect(mockReceiver).toHaveBeenCalledWith(response);
expect(serviceCallMulti.complete).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith(response);
expect(mockClearTimeout).toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
});
it("should handle response when receiver returns false (continue)", () => {
mockReceiver.mockReturnValue(false); // Signal to continue
const response = { partial: "data" };
serviceCallMulti.start();
serviceCallMulti.onReceived(response);
expect(mockReceiver).toHaveBeenCalledWith(response);
expect(serviceCallMulti.complete).toBe(false);
expect(mockSuccess).not.toHaveBeenCalled();
expect(mockClearTimeout).not.toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti);
});
it("should handle timeout and retry", () => {
serviceCallMulti.start();
// Initial retries should be 3, but start() calls attempt() which decrements to 2
expect(serviceCallMulti.retries).toBe(2);
// Simulate timeout
serviceCallMulti.onTimeout();
expect(mockClearTimeout).toHaveBeenCalled();
expect(serviceCallMulti.retries).toBe(1); // Should decrement from 2 to 1
});
it("should exhaust retries and call error callback", () => {
// Set retries to 0 to force immediate failure
serviceCallMulti.retries = 0;
serviceCallMulti.start();
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
});
it("should handle WebSocket send failure", () => {
mockSocket.ws.send.mockImplementation(() => {
throw new Error("Connection failed");
});
serviceCallMulti.start();
expect(mockSocket.reopen).toHaveBeenCalled();
// With exponential backoff, the delay should be calculated as:
// SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random
// Since retries is decremented to 2 after start(), it's 3 - 2 = 1
// So base delay is 2000 * 2^1 = 4000, plus random up to 1000
// The delay should be between 4000 and 5000ms (capped at 30000)
const callArgs = mockSetTimeout.mock.calls[0];
expect(callArgs[0]).toEqual(expect.any(Function));
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
expect(callArgs[1]).toBeLessThanOrEqual(5000);
});
it("should handle missing WebSocket connection", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockSocket as any).ws = null;
serviceCallMulti.start();
// Should trigger reopen and schedule with exponential backoff
expect(mockSocket.reopen).toHaveBeenCalled();
// Same calculation as above - base delay 4000ms + random up to 1000ms
const callArgs = mockSetTimeout.mock.calls[0];
expect(callArgs[0]).toEqual(expect.any(Function));
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
expect(callArgs[1]).toBeLessThanOrEqual(5000);
});
it("should not process response if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCallMulti.complete = true;
serviceCallMulti.onReceived({ result: "test" });
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"should not happen, request is already complete",
);
consoleSpy.mockRestore();
});
it("should not timeout if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCallMulti.complete = true;
serviceCallMulti.onTimeout();
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"timeout should not happen, request is already complete",
);
consoleSpy.mockRestore();
});
it("should not attempt if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCallMulti.complete = true;
serviceCallMulti.attempt();
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"attempt should not be called, request is already complete",
);
consoleSpy.mockRestore();
});
it("should handle streaming responses correctly", () => {
mockReceiver
.mockReturnValueOnce(false) // First response - continue
.mockReturnValueOnce(false) // Second response - continue
.mockReturnValueOnce(true); // Third response - complete
serviceCallMulti.start();
// First response
serviceCallMulti.onReceived({ chunk: 1 });
expect(serviceCallMulti.complete).toBe(false);
expect(mockSuccess).not.toHaveBeenCalled();
// Second response
serviceCallMulti.onReceived({ chunk: 2 });
expect(serviceCallMulti.complete).toBe(false);
expect(mockSuccess).not.toHaveBeenCalled();
// Third response (final)
serviceCallMulti.onReceived({ chunk: 3, final: true });
expect(serviceCallMulti.complete).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith({ chunk: 3, final: true });
});
it("should handle receiver function errors gracefully", () => {
mockReceiver.mockImplementation(() => {
throw new Error("Receiver error");
});
serviceCallMulti.start();
expect(() => {
serviceCallMulti.onReceived({ test: "data" });
}).toThrow("Receiver error");
});
it("should handle multiple timeout scenarios", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCallMulti.start();
// After start, retries should be 2 (decremented from 3)
expect(serviceCallMulti.retries).toBe(2);
// First timeout
serviceCallMulti.onTimeout();
expect(serviceCallMulti.retries).toBe(1);
// Second timeout
serviceCallMulti.onTimeout();
expect(serviceCallMulti.retries).toBe(0);
consoleSpy.mockRestore();
});
it("should clean up properly when receiver signals completion", () => {
mockReceiver.mockReturnValue(true);
serviceCallMulti.start();
const response = { final: true };
serviceCallMulti.onReceived(response);
expect(serviceCallMulti.complete).toBe(true);
expect(mockClearTimeout).toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
expect(mockSuccess).toHaveBeenCalledWith(response);
});
});

View file

@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ServiceCall } from "../socket/service-call";
// Mock WebSocket constants
vi.stubGlobal("WebSocket", {
OPEN: 1,
CONNECTING: 0,
CLOSING: 2,
CLOSED: 3,
});
// Mock Socket interface
const mockSocket = {
inflight: {} as Record<string, unknown>,
ws: {
send: vi.fn(),
readyState: 1, // WebSocket.OPEN
},
reopen: vi.fn(),
};
// Mock setTimeout and clearTimeout
const mockSetTimeout = vi.fn();
const mockClearTimeout = vi.fn();
vi.stubGlobal("setTimeout", mockSetTimeout);
vi.stubGlobal("clearTimeout", mockClearTimeout);
describe("ServiceCall", () => {
let mockSuccess: ReturnType<typeof vi.fn>;
let mockError: ReturnType<typeof vi.fn>;
let serviceCall: ServiceCall;
beforeEach(() => {
vi.clearAllMocks();
mockSuccess = vi.fn();
mockError = vi.fn();
mockSocket.inflight = {} as Record<string, unknown>;
mockSocket.ws = {
send: vi.fn(),
readyState: 1, // WebSocket.OPEN
};
mockSocket.reopen.mockClear();
serviceCall = new ServiceCall(
"test-mid",
{ id: "test-id", service: "test-service", request: { test: "data" } },
mockSuccess,
mockError,
5000, // 5 second timeout
3, // 3 retries
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockSocket as any,
);
});
it("should initialize with correct properties", () => {
expect(serviceCall.mid).toBe("test-mid");
expect(serviceCall.timeout).toBe(5000);
expect(serviceCall.retries).toBe(3);
expect(serviceCall.complete).toBe(false);
expect(serviceCall.socket).toBe(mockSocket);
});
it("should register itself in socket inflight when started", () => {
serviceCall.start();
expect(mockSocket.inflight["test-mid"]).toBe(serviceCall);
});
it("should send message on successful attempt", () => {
serviceCall.start();
expect(mockSocket.ws.send).toHaveBeenCalledWith(
JSON.stringify({
id: "test-id",
service: "test-service",
request: { test: "data" },
}),
);
expect(mockSetTimeout).toHaveBeenCalled();
});
it("should handle successful response", () => {
const responseData = { result: "success" };
const message = { response: responseData };
serviceCall.start();
serviceCall.onReceived(message);
expect(serviceCall.complete).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith(responseData);
expect(mockClearTimeout).toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
});
it("should handle timeout and retry", () => {
serviceCall.start();
// Initial retries should be 3, but start() calls attempt() which decrements to 2
expect(serviceCall.retries).toBe(2);
// Simulate timeout
serviceCall.onTimeout();
expect(mockClearTimeout).toHaveBeenCalled();
expect(serviceCall.retries).toBe(1); // Should decrement from 2 to 1
});
it("should exhaust retries and call error callback", () => {
// Set retries to 0 to force immediate failure
serviceCall.retries = 0;
serviceCall.start();
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
});
it("should handle WebSocket send failure", () => {
mockSocket.ws.send.mockImplementation(() => {
throw new Error("Connection failed");
});
serviceCall.start();
// Should NOT call reopen anymore - BaseApi handles reconnection
expect(mockSocket.reopen).not.toHaveBeenCalled();
// With exponential backoff, the delay should be calculated as:
// SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random
// Since retries is decremented to 2 after start(), it's 3 - 2 = 1
// So base delay is 2000 * 2^1 = 4000, plus random up to 1000
// The delay should be between 4000 and 5000ms (capped at 30000)
const callArgs = mockSetTimeout.mock.calls[0];
expect(callArgs[0]).toEqual(expect.any(Function));
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
expect(callArgs[1]).toBeLessThanOrEqual(5000);
});
it("should handle missing WebSocket connection", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockSocket as any).ws = null;
serviceCall.start();
// Should NOT trigger reopen - just wait for BaseApi to reconnect
expect(mockSocket.reopen).not.toHaveBeenCalled();
// Same calculation as above - base delay 4000ms + random up to 1000ms
const callArgs = mockSetTimeout.mock.calls[0];
expect(callArgs[0]).toEqual(expect.any(Function));
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
expect(callArgs[1]).toBeLessThanOrEqual(5000);
});
it("should not process response if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCall.complete = true;
serviceCall.onReceived({ result: "test" });
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"should not happen, request is already complete",
);
consoleSpy.mockRestore();
});
it("should not timeout if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCall.complete = true;
serviceCall.onTimeout();
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"timeout should not happen, request is already complete",
);
consoleSpy.mockRestore();
});
it("should not attempt if already complete", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
serviceCall.complete = true;
serviceCall.attempt();
expect(consoleSpy).toHaveBeenCalledWith(
"test-mid",
"attempt should not be called, request is already complete",
);
consoleSpy.mockRestore();
});
it("should handle multiple retries correctly", () => {
mockSocket.ws.send.mockImplementation(() => {
throw new Error("Connection failed");
});
serviceCall.start();
// Should have decremented retries and scheduled a retry
expect(serviceCall.retries).toBe(2);
// Should NOT call reopen - BaseApi handles reconnection
expect(mockSocket.reopen).not.toHaveBeenCalled();
});
it("should clean up properly on successful response", () => {
serviceCall.start();
const responseData = { success: true };
const message = { response: responseData };
serviceCall.onReceived(message);
expect(serviceCall.complete).toBe(true);
expect(mockClearTimeout).toHaveBeenCalled();
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
expect(mockSuccess).toHaveBeenCalledWith(responseData);
});
it("should handle edge case of negative retries", () => {
serviceCall.retries = -1;
serviceCall.attempt();
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
});
it("should bind timeout callbacks correctly", () => {
serviceCall.start();
// Verify that setTimeout was called with a bound function
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 5000);
});
});

View file

@ -0,0 +1,13 @@
// @trustgraph/client
// TrustGraph TypeScript Client
// Export models (data types)
export * from "./models/Triple.js";
export * from "./models/messages.js";
export * from "./models/namespaces.js";
// Export socket client
export * from "./socket/trustgraph-socket.js";
// Export WebSocket adapter (isomorphic helpers and types)
export * from "./socket/websocket-adapter.js";

View file

@ -0,0 +1,40 @@
// Term type discriminators matching the wire format
// i = IRI, b = BLANK node, l = LITERAL, t = TRIPLE (reified)
export type TermType = "i" | "b" | "l" | "t";
export interface IriTerm {
t: "i";
i: string;
}
export interface BlankTerm {
t: "b";
d: string;
}
export interface LiteralTerm {
t: "l";
v: string;
dt?: string; // datatype
ln?: string; // language
}
export interface TripleTerm {
t: "t";
tr?: Triple;
}
export type Term = IriTerm | BlankTerm | LiteralTerm | TripleTerm;
export interface PartialTriple {
s?: Term;
p?: Term;
o?: Term;
}
export interface Triple {
s: Term;
p: Term;
o: Term;
g?: string; // graph (renamed from direc to match backend)
}

View file

@ -0,0 +1,496 @@
import { Triple, Term } from "./Triple.js";
// FIXME: Better types?
export type Request = object;
export type Response = object;
export type Error = object | string;
export interface ResponseError {
type?: string;
message: string;
}
export interface RequestMessage {
id: string;
service: string;
request: Request;
flow?: string;
}
export interface ApiResponse {
id: string;
response: Response;
}
export interface Metadata {
id?: string;
metadata?: Triple[];
user?: string;
collection?: string;
}
export interface EntityEmbeddings {
entity?: Term;
vectors?: number[][];
}
export interface GraphEmbeddings {
metadata?: Metadata;
entities?: EntityEmbeddings[];
}
export interface TextCompletionRequest {
system: string;
prompt: string;
streaming?: boolean;
}
export interface TextCompletionResponse {
response: string;
// Streaming fields
end_of_stream?: boolean;
error?: {
message: string;
type?: string;
};
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
}
export interface GraphRagRequest {
query: string;
user?: string;
collection?: string;
"entity-limit"?: number; // Default: 50
"triple-limit"?: number; // Default: 30
"max-subgraph-size"?: number; // Default: 1000
"max-path-length"?: number; // Default: 2
streaming?: boolean;
}
export interface GraphRagResponse {
response: string;
// Streaming fields
chunk?: string;
end_of_stream?: boolean;
error?: {
message: string;
type?: string;
};
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
// Explainability fields
message_type?: "chunk" | "explain";
explain_id?: string;
explain_graph?: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval)
end_of_session?: boolean;
}
export interface DocumentRagRequest {
query: string;
user?: string;
collection?: string;
"doc-limit"?: number; // Default: 20
streaming?: boolean;
}
export interface DocumentRagResponse {
response: string;
// Streaming fields
chunk?: string;
end_of_stream?: boolean;
error?: {
message: string;
type?: string;
};
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
// Explainability fields
message_type?: "chunk" | "explain";
explain_id?: string;
explain_graph?: string;
end_of_session?: boolean;
}
export interface AgentRequest {
question: string;
user?: string;
streaming?: boolean;
}
export interface AgentResponse {
// Streaming response format (new protocol)
chunk_type?: "thought" | "action" | "observation" | "answer" | "final-answer" | "explain" | "error";
content?: string;
end_of_message?: boolean;
end_of_dialog?: boolean;
// Legacy fields for backward compatibility with non-streaming
thought?: string;
observation?: string;
answer?: string;
error?: ResponseError;
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
// Explainability fields
message_type?: "chunk" | "explain";
explain_id?: string;
explain_graph?: string;
}
export interface EmbeddingsRequest {
texts: string[];
}
export interface EmbeddingsResponse {
vectors: number[][]; // One vector per input text
}
export interface GraphEmbeddingsQueryRequest {
vector: number[]; // Single query vector
limit: number;
user?: string;
collection?: string;
}
export interface EntityMatch {
entity: Term | null;
score: number;
}
export interface GraphEmbeddingsQueryResponse {
entities: EntityMatch[];
}
export interface TriplesQueryRequest {
s?: Term;
p?: Term;
o?: Term;
g?: string; // Named graph URI filter (plain string, not Term)
limit: number;
user?: string;
collection?: string;
}
export interface TriplesQueryResponse {
response: Triple[];
}
export interface RowsQueryRequest {
query: string;
user?: string;
collection?: string;
variables?: Record<string, unknown>;
operation_name?: string;
}
export interface RowsQueryResponse {
data?: Record<string, unknown>;
errors?: Record<string, unknown>[];
extensions?: Record<string, unknown>;
values?: unknown[];
}
export interface NlpQueryRequest {
question: string;
max_results?: number;
}
export interface NlpQueryResponse {
graphql_query?: string;
variables?: Record<string, unknown>;
detected_schemas?: Record<string, unknown>[];
confidence?: number;
}
export interface StructuredQueryRequest {
question: string;
user?: string;
collection?: string;
}
export interface StructuredQueryResponse {
data?: Record<string, unknown>;
errors?: Record<string, unknown>[];
}
export interface RowEmbeddingsQueryRequest {
vector: number[]; // Single query vector
schema_name: string;
user?: string;
collection?: string;
index_name?: string;
limit?: number;
}
export interface RowEmbeddingsMatch {
index_name: string;
index_value: string[];
text: string;
score: number;
}
export interface RowEmbeddingsQueryResponse {
matches?: RowEmbeddingsMatch[];
error?: {
message: string;
type?: string;
};
}
export interface LoadDocumentRequest {
id?: string;
data: string;
metadata?: Triple[];
}
export type LoadDocumentResponse = void;
export interface LoadTextRequest {
id?: string;
text: string;
charset?: string;
metadata?: Triple[];
}
export type LoadTextResponse = void;
export interface DocumentMetadata {
id?: string;
time?: number;
kind?: string;
title?: string;
comments?: string;
metadata?: Triple[];
user?: string;
tags?: string[];
"document-type"?: string;
}
export interface ProcessingMetadata {
id?: string;
"document-id"?: string;
time?: number;
flow?: string;
user?: string;
collection?: string;
tags?: string[];
}
export interface LibraryRequest {
operation: string;
"document-id"?: string;
"processing-id"?: string;
"document-metadata"?: DocumentMetadata;
"processing-metadata"?: ProcessingMetadata;
content?: string;
user?: string;
collection?: string;
metadata?: Triple[];
id?: string;
flow?: string;
}
export interface LibraryResponse {
error: Error;
"document-metadata"?: DocumentMetadata;
content?: string;
"document-metadatas"?: DocumentMetadata[];
"processing-metadata"?: ProcessingMetadata;
}
export interface KnowledgeRequest {
operation: string;
user?: string;
id?: string;
flow?: string;
collection?: string;
triples?: Triple[];
"graph-embeddings"?: GraphEmbeddings;
}
export interface KnowledgeResponse {
error?: Error;
ids?: string[];
eos?: boolean;
triples?: Triple[];
"graph-embeddings"?: GraphEmbeddings;
}
export interface FlowRequest {
operation: string;
"blueprint-name"?: string;
"blueprint-definition"?: string;
description?: string;
"flow-id"?: string;
parameters?: Record<string, unknown>;
user?: string;
}
export interface FlowResponse {
"blueprint-names"?: string[];
"flow-ids"?: string[];
ids?: string[];
flow?: string;
"blueprint-definition"?: string;
description?: string;
error?:
| {
message?: string;
}
| Error;
}
export interface PromptRequest {
id: string;
terms: Record<string, unknown>;
streaming?: boolean;
}
export interface PromptResponse {
text: string;
// Streaming fields
end_of_stream?: boolean;
error?: {
message: string;
type?: string;
};
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
}
export type ConfigRequest = object;
export type ConfigResponse = object;
// Chunked Upload Types
export interface ChunkedUploadDocumentMetadata {
id: string;
time: number;
kind: string;
title: string;
comments?: string;
metadata?: Triple[];
user: string;
collection?: string;
tags?: string[];
}
export interface BeginUploadRequest {
operation: "begin-upload";
"document-metadata": ChunkedUploadDocumentMetadata;
"total-size": number;
"chunk-size"?: number;
}
export interface BeginUploadResponse {
"upload-id": string;
"chunk-size": number;
"total-chunks": number;
error?: ResponseError;
}
export interface UploadChunkRequest {
operation: "upload-chunk";
"upload-id": string;
"chunk-index": number;
content: string; // base64-encoded
user: string;
}
export interface UploadChunkResponse {
"upload-id": string;
"chunk-index": number;
"chunks-received": number;
"total-chunks": number;
"bytes-received": number;
"total-bytes": number;
error?: ResponseError;
}
export interface CompleteUploadRequest {
operation: "complete-upload";
"upload-id": string;
user: string;
}
export interface CompleteUploadResponse {
"document-id": string;
"object-id": string;
error?: ResponseError;
}
export interface GetUploadStatusRequest {
operation: "get-upload-status";
"upload-id": string;
user: string;
}
export interface GetUploadStatusResponse {
"upload-id": string;
"upload-state": "in-progress" | "completed" | "expired";
"chunks-received": number;
"total-chunks": number;
"received-chunks": number[];
"missing-chunks": number[];
"bytes-received": number;
"total-bytes": number;
error?: ResponseError;
}
export interface AbortUploadRequest {
operation: "abort-upload";
"upload-id": string;
user: string;
}
export interface AbortUploadResponse {
error?: ResponseError;
}
export interface ListUploadsRequest {
operation: "list-uploads";
user: string;
}
export interface UploadSession {
"upload-id": string;
"document-id": string;
"document-metadata-json": string;
"total-size": number;
"chunk-size": number;
"total-chunks": number;
"chunks-received": number;
"created-at": string;
}
export interface ListUploadsResponse {
"upload-sessions": UploadSession[];
error?: ResponseError;
}
export interface StreamDocumentRequest {
operation: "stream-document";
"document-id": string;
"chunk-size"?: number;
user: string;
}
export interface StreamDocumentResponse {
content: string; // base64-encoded chunk
"chunk-index": number;
"total-chunks": number;
error?: ResponseError;
}

View file

@ -0,0 +1,42 @@
/**
* RDF namespace constants for TrustGraph
* Used for querying explainability data, provenance chains, and knowledge graph
*/
// TrustGraph namespace
export const TG = "https://trustgraph.ai/ns/";
export const TG_QUERY = TG + "query";
export const TG_EDGE_COUNT = TG + "edgeCount";
export const TG_SELECTED_EDGE = TG + "selectedEdge";
export const TG_EDGE = TG + "edge";
export const TG_REASONING = TG + "reasoning";
export const TG_CONTENT = TG + "content";
export const TG_REIFIES = TG + "reifies";
export const TG_DOCUMENT = TG + "document";
// W3C PROV-O namespace
export const PROV = "http://www.w3.org/ns/prov#";
export const PROV_STARTED_AT_TIME = PROV + "startedAtTime";
export const PROV_WAS_DERIVED_FROM = PROV + "wasDerivedFrom";
export const PROV_WAS_GENERATED_BY = PROV + "wasGeneratedBy";
export const PROV_ACTIVITY = PROV + "Activity";
export const PROV_ENTITY = PROV + "Entity";
// RDFS namespace
export const RDFS = "http://www.w3.org/2000/01/rdf-schema#";
export const RDFS_LABEL = RDFS + "label";
// RDF namespace
export const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
export const RDF_TYPE = RDF + "type";
// Schema.org namespace (used in document metadata)
export const SCHEMA = "https://schema.org/";
export const SCHEMA_NAME = SCHEMA + "name";
export const SCHEMA_DESCRIPTION = SCHEMA + "description";
export const SCHEMA_AUTHOR = SCHEMA + "author";
export const SCHEMA_KEYWORDS = SCHEMA + "keywords";
// SKOS namespace
export const SKOS = "http://www.w3.org/2004/02/skos/core#";
export const SKOS_DEFINITION = SKOS + "definition";

View file

@ -0,0 +1,172 @@
import { RequestMessage } from "../models/messages.js";
import { WS_OPEN, WS_CONNECTING, type IsomorphicWebSocket } from "./websocket-adapter.js";
// Constant defining the delay before attempting to reconnect a WebSocket
// (2 seconds)
export const SOCKET_RECONNECTION_TIMEOUT = 2000;
// Forward declare Socket type to avoid circular dependency
// Using a minimal interface that matches what BaseApi provides
interface Socket {
ws?: IsomorphicWebSocket;
inflight: { [key: string]: ServiceCallMulti };
reopen: () => void;
getNextId?: () => string;
user?: string;
}
export class ServiceCallMulti {
constructor(
mid: string,
msg: RequestMessage,
success: (resp: unknown) => void,
error: (err: object | string) => void,
timeout: number,
retries: number,
socket: Socket,
receiver: (resp: unknown) => boolean,
) {
this.mid = mid;
this.msg = msg;
this.success = success;
this.error = error;
this.timeout = timeout;
this.retries = retries;
this.socket = socket;
this.complete = false;
this.receiver = receiver;
}
mid: string;
msg: RequestMessage;
success: (resp: unknown) => void;
error: (err: object | string) => void;
receiver: (resp: unknown) => boolean;
timeoutId?: ReturnType<typeof setTimeout>;
timeout: number;
retries: number;
socket: Socket;
complete: boolean;
start() {
this.socket.inflight[this.mid] = this;
this.attempt();
}
onReceived(resp: object) {
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
const fin = this.receiver(resp);
if (fin) {
this.complete = true;
// console.log("Received for", this.mid);
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
delete this.socket.inflight[this.mid];
this.success(resp);
}
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
onTimeout() {
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
console.log("Request", this.mid, "timed out");
clearTimeout(this.timeoutId);
this.attempt();
}
attempt() {
// console.log("attempt:", this.mid);
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
this.retries--;
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) {
try {
this.socket.ws.send(JSON.stringify(this.msg));
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
return;
} catch (e) {
console.log("Error:", e);
console.log("Message send failure, retry...");
// Calculate backoff delay with jitter
const backoffDelay = Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000, // Max 30 seconds
);
this.timeoutId = setTimeout(this.attempt.bind(this), backoffDelay);
console.log("Reopen...");
// Attempt to reopen the WebSocket connection
this.socket.reopen();
}
} else {
// No WebSocket connection available or not ready
// Check if socket is connecting
if (
this.socket.ws &&
this.socket.ws.readyState === WS_CONNECTING
) {
// Wait a bit longer for connection to establish
setTimeout(this.attempt.bind(this), 500);
} else {
// Socket is closed or closing, trigger reopen
console.log("Socket not ready, reopening...");
this.socket.reopen();
// Calculate backoff delay
const backoffDelay = Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000,
);
setTimeout(this.attempt.bind(this), backoffDelay);
}
}
}
}

View file

@ -0,0 +1,240 @@
import { RequestMessage } from "../models/messages.js";
import { WS_OPEN, type IsomorphicWebSocket } from "./websocket-adapter.js";
// Constant defining the delay before attempting to reconnect a WebSocket
// (2 seconds)
export const SOCKET_RECONNECTION_TIMEOUT = 2000;
// Forward declare Socket type to avoid circular dependency
// Using a minimal interface that matches what BaseApi provides
interface Socket {
ws?: IsomorphicWebSocket;
inflight: { [key: string]: ServiceCall };
reopen: () => void;
getNextId?: () => string;
user?: string;
}
/**
* ServiceCall represents a single request/response cycle over a WebSocket
* connection with built-in retry logic, timeout handling, and completion
* tracking.
*
* This class manages the lifecycle of a service call including:
* - Sending the initial request
* - Handling timeouts and retries
* - Managing completion state
* - Cleaning up resources
*/
export class ServiceCall {
constructor(
mid: string, // Message ID - unique identifier for this request
msg: RequestMessage, // The actual message/request to send
success: (resp: unknown) => void, // Callback function called on
// successful response
error: (err: object | string) => void, // Callback function called on error/failure
timeout: number, // Timeout duration in milliseconds
retries: number, // Number of retry attempts allowed
socket: Socket, // WebSocket instance to send the message through
) {
this.mid = mid;
this.msg = msg;
this.success = success;
this.error = error;
this.timeout = timeout;
this.retries = retries;
this.socket = socket;
this.complete = false; // Track if this request has completed
}
// Properties
mid: string; // Message identifier
msg: RequestMessage; // The request message
success: (resp: unknown) => void; // Success callback
error: (err: object | string) => void; // Error callback
timeoutId?: ReturnType<typeof setTimeout>; // Reference to the active timeout timer
timeout: number; // Timeout duration in milliseconds
retries: number; // Remaining retry attempts
socket: Socket; // WebSocket connection reference
complete: boolean; // Flag indicating if request is complete
/**
* Initiates the service call by registering it with the socket's inflight
* requests and making the first attempt to send the message
*/
start() {
// Register this request as "in-flight" so responses can be matched to it
this.socket.inflight[this.mid] = this;
// Make the first attempt to send the message
this.attempt();
}
/**
* Called when a response is received for this request
* Handles cleanup and calls the success or error callback based on response
*
* @param resp - The response object received from the server
*/
onReceived(resp: object) {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
// Mark as complete to prevent duplicate processing
this.complete = true;
// Clean up timeout timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Remove from inflight requests tracker
delete this.socket.inflight[this.mid];
// Check if the response contains an error (error can be directly in resp or nested under response)
let errorToHandle: unknown = null;
// Check for direct error in response
if (resp && typeof resp === "object" && "error" in resp) {
errorToHandle = (resp as Record<string, unknown>).error;
}
// Check for nested error under response property
else if (resp && typeof resp === "object" && "response" in resp) {
const response = (resp as Record<string, unknown>).response;
if (response && typeof response === "object" && "error" in response) {
errorToHandle = (response as Record<string, unknown>).error;
}
}
if (errorToHandle) {
// Response contains an error - call error callback
const errorObj = errorToHandle as Record<string, unknown>;
const errorMessage =
(typeof errorObj.message === "string" ? errorObj.message : null) ||
(typeof errorObj.type === "string" ? errorObj.type : null) ||
"Unknown error";
console.log(
"ServiceCall: API error detected in response:",
errorMessage,
"Full error:",
errorToHandle,
);
this.error(new Error(errorMessage));
return;
}
// Extract the response field from the message object
// The resp parameter is the full message: {id, response, complete}
// We need to pass just the response field to the success callback
const responseData = (resp as { response?: unknown }).response;
this.success(responseData);
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
/**
* Called when the request times out
* Triggers another attempt if retries are available
*/
onTimeout() {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
console.log("Request", this.mid, "timed out");
// Clear the current timeout
clearTimeout(this.timeoutId);
// Try again (this will check retry count)
this.attempt();
}
/**
* Calculates exponential backoff delay with jitter
* @returns backoff delay in milliseconds
*/
calculateBackoff() {
return Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000, // Max 30 seconds
);
}
/**
* Core retry logic - attempts to send the message over the WebSocket
* Handles retries and waits for BaseApi to handle reconnection
*/
attempt() {
// Defensive check - this shouldn't be called on completed requests
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
// Decrement retry counter
this.retries--;
// Check if we've exhausted all retries
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
// Clean up and call error callback
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) {
try {
// Attempt to send the message as JSON
this.socket.ws.send(JSON.stringify(this.msg));
// Set up timeout for this attempt
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
return; // Success - message sent, waiting for response or timeout
} catch (e) {
// Handle send failure - wait for BaseApi to handle reconnection
console.log("Error:", e);
console.log(
"Message send failure, waiting for socket reconnection...",
);
// Schedule retry with backoff - let BaseApi handle the reconnection
this.timeoutId = setTimeout(
this.attempt.bind(this),
this.calculateBackoff(),
);
}
} else {
// No WebSocket connection available or not ready
// Let BaseApi handle reconnection, just wait and retry
console.log("Request", this.mid, "waiting for socket reconnection...");
// Use consistent backoff for all waiting scenarios
setTimeout(this.attempt.bind(this), this.calculateBackoff());
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
/**
* Isomorphic WebSocket adapter for browser and Node.js environments.
*
* In browsers, uses the native globalThis.WebSocket.
* In Node.js, dynamically requires the 'ws' package.
*
* Provides its own minimal type definitions for the WebSocket API surface
* we actually use, so the package does not require DOM lib types.
*/
// ---------------------------------------------------------------------------
// WebSocket readyState constants (identical in browser WebSocket and 'ws')
// ---------------------------------------------------------------------------
export const WS_CONNECTING = 0;
export const WS_OPEN = 1;
export const WS_CLOSING = 2;
export const WS_CLOSED = 3;
// ---------------------------------------------------------------------------
// Minimal WebSocket type surface used by this package
// ---------------------------------------------------------------------------
/** Minimal event type compatible with both browser Event and ws events. */
export interface WsEvent {
type: string;
[key: string]: unknown;
}
/** Minimal MessageEvent-compatible shape. */
export interface WsMessageEvent {
data: unknown;
type: string;
[key: string]: unknown;
}
/** Minimal CloseEvent-compatible shape. */
export interface WsCloseEvent {
code: number;
reason: string;
wasClean: boolean;
type: string;
[key: string]: unknown;
}
/**
* Minimal interface covering the WebSocket instance methods and properties
* used by this package. Compatible with both browser `WebSocket` and the
* `ws` npm package.
*/
export interface IsomorphicWebSocket {
readonly readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
addEventListener(type: "message", listener: (event: WsMessageEvent) => void): void;
addEventListener(type: "close", listener: (event: WsCloseEvent) => void): void;
addEventListener(type: "open", listener: (event: WsEvent) => void): void;
addEventListener(type: "error", listener: (event: WsEvent) => void): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
removeEventListener(type: string, listener: (...args: any[]) => void): void;
}
/** Constructor signature for an isomorphic WebSocket implementation. */
export interface IsomorphicWebSocketConstructor {
new (url: string): IsomorphicWebSocket;
}
// ---------------------------------------------------------------------------
// Runtime helpers
// ---------------------------------------------------------------------------
/**
* Returns the WebSocket constructor appropriate for the current environment.
*
* - Browser: uses `globalThis.WebSocket` (native)
* - Node.js: dynamically `require`s the `ws` npm package
*
* @throws Error if no WebSocket implementation is available
*/
export function getWebSocketConstructor(): IsomorphicWebSocketConstructor {
// Browser environment (or Deno, Bun, etc. where WebSocket is global)
if (typeof globalThis !== "undefined" && "WebSocket" in globalThis) {
return (globalThis as unknown as { WebSocket: IsomorphicWebSocketConstructor }).WebSocket;
}
// Node.js environment — dynamically require 'ws'
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ws = require("ws");
return ws as IsomorphicWebSocketConstructor;
} catch {
throw new Error(
'WebSocket is not available. In Node.js, install the "ws" package: npm install ws',
);
}
}
/**
* Returns the default WebSocket URL for the current environment.
*
* - Browser: returns the relative path `"/api/socket"` (resolved by the
* browser against the current page origin).
* - Node.js: returns a full URL `"ws://localhost:8088/api/v1/socket"` since
* relative URLs are not meaningful outside a browser.
*/
export function getDefaultSocketUrl(): string {
if (typeof window !== "undefined") {
return "/api/socket";
}
return "ws://localhost:8088/api/v1/socket";
}
/**
* Isomorphic `getRandomValues` that works in both browser and Node.js.
*
* - Browser / Node.js 19+: uses `globalThis.crypto.getRandomValues`
* - Older Node.js: falls back to `node:crypto.randomFillSync`
*/
export function getRandomValues(array: Uint32Array): Uint32Array {
if (typeof globalThis.crypto?.getRandomValues === "function") {
return globalThis.crypto.getRandomValues(array);
}
// Node.js fallback for versions < 19 where globalThis.crypto may not exist
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { randomFillSync } = require("node:crypto");
return randomFillSync(array) as Uint32Array;
} catch {
throw new Error(
"No cryptographic random source available. " +
"Upgrade to Node.js 19+ or ensure the 'crypto' module is available.",
);
}
}

View file

@ -0,0 +1,3 @@
// Type definitions for TrustGraph client
export {};

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"composite": true
},
"include": ["src"],
"exclude": ["src/__tests__"]
}

View file

@ -15,7 +15,7 @@
"openai": "^4.85.0",
"@anthropic-ai/sdk": "^0.39.0",
"@qdrant/js-client-rest": "^1.13.0",
"neo4j-driver": "^5.28.0",
"falkordb": "^5.0.0",
"fastify": "^5.2.0",
"@fastify/websocket": "^11.0.0"
},

View file

@ -0,0 +1,357 @@
/**
* Config service manages system global configuration state.
*
* An AsyncProcessor (NOT FlowProcessor) that:
* 1. Listens on config-request topic
* 2. Handles operations: get, put, delete, list, config (full dump)
* 3. Stores config in-memory with a nested Map structure
* 4. On any mutation: increments version, broadcasts ConfigPush on config-push topic
* 5. Optionally persists to a JSON file for restart durability
*
* Python reference: trustgraph-flow/trustgraph/config/service/service.py
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import {
AsyncProcessor,
type ProcessorConfig,
topics,
type ConfigRequest,
type ConfigResponse,
type ConfigOperation,
} from "@trustgraph/base";
import type { PubSubBackend, BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
export interface ConfigServiceConfig extends ProcessorConfig {
persistPath?: string;
}
interface ConfigPush {
version: number;
config: Record<string, unknown>;
}
export class ConfigService extends AsyncProcessor {
private store = new Map<string, Map<string, unknown>>();
private version = 0;
private persistPath: string | null;
private consumer: BackendConsumer<ConfigRequest> | null = null;
private responseProducer: BackendProducer<ConfigResponse> | null = null;
private pushProducer: BackendProducer<ConfigPush> | null = null;
constructor(config: ConfigServiceConfig) {
super(config);
this.persistPath = config.persistPath ?? process.env.CONFIG_PERSIST_PATH ?? null;
}
protected override async run(): Promise<void> {
// Optionally load persisted state
if (this.persistPath) {
await this.loadFromDisk();
}
// Create producers
this.responseProducer = await this.pubsub.createProducer<ConfigResponse>({
topic: topics.configResponse,
});
this.pushProducer = await this.pubsub.createProducer<ConfigPush>({
topic: topics.configPush,
});
// Create consumer for config requests
this.consumer = await this.pubsub.createConsumer<ConfigRequest>({
topic: topics.configRequest,
subscription: `${this.config.id}-config-request`,
});
// Push initial config
await this.pushConfig();
console.log(`[ConfigService] Listening on ${topics.configRequest}`);
// Main consume loop
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (!msg) continue;
await this.handleMessage(msg);
await this.consumer.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error("[ConfigService] Error in consume loop:", err);
await sleep(1000);
}
}
}
private async handleMessage(msg: Message<ConfigRequest>): Promise<void> {
const request = msg.value();
const props = msg.properties();
const requestId = props.id;
if (!requestId) {
console.warn("[ConfigService] Received request without id, ignoring");
return;
}
try {
const response = await this.handleOperation(request);
await this.responseProducer!.send(response, { id: requestId });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.responseProducer!.send(
{
error: { type: "config-error", message },
},
{ id: requestId },
);
}
}
private async handleOperation(request: ConfigRequest): Promise<ConfigResponse> {
const op: ConfigOperation = request.operation;
switch (op) {
case "get":
return this.handleGet(request.keys ?? []);
case "put":
return await this.handlePut(request.keys ?? [], request.values ?? {});
case "delete":
return await this.handleDelete(request.keys ?? []);
case "list":
return this.handleList(request.keys ?? []);
case "config":
return this.handleConfigDump();
default:
throw new Error(`Unknown config operation: ${op as string}`);
}
}
private handleGet(keys: string[]): ConfigResponse {
if (keys.length === 0) {
return { version: this.version, values: {} };
}
const values: Record<string, unknown> = {};
const namespace = keys[0];
const subMap = this.store.get(namespace);
if (subMap) {
if (keys.length === 1) {
// Return entire namespace
for (const [k, v] of subMap) {
values[k] = v;
}
} else {
// Return specific keys within namespace
for (let i = 1; i < keys.length; i++) {
const key = keys[i];
if (subMap.has(key)) {
values[key] = subMap.get(key);
}
}
}
}
return { version: this.version, values };
}
private async handlePut(
keys: string[],
values: Record<string, unknown>,
): Promise<ConfigResponse> {
if (keys.length === 0) {
throw new Error("Put requires at least one key (namespace)");
}
const namespace = keys[0];
let subMap = this.store.get(namespace);
if (!subMap) {
subMap = new Map<string, unknown>();
this.store.set(namespace, subMap);
}
for (const [k, v] of Object.entries(values)) {
subMap.set(k, v);
}
this.version++;
await this.persist();
await this.pushConfig();
return { version: this.version };
}
private async handleDelete(keys: string[]): Promise<ConfigResponse> {
if (keys.length === 0) {
throw new Error("Delete requires at least one key");
}
const namespace = keys[0];
if (keys.length === 1) {
// Delete entire namespace
this.store.delete(namespace);
} else {
// Delete specific keys within namespace
const subMap = this.store.get(namespace);
if (subMap) {
for (let i = 1; i < keys.length; i++) {
subMap.delete(keys[i]);
}
if (subMap.size === 0) {
this.store.delete(namespace);
}
}
}
this.version++;
await this.persist();
await this.pushConfig();
return { version: this.version };
}
private handleList(keys: string[]): ConfigResponse {
if (keys.length === 0) {
// List all namespaces
return {
version: this.version,
directory: [...this.store.keys()],
};
}
const namespace = keys[0];
const subMap = this.store.get(namespace);
return {
version: this.version,
directory: subMap ? [...subMap.keys()] : [],
};
}
private handleConfigDump(): ConfigResponse {
const config: Record<string, unknown> = {};
for (const [namespace, subMap] of this.store) {
const obj: Record<string, unknown> = {};
for (const [k, v] of subMap) {
obj[k] = v;
}
config[namespace] = obj;
}
return {
version: this.version,
config,
};
}
private async pushConfig(): Promise<void> {
if (!this.pushProducer) return;
const config: Record<string, unknown> = {};
for (const [namespace, subMap] of this.store) {
const obj: Record<string, unknown> = {};
for (const [k, v] of subMap) {
obj[k] = v;
}
config[namespace] = obj;
}
await this.pushProducer.send({
version: this.version,
config,
});
console.log(`[ConfigService] Pushed configuration version ${this.version}`);
}
private async persist(): Promise<void> {
if (!this.persistPath) return;
try {
const data: Record<string, Record<string, unknown>> = {};
for (const [namespace, subMap] of this.store) {
const obj: Record<string, unknown> = {};
for (const [k, v] of subMap) {
obj[k] = v;
}
data[namespace] = obj;
}
const json = JSON.stringify(
{ version: this.version, data },
null,
2,
);
await mkdir(dirname(this.persistPath), { recursive: true });
await writeFile(this.persistPath, json, "utf-8");
} catch (err) {
console.error("[ConfigService] Failed to persist config:", err);
}
}
private async loadFromDisk(): Promise<void> {
if (!this.persistPath) return;
try {
const raw = await readFile(this.persistPath, "utf-8");
const parsed = JSON.parse(raw) as {
version: number;
data: Record<string, Record<string, unknown>>;
};
this.version = parsed.version ?? 0;
this.store.clear();
for (const [namespace, obj] of Object.entries(parsed.data ?? {})) {
const subMap = new Map<string, unknown>();
for (const [k, v] of Object.entries(obj)) {
subMap.set(k, v);
}
this.store.set(namespace, subMap);
}
console.log(
`[ConfigService] Loaded persisted config (version=${this.version}, namespaces=${this.store.size})`,
);
} catch {
// File doesn't exist yet or is invalid — start fresh
console.log("[ConfigService] No persisted config found, starting fresh");
}
}
override async stop(): Promise<void> {
if (this.consumer) {
await this.consumer.close();
this.consumer = null;
}
if (this.responseProducer) {
await this.responseProducer.close();
this.responseProducer = null;
}
if (this.pushProducer) {
await this.pushProducer.close();
this.pushProducer = null;
}
await super.stop();
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function run(): Promise<void> {
await ConfigService.launch("config-svc");
}

View file

@ -0,0 +1,76 @@
/**
* Ollama embeddings service.
*
* Simple HTTP POST to a local Ollama instance to generate embeddings.
* Extends EmbeddingsService from @trustgraph/base so it plugs into the
* flow processor framework (consumer/producer wiring is handled by the base class).
*
* Python reference: trustgraph-flow/trustgraph/embeddings/ollama/processor.py
*/
import {
EmbeddingsService,
type ProcessorConfig,
} from "@trustgraph/base";
export interface OllamaEmbeddingsConfig extends ProcessorConfig {
model?: string;
ollamaHost?: string;
}
interface OllamaEmbedResponse {
embeddings: number[][];
}
export class OllamaEmbeddingsProcessor extends EmbeddingsService {
private defaultModel: string;
private ollamaHost: string;
constructor(config: OllamaEmbeddingsConfig) {
super(config);
this.defaultModel = config.model ?? "mxbai-embed-large";
this.ollamaHost =
config.ollamaHost ??
process.env.OLLAMA_HOST ??
"http://localhost:11434";
console.log(
`[OllamaEmbeddings] Initialized (host=${this.ollamaHost}, model=${this.defaultModel})`,
);
}
async onEmbeddings(texts: string[], model?: string): Promise<number[][]> {
if (!texts || texts.length === 0) {
return [];
}
const useModel = model ?? this.defaultModel;
const url = `${this.ollamaHost}/api/embed`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: useModel,
input: texts,
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(
`Ollama embeddings request failed (${response.status}): ${body}`,
);
}
const data = (await response.json()) as OllamaEmbedResponse;
return data.embeddings;
}
}
export async function run(): Promise<void> {
await OllamaEmbeddingsProcessor.launch("embeddings");
}

View file

@ -1,14 +1,70 @@
/**
* Dispatcher manager routes requests to backend services via pub/sub.
*
* Maintains a service registry mapping service names to NATS topic pairs.
* Applies wire format translation on requests (client internal) and
* reverse translation on responses (internal client).
*
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py
*/
import { NatsBackend, RequestResponse, type PubSubBackend } from "@trustgraph/base";
import type { GatewayConfig } from "../server.js";
import { translateRequest, translateResponse } from "./serialize.js";
export type Responder = (response: unknown, complete: boolean) => Promise<void>;
// ---------- Service registry ----------
/**
* Flow-scoped request/response services.
* These are resolved within a specific flow's interface definitions.
* Topic pattern: tg.flow.<name>-request / tg.flow.<name>-response
*/
const FLOW_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
["agent", { request: "agent-request", response: "agent-response" }],
["text-completion", { request: "text-completion-request", response: "text-completion-response" }],
["prompt", { request: "prompt-request", response: "prompt-response" }],
["graph-rag", { request: "graph-rag-request", response: "graph-rag-response" }],
["document-rag", { request: "document-rag-request", response: "document-rag-response" }],
["embeddings", { request: "embeddings-request", response: "embeddings-response" }],
["graph-embeddings", { request: "graph-embeddings-request", response: "graph-embeddings-response" }],
["document-embeddings", { request: "doc-embeddings-request", response: "doc-embeddings-response" }],
["triples", { request: "triples-request", response: "triples-response" }],
]);
/**
* Global services (not flow-scoped).
* These always use fixed topics regardless of which flow is active.
*/
const GLOBAL_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
["config", { request: "config-request", response: "config-response" }],
["flow", { request: "flow-request", response: "flow-response" }],
["librarian", { request: "librarian-request", response: "librarian-response" }],
["knowledge", { request: "knowledge-request", response: "knowledge-response" }],
["collection-management", { request: "collection-management-request", response: "collection-management-response" }],
]);
/**
* Services that support streaming responses (multiple messages per request).
* The completion flag is determined by checking for end-of-stream markers.
*/
const STREAMING_SERVICES = new Set([
"agent",
"text-completion",
"graph-rag",
"document-rag",
"triples",
"knowledge",
"librarian",
]);
function topicName(name: string): string {
return `tg.flow.${name}`;
}
// ---------- Manager ----------
export class DispatcherManager {
private pubsub: PubSubBackend;
private requestors = new Map<string, RequestResponse<unknown, unknown>>();
@ -18,8 +74,7 @@ export class DispatcherManager {
}
async start(): Promise<void> {
// Pre-create requestors for known global services
// Flow-specific requestors are created on demand
// Requestors are created on demand when first accessed
}
async stop(): Promise<void> {
@ -29,6 +84,8 @@ export class DispatcherManager {
await this.pubsub.close();
}
// ---------- Internal helpers ----------
private async getRequestor(
requestTopic: string,
responseTopic: string,
@ -48,25 +105,71 @@ export class DispatcherManager {
return rr;
}
private resolveGlobalTopics(
kind: string,
): { requestTopic: string; responseTopic: string } {
const entry = GLOBAL_SERVICES.get(kind);
if (entry) {
return {
requestTopic: topicName(entry.request),
responseTopic: topicName(entry.response),
};
}
// Fallback: derive from kind name directly
return {
requestTopic: topicName(`${kind}-request`),
responseTopic: topicName(`${kind}-response`),
};
}
private resolveFlowTopics(
kind: string,
): { requestTopic: string; responseTopic: string } {
const entry = FLOW_SERVICES.get(kind);
if (entry) {
return {
requestTopic: topicName(entry.request),
responseTopic: topicName(entry.response),
};
}
// Fallback: derive from kind name directly
return {
requestTopic: topicName(`${kind}-request`),
responseTopic: topicName(`${kind}-response`),
};
}
/**
* Determine whether a response is the final one in a streaming sequence.
* Checks for various end-of-stream markers used by different services.
*/
private isComplete(response: unknown): boolean {
if (typeof response !== "object" || response === null) return true;
const res = response as Record<string, unknown>;
return (
!!res.complete ||
!!res.endOfStream ||
!!res.endOfSession ||
!!res.end_of_stream ||
!!res.end_of_session ||
!!res.eos ||
// error responses are always final
!!res.error
);
}
// ---------- Global service dispatch ----------
async dispatchGlobalService(
kind: string,
request: Record<string, unknown>,
): Promise<unknown> {
const requestTopic = `tg.flow.${kind}-request`;
const responseTopic = `tg.flow.${kind}-response`;
const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind);
const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`);
return rr.request(request);
}
async dispatchFlowService(
flow: string,
kind: string,
request: Record<string, unknown>,
): Promise<unknown> {
const requestTopic = `tg.flow.${kind}-request`;
const responseTopic = `tg.flow.${kind}-response`;
const rr = await this.getRequestor(requestTopic, responseTopic, `flow:${flow}:${kind}`);
return rr.request(request);
const translated = translateRequest(kind, request);
const response = await rr.request(translated);
return translateResponse(kind, response);
}
async dispatchGlobalServiceStreaming(
@ -74,37 +177,74 @@ export class DispatcherManager {
request: Record<string, unknown>,
responder: Responder,
): Promise<void> {
const requestTopic = `tg.flow.${kind}-request`;
const responseTopic = `tg.flow.${kind}-response`;
const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind);
const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`);
const translated = translateRequest(kind, request);
await rr.request(request, {
await rr.request(translated, {
recipient: async (response) => {
const res = response as Record<string, unknown>;
const complete = !!res.complete || !!res.endOfStream || !!res.endOfSession;
await responder(res, complete);
const translatedRes = translateResponse(kind, response);
const complete = this.isComplete(translatedRes);
await responder(translatedRes, complete);
return complete;
},
});
}
// ---------- Flow-scoped service dispatch ----------
async dispatchFlowService(
flow: string,
kind: string,
request: Record<string, unknown>,
): Promise<unknown> {
const { requestTopic, responseTopic } = this.resolveFlowTopics(kind);
const rr = await this.getRequestor(
requestTopic,
responseTopic,
`flow:${flow}:${kind}`,
);
const translated = translateRequest(kind, request);
const response = await rr.request(translated);
return translateResponse(kind, response);
}
async dispatchFlowServiceStreaming(
flow: string,
kind: string,
request: Record<string, unknown>,
responder: Responder,
): Promise<void> {
const requestTopic = `tg.flow.${kind}-request`;
const responseTopic = `tg.flow.${kind}-response`;
const rr = await this.getRequestor(requestTopic, responseTopic, `flow:${flow}:${kind}`);
const { requestTopic, responseTopic } = this.resolveFlowTopics(kind);
const rr = await this.getRequestor(
requestTopic,
responseTopic,
`flow:${flow}:${kind}`,
);
const translated = translateRequest(kind, request);
await rr.request(request, {
await rr.request(translated, {
recipient: async (response) => {
const res = response as Record<string, unknown>;
const complete = !!res.complete || !!res.endOfStream || !!res.endOfSession;
await responder(res, complete);
const translatedRes = translateResponse(kind, response);
const complete = this.isComplete(translatedRes);
await responder(translatedRes, complete);
return complete;
},
});
}
// ---------- Static introspection ----------
static get flowServiceNames(): readonly string[] {
return [...FLOW_SERVICES.keys()];
}
static get globalServiceNames(): readonly string[] {
return [...GLOBAL_SERVICES.keys()];
}
static isStreamingService(kind: string): boolean {
return STREAMING_SERVICES.has(kind);
}
}

View file

@ -0,0 +1,272 @@
/**
* Wire format serializer translates between the compact client wire format
* (used by @trustgraph/client) and the verbose internal format
* (used by @trustgraph/base services).
*
* Client wire format (compact):
* IRI: { t: "i", i: "<iri>" }
* BLANK: { t: "b", d: "<id>" }
* LITERAL: { t: "l", v: "<value>", dt?: "<datatype>", ln?: "<language>" }
* TRIPLE: { t: "t", tr?: { s, p, o, g? } }
*
* Internal format (verbose):
* IRI: { type: "IRI", iri: "<iri>" }
* BLANK: { type: "BLANK", id: "<id>" }
* LITERAL: { type: "LITERAL", value: "<value>", datatype?: "<dt>", language?: "<lang>" }
* TRIPLE: { type: "TRIPLE", triple: { s, p, o, g? } }
*
* Python reference: trustgraph-base/trustgraph/messaging/translators/primitives.py
*/
import type { Term, Triple } from "@trustgraph/base";
// ---------- Client wire format type definitions ----------
interface ClientIriTerm {
t: "i";
i: string;
}
interface ClientBlankTerm {
t: "b";
d: string;
}
interface ClientLiteralTerm {
t: "l";
v: string;
dt?: string;
ln?: string;
}
interface ClientTripleTerm {
t: "t";
tr?: ClientTriple;
}
type ClientTerm = ClientIriTerm | ClientBlankTerm | ClientLiteralTerm | ClientTripleTerm;
interface ClientTriple {
s: ClientTerm;
p: ClientTerm;
o: ClientTerm;
g?: string;
}
// ---------- Client → Internal ----------
export function clientTermToInternal(wire: ClientTerm): Term {
switch (wire.t) {
case "i":
return { type: "IRI", iri: wire.i };
case "b":
return { type: "BLANK", id: wire.d };
case "l":
return {
type: "LITERAL",
value: wire.v,
datatype: wire.dt,
language: wire.ln,
};
case "t":
return {
type: "TRIPLE",
triple: wire.tr ? clientTripleToInternal(wire.tr) : undefined!,
};
default:
// Defensive: pass through unknown term types
return wire as unknown as Term;
}
}
export function clientTripleToInternal(wire: ClientTriple): Triple {
const result: Triple = {
s: clientTermToInternal(wire.s),
p: clientTermToInternal(wire.p),
o: clientTermToInternal(wire.o),
};
if (wire.g !== undefined) {
// In the client wire format, g is a plain string.
// In the internal format, g is an optional Term (named graph).
// The Python translator treats g as a plain string passthrough,
// so we keep it as-is for compatibility.
(result as unknown as Record<string, unknown>).g = wire.g;
}
return result;
}
// ---------- Internal → Client ----------
export function internalTermToClient(term: Term): ClientTerm {
switch (term.type) {
case "IRI":
return { t: "i", i: term.iri };
case "BLANK":
return { t: "b", d: term.id };
case "LITERAL": {
const lit: ClientLiteralTerm = { t: "l", v: term.value };
if (term.datatype) lit.dt = term.datatype;
if (term.language) lit.ln = term.language;
return lit;
}
case "TRIPLE":
return {
t: "t",
tr: term.triple ? internalTripleToClient(term.triple) : undefined,
};
default:
return term as unknown as ClientTerm;
}
}
export function internalTripleToClient(triple: Triple): ClientTriple {
const result: ClientTriple = {
s: internalTermToClient(triple.s),
p: internalTermToClient(triple.p),
o: internalTermToClient(triple.o),
};
const g = (triple as unknown as Record<string, unknown>).g;
if (g !== undefined && g !== null) {
if (typeof g === "string") {
result.g = g;
} else {
// If g is a Term, convert it back to client wire format
result.g = (g as Record<string, unknown>).iri as string | undefined;
}
}
return result;
}
// ---------- Deep object translation ----------
/**
* Recursively walk an object and translate every Term-shaped value.
* A client term is detected by the presence of a `t` property
* with value "i", "b", "l", or "t".
*/
function isClientTerm(v: unknown): v is ClientTerm {
return (
typeof v === "object" &&
v !== null &&
"t" in v &&
typeof (v as Record<string, unknown>).t === "string" &&
["i", "b", "l", "t"].includes((v as Record<string, unknown>).t as string)
);
}
/**
* An internal term is detected by the presence of a `type` property
* with value "IRI", "BLANK", "LITERAL", or "TRIPLE".
*/
function isInternalTerm(v: unknown): v is Term {
return (
typeof v === "object" &&
v !== null &&
"type" in v &&
typeof (v as Record<string, unknown>).type === "string" &&
["IRI", "BLANK", "LITERAL", "TRIPLE"].includes(
(v as Record<string, unknown>).type as string,
)
);
}
/**
* Deep-translate all client Terms in a request body to internal format.
* Handles nested objects and arrays.
*/
function deepClientToInternal(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) {
return value.map(deepClientToInternal);
}
if (typeof value === "object") {
if (isClientTerm(value)) {
return clientTermToInternal(value);
}
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
result[k] = deepClientToInternal(v);
}
return result;
}
return value;
}
/**
* Deep-translate all internal Terms in a response body to client format.
* Handles nested objects and arrays.
*/
function deepInternalToClient(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) {
return value.map(deepInternalToClient);
}
if (typeof value === "object") {
if (isInternalTerm(value)) {
return internalTermToClient(value);
}
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
result[k] = deepInternalToClient(v);
}
return result;
}
return value;
}
// ---------- Services that contain Term fields ----------
/**
* Services whose requests contain Term/Triple fields that need translation.
* All other services pass through without term translation.
*/
const TERM_BEARING_REQUEST_SERVICES = new Set([
"triples",
"knowledge",
]);
/**
* Services whose responses contain Term/Triple fields that need translation.
*/
const TERM_BEARING_RESPONSE_SERVICES = new Set([
"triples",
"graph-embeddings",
"knowledge",
]);
// ---------- Top-level request / response translators ----------
/**
* Translate a client request body to internal format.
*
* For services that carry Term fields (triples, knowledge), this deep-walks
* the request and converts compact verbose.
* All other services pass through unchanged, since their payloads are simple
* scalar fields (query strings, limits, etc.).
*/
export function translateRequest(service: string, body: unknown): unknown {
if (TERM_BEARING_REQUEST_SERVICES.has(service)) {
return deepClientToInternal(body);
}
return body;
}
/**
* Translate an internal response body to client wire format.
*
* For services that return Term fields (triples, graph-embeddings, knowledge),
* this deep-walks the response and converts verbose compact.
* All other services pass through unchanged.
*/
export function translateResponse(service: string, response: unknown): unknown {
if (TERM_BEARING_RESPONSE_SERVICES.has(service)) {
return deepInternalToClient(response);
}
return response;
}

View file

@ -1,3 +1,11 @@
export { createGateway, run, type GatewayConfig } from "./server.js";
export { DispatcherManager } from "./dispatch/manager.js";
export { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
export {
clientTermToInternal,
clientTripleToInternal,
internalTermToClient,
internalTripleToClient,
translateRequest,
translateResponse,
} from "./dispatch/serialize.js";

View file

@ -2,13 +2,17 @@
* API Gateway HTTP + WebSocket server.
*
* Replaces the Python aiohttp gateway with Fastify.
* Uses the Mux class for WebSocket multiplexing (queue-based request
* buffering, concurrency control, proper task lifecycle).
*
* Python reference: trustgraph-flow/trustgraph/gateway/service.py
*/
import Fastify from "fastify";
import websocketPlugin from "@fastify/websocket";
import { registry } from "@trustgraph/base";
import { DispatcherManager } from "./dispatch/manager.js";
import { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
export interface GatewayConfig {
port: number;
@ -27,7 +31,7 @@ export async function createGateway(config: GatewayConfig) {
// Authentication middleware
app.addHook("onRequest", async (request, reply) => {
if (request.url === "/api/v1/metrics") return;
if (request.url === "/api/v1/socket") return; // Socket auth via query param
if (request.url.startsWith("/api/v1/socket")) return; // Socket auth via query param
if (config.secret) {
const auth = request.headers.authorization;
@ -37,7 +41,7 @@ export async function createGateway(config: GatewayConfig) {
}
});
// REST endpoint: POST /api/v1/:kind
// REST endpoint: POST /api/v1/:kind (global services)
app.post<{ Params: { kind: string } }>("/api/v1/:kind", async (request, reply) => {
const { kind } = request.params;
const body = request.body as Record<string, unknown>;
@ -50,7 +54,7 @@ export async function createGateway(config: GatewayConfig) {
}
});
// REST endpoint: POST /api/v1/flow/:flow/service/:kind
// REST endpoint: POST /api/v1/flow/:flow/service/:kind (flow-scoped services)
app.post<{ Params: { flow: string; kind: string } }>(
"/api/v1/flow/:flow/service/:kind",
async (request, reply) => {
@ -67,6 +71,7 @@ export async function createGateway(config: GatewayConfig) {
);
// WebSocket endpoint: /api/v1/socket
// Uses Mux for queue-based request buffering and concurrency control.
app.get("/api/v1/socket", { websocket: true }, (socket, request) => {
// Auth via query param
const url = new URL(request.url, `http://${request.headers.host}`);
@ -76,26 +81,67 @@ export async function createGateway(config: GatewayConfig) {
return;
}
socket.on("message", async (data) => {
try {
const msg = JSON.parse(data.toString());
const { id, service, flow, request: req } = msg;
// Build the MuxHandler that dispatches to the DispatcherManager
const handler: MuxHandler = async (muxReq, respond) => {
if (muxReq.flow) {
await dispatcher.dispatchFlowServiceStreaming(
muxReq.flow,
muxReq.service,
muxReq.request,
respond,
);
} else {
await dispatcher.dispatchGlobalServiceStreaming(
muxReq.service,
muxReq.request,
respond,
);
}
};
const responder = async (response: unknown, complete: boolean) => {
socket.send(JSON.stringify({ id, response, complete }));
const mux = new Mux(handler);
// Start the Mux run loop — sends responses back over the socket
const runPromise = mux.run((data) => {
// Only send if the socket is still open (readyState 1 = OPEN)
if (socket.readyState === 1) {
socket.send(data);
}
});
// Incoming messages get queued into the Mux
socket.on("message", (data) => {
try {
const msg = JSON.parse(data.toString()) as {
id?: string;
service?: string;
flow?: string;
request?: Record<string, unknown>;
};
if (flow) {
await dispatcher.dispatchFlowServiceStreaming(flow, service, req, responder);
} else {
await dispatcher.dispatchGlobalServiceStreaming(service, req, responder);
if (!msg.id || !msg.service || !msg.request) {
socket.send(
JSON.stringify({
id: msg.id ?? null,
error: { type: "bad-request", message: "Missing id, service, or request" },
complete: true,
}),
);
return;
}
const muxReq: MuxRequest = {
id: msg.id,
service: msg.service,
flow: msg.flow,
request: msg.request,
};
mux.receive(muxReq);
} catch (err) {
const msg = JSON.parse(data.toString());
socket.send(
JSON.stringify({
id: msg.id,
error: { type: "internal", message: String(err) },
error: { type: "parse-error", message: String(err) },
complete: true,
}),
);
@ -103,13 +149,26 @@ export async function createGateway(config: GatewayConfig) {
});
socket.on("close", () => {
// Cleanup
mux.stop();
});
socket.on("error", () => {
mux.stop();
});
// Ensure runPromise errors don't go unhandled
runPromise.catch((err) => {
console.error("[Gateway] Mux run loop error:", err);
mux.stop();
if (socket.readyState === 1) {
socket.close(1011, "Internal server error");
}
});
});
// Metrics endpoint
app.get("/api/v1/metrics", async () => {
const { registry } = await import("@trustgraph/base");
// Metrics endpoint — returns Prometheus metrics from prom-client
app.get("/api/v1/metrics", async (_, reply) => {
reply.header("content-type", registry.contentType);
return registry.metrics();
});

View file

@ -5,3 +5,42 @@ export { OpenAIProcessor } from "./model/text-completion/openai.js";
export { ClaudeProcessor } from "./model/text-completion/claude.js";
export { GraphRag, type GraphRagConfig, type GraphRagClients } from "./retrieval/graph-rag.js";
export { DocumentRag, type DocumentRagClients } from "./retrieval/document-rag.js";
export { FalkorDBTriplesStore, type FalkorDBConfig } from "./storage/triples/falkordb.js";
export { FalkorDBTriplesQuery, type FalkorDBQueryConfig } from "./query/triples/falkordb.js";
// Qdrant embeddings storage
export {
QdrantDocEmbeddingsStore,
type QdrantDocEmbeddingsConfig,
type DocEmbeddingsMessage,
type DocEmbeddingChunk,
} from "./storage/embeddings/qdrant-doc.js";
export {
QdrantGraphEmbeddingsStore,
type QdrantGraphEmbeddingsConfig,
type GraphEmbeddingsMessage,
type GraphEmbeddingEntity,
} from "./storage/embeddings/qdrant-graph.js";
// Qdrant embeddings query
export {
QdrantDocEmbeddingsQuery,
type QdrantDocQueryConfig,
type ChunkMatch,
type DocEmbeddingsQueryRequest,
} from "./query/embeddings/qdrant-doc.js";
export {
QdrantGraphEmbeddingsQuery,
type QdrantGraphQueryConfig,
type EntityMatch,
type GraphEmbeddingsQueryRequest,
} from "./query/embeddings/qdrant-graph.js";
// Embeddings services
export { OllamaEmbeddingsProcessor, type OllamaEmbeddingsConfig } from "./embeddings/ollama.js";
// Prompt template service
export { PromptTemplateService, type PromptTemplate, type PromptTemplateConfig } from "./prompt/template.js";
// Config service
export { ConfigService, type ConfigServiceConfig } from "./config/service.js";

View file

@ -0,0 +1,154 @@
/**
* Prompt template service.
*
* A FlowProcessor that:
* 1. Consumes prompt requests (name + variables)
* 2. Looks up template by name from an in-memory template map (loaded via config)
* 3. Renders template: replaces {variable} placeholders with values
* 4. Returns { system, prompt } strings
*
* Template config shape (received via config push):
* {
* "prompt": {
* "extract-concepts": {
* "system": "You are a helpful assistant.",
* "prompt": "Extract key concepts from: {query}"
* },
* "graph-rag-synthesize": {
* "system": "You are a knowledge graph assistant.",
* "prompt": "Given this context:\n{context}\n\nAnswer: {query}"
* }
* }
* }
*
* Python reference: trustgraph-flow/trustgraph/prompt/template/service.py
*/
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
type ProcessorConfig,
type FlowContext,
type PromptRequest,
type PromptResponse,
} from "@trustgraph/base";
export interface PromptTemplate {
system: string;
prompt: string;
}
export interface PromptTemplateConfig extends ProcessorConfig {
configKey?: string;
}
export class PromptTemplateService extends FlowProcessor {
private templates = new Map<string, PromptTemplate>();
private configKey: string;
constructor(config: PromptTemplateConfig) {
super(config);
this.configKey = config.configKey ?? "prompt";
this.registerSpecification(
new ConsumerSpec<PromptRequest>(
"request",
this.onRequest.bind(this),
),
);
this.registerSpecification(new ProducerSpec<PromptResponse>("response"));
this.registerConfigHandler(this.onPromptConfig.bind(this));
console.log("[PromptTemplate] Service initialized");
}
private async onPromptConfig(
config: Record<string, unknown>,
version: number,
): Promise<void> {
console.log(`[PromptTemplate] Loading prompt configuration version ${version}`);
const promptConfig = config[this.configKey] as
| Record<string, { system?: string; prompt?: string }>
| undefined;
if (!promptConfig) {
console.warn(`[PromptTemplate] No key "${this.configKey}" in config`);
return;
}
try {
this.templates.clear();
for (const [name, template] of Object.entries(promptConfig)) {
this.templates.set(name, {
system: template.system ?? "",
prompt: template.prompt ?? "",
});
}
console.log(
`[PromptTemplate] Loaded ${this.templates.size} template(s): ${[...this.templates.keys()].join(", ")}`,
);
} catch (err) {
console.error("[PromptTemplate] Failed to load prompt configuration:", err);
}
}
private async onRequest(
msg: PromptRequest,
properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
const responseProducer = flowCtx.flow.producer<PromptResponse>("response");
try {
const template = this.templates.get(msg.name);
if (!template) {
throw new Error(`Unknown prompt template: "${msg.name}"`);
}
const variables = msg.variables ?? {};
const system = renderTemplate(template.system, variables);
const prompt = renderTemplate(template.prompt, variables);
await responseProducer.send(requestId, { system, prompt });
} catch (err) {
console.error(`[PromptTemplate] Error processing request:`, err);
const message = err instanceof Error ? err.message : String(err);
await responseProducer.send(requestId, {
system: "",
prompt: "",
error: { type: "prompt-error", message },
});
}
}
}
/**
* Simple template rendering: replaces {variable} placeholders with values.
* Unmatched placeholders are left as-is.
*/
function renderTemplate(
template: string,
variables: Record<string, string>,
): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
if (key in variables) {
return variables[key];
}
return match;
});
}
export async function run(): Promise<void> {
await PromptTemplateService.launch("prompt");
}

View file

@ -0,0 +1,80 @@
/**
* Qdrant document embeddings query service.
*
* Input: vector, user, collection, limit
* Output: list of { chunkId, score } matches
*
* Python reference: trustgraph-flow/trustgraph/query/doc_embeddings/qdrant/service.py
*/
import { QdrantClient } from "@qdrant/js-client-rest";
export interface QdrantDocQueryConfig {
url?: string;
apiKey?: string;
}
export interface ChunkMatch {
chunkId: string;
score: number;
}
export interface DocEmbeddingsQueryRequest {
vector: number[];
user: string;
collection: string;
limit: number;
}
export class QdrantDocEmbeddingsQuery {
private client: QdrantClient;
constructor(config: QdrantDocQueryConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
console.log("[QdrantDocQuery] Query service initialized");
}
async query(request: DocEmbeddingsQueryRequest): Promise<ChunkMatch[]> {
const { vector, user, collection, limit } = request;
if (!vector || vector.length === 0) {
return [];
}
const dim = vector.length;
const collectionName = `d_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not
const exists = await this.client.collectionExists(collectionName);
if (!exists.exists) {
console.log(
`[QdrantDocQuery] Collection ${collectionName} does not exist, returning empty results`,
);
return [];
}
const searchResult = await this.client.search(collectionName, {
vector,
limit,
with_payload: true,
});
const chunks: ChunkMatch[] = [];
for (const point of searchResult) {
const payload = point.payload as Record<string, unknown> | undefined;
const chunkId = payload?.chunk_id as string | undefined;
if (chunkId) {
chunks.push({
chunkId,
score: point.score,
});
}
}
return chunks;
}
}

View file

@ -0,0 +1,103 @@
/**
* Qdrant graph embeddings query service.
*
* Input: vector, user, collection, limit
* Output: list of Term entities with scores, deduplicated by entity value
*
* Queries limit*2 points and deduplicates by entity value to ensure
* we return up to `limit` unique entities.
*
* Python reference: trustgraph-flow/trustgraph/query/graph_embeddings/qdrant/service.py
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import type { Term } from "@trustgraph/base";
export interface QdrantGraphQueryConfig {
url?: string;
apiKey?: string;
}
export interface EntityMatch {
entity: Term;
score: number;
}
export interface GraphEmbeddingsQueryRequest {
vector: number[];
user: string;
collection: string;
limit: number;
}
function createTerm(value: string): Term {
if (value.startsWith("http://") || value.startsWith("https://")) {
return { type: "IRI", iri: value };
}
return { type: "LITERAL", value };
}
export class QdrantGraphEmbeddingsQuery {
private client: QdrantClient;
constructor(config: QdrantGraphQueryConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
console.log("[QdrantGraphQuery] Query service initialized");
}
async query(request: GraphEmbeddingsQueryRequest): Promise<EntityMatch[]> {
const { vector, user, collection, limit } = request;
if (!vector || vector.length === 0) {
return [];
}
const dim = vector.length;
const collectionName = `t_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not
const exists = await this.client.collectionExists(collectionName);
if (!exists.exists) {
console.log(
`[QdrantGraphQuery] Collection ${collectionName} does not exist, returning empty results`,
);
return [];
}
// Query 2x the limit so we have a better chance of getting `limit`
// unique entities after deduplication (same heuristic as Python impl)
const searchResult = await this.client.search(collectionName, {
vector,
limit: limit * 2,
with_payload: true,
});
const entitySet = new Set<string>();
const entities: EntityMatch[] = [];
for (const point of searchResult) {
const payload = point.payload as Record<string, unknown> | undefined;
const entityValue = payload?.entity as string | undefined;
if (!entityValue) continue;
// Deduplicate by entity value, keeping the highest score (results are
// already sorted by score descending from Qdrant)
if (!entitySet.has(entityValue)) {
entitySet.add(entityValue);
entities.push({
entity: createTerm(entityValue),
score: point.score,
});
}
// Stop once we have enough unique entities
if (entities.length >= limit) break;
}
return entities;
}
}

View file

@ -0,0 +1,243 @@
/**
* FalkorDB triples query service queries RDF triples from FalkorDB.
*
* Implements all SPO query patterns (S, P, O, SP, SO, PO, SPO, *).
*
* Python reference: trustgraph-flow/trustgraph/query/triples/falkordb/service.py
*/
import { createClient, Graph } from "falkordb";
import type { Term, Triple } from "@trustgraph/base";
export interface FalkorDBQueryConfig {
url?: string;
database?: string;
}
function termToValue(term: Term | undefined): string | null {
if (!term) return null;
switch (term.type) {
case "IRI": return term.iri;
case "LITERAL": return term.value;
case "BLANK": return term.id;
default: return null;
}
}
function createTerm(value: string): Term {
if (value.startsWith("http://") || value.startsWith("https://")) {
return { type: "IRI", iri: value };
}
return { type: "LITERAL", value };
}
export class FalkorDBTriplesQuery {
private graph: Graph;
constructor(config: FalkorDBQueryConfig = {}) {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
const client = createClient({ url });
this.graph = new Graph(client, database);
}
async queryTriples(
s?: Term,
p?: Term,
o?: Term,
limit = 100,
): Promise<Triple[]> {
const sv = termToValue(s);
const pv = termToValue(p);
const ov = termToValue(o);
const rawTriples: [string, string, string][] = [];
// Query both Node and Literal targets for each pattern
if (sv && pv && ov) {
// SPO — exact match
await this.matchPattern(rawTriples, sv, pv, ov, limit);
} else if (sv && pv) {
// SP — known subject + predicate
await this.matchSP(rawTriples, sv, pv, limit);
} else if (sv && ov) {
// SO — known subject + object
await this.matchSO(rawTriples, sv, ov, limit);
} else if (pv && ov) {
// PO — known predicate + object
await this.matchPO(rawTriples, pv, ov, limit);
} else if (sv) {
// S only
await this.matchS(rawTriples, sv, limit);
} else if (pv) {
// P only
await this.matchP(rawTriples, pv, limit);
} else if (ov) {
// O only
await this.matchO(rawTriples, ov, limit);
} else {
// Wildcard — all triples
await this.matchAll(rawTriples, limit);
}
return rawTriples.slice(0, limit).map(([s, p, o]) => ({
s: createTerm(s),
p: createTerm(p),
o: createTerm(o),
}));
}
private async matchPattern(
out: [string, string, string][],
sv: string, pv: string, ov: string, limit: number,
): Promise<void> {
for (const destType of ["Literal", "Node"] as const) {
const destKey = destType === "Literal" ? "value" : "uri";
const result = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri LIMIT ${limit}`,
{ params: { src: sv, rel: pv, dest: ov } },
);
for (const _rec of (result.data ?? []) as unknown[][]) {
out.push([sv, pv, ov]);
}
}
}
private async matchSP(
out: [string, string, string][],
sv: string, pv: string, limit: number,
): Promise<void> {
// Literals
const litResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN dest.value as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (litResult.data ?? []) as string[][]) {
out.push([sv, pv, rec[0] as string]);
}
// Nodes
const nodeResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (nodeResult.data ?? []) as string[][]) {
out.push([sv, pv, rec[0] as string]);
}
}
private async matchSO(
out: [string, string, string][],
sv: string, ov: string, limit: number,
): Promise<void> {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN rel.uri as rel LIMIT ${limit}`,
{ params: { src: sv, dest: ov } },
);
for (const rec of (result.data ?? []) as string[][]) {
out.push([sv, rec[0] as string, ov]);
}
}
}
private async matchPO(
out: [string, string, string][],
pv: string, ov: string, limit: number,
): Promise<void> {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await this.graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src LIMIT ${limit}`,
{ params: { rel: pv, dest: ov } },
);
for (const rec of (result.data ?? []) as string[][]) {
out.push([rec[0] as string, pv, ov]);
}
}
}
private async matchS(
out: [string, string, string][],
sv: string, limit: number,
): Promise<void> {
const litResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Literal) ` +
`RETURN rel.uri as rel, dest.value as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (litResult.data ?? []) as string[][]) {
out.push([sv, rec[0] as string, rec[1] as string]);
}
const nodeResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Node) ` +
`RETURN rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (nodeResult.data ?? []) as string[][]) {
out.push([sv, rec[0] as string, rec[1] as string]);
}
}
private async matchP(
out: [string, string, string][],
pv: string, limit: number,
): Promise<void> {
const litResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN src.uri as src, dest.value as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (litResult.data ?? []) as string[][]) {
out.push([rec[0] as string, pv, rec[1] as string]);
}
const nodeResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN src.uri as src, dest.uri as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (nodeResult.data ?? []) as string[][]) {
out.push([rec[0] as string, pv, rec[1] as string]);
}
}
private async matchO(
out: [string, string, string][],
ov: string, limit: number,
): Promise<void> {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await this.graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src, rel.uri as rel LIMIT ${limit}`,
{ params: { dest: ov } },
);
for (const rec of (result.data ?? []) as string[][]) {
out.push([rec[0] as string, rec[1] as string, ov]);
}
}
}
private async matchAll(
out: [string, string, string][],
limit: number,
): Promise<void> {
const litResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Literal) ` +
`RETURN src.uri as src, rel.uri as rel, dest.value as dest LIMIT ${limit}`,
);
for (const rec of (litResult.data ?? []) as string[][]) {
out.push([rec[0] as string, rec[1] as string, rec[2] as string]);
}
const nodeResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Node) ` +
`RETURN src.uri as src, rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
);
for (const rec of (nodeResult.data ?? []) as string[][]) {
out.push([rec[0] as string, rec[1] as string, rec[2] as string]);
}
}
}

View file

@ -124,26 +124,156 @@ export class GraphRag {
}
private async followEdges(entities: Term[]): Promise<Triple[]> {
// Batch triple queries for all entities
const allTriples: Triple[] = [];
// BFS multi-hop traversal up to maxPathLength
const visited = new Set<string>();
const subgraph: Triple[] = [];
const queries = entities.map((entity) =>
this.clients.triples.request({ s: entity, limit: this.config.tripleLimit }),
// Current frontier: the set of entities to expand at this depth level
let currentLevel = new Set<string>(
entities.map((e) => termToString(e)),
);
const results = await Promise.all(queries);
for (const result of results) {
allTriples.push(...(result as TriplesQueryResponse).triples);
for (let depth = 0; depth < this.config.maxPathLength; depth++) {
if (currentLevel.size === 0 || subgraph.length >= this.config.maxSubgraphSize) {
break;
}
// Filter out already-visited entities
const unvisited = [...currentLevel].filter((e) => !visited.has(e));
if (unvisited.length === 0) break;
// Batch triple queries for all unvisited entities at this depth
// Query each entity as subject to get outgoing edges
const queries = unvisited.map((entityStr) => {
const term = stringToTerm(entityStr);
return this.clients.triples.request({
s: term,
limit: this.config.tripleLimit,
});
});
const results = await Promise.all(queries);
const nextLevel = new Set<string>();
for (const result of results) {
const triples = (result as TriplesQueryResponse).triples;
for (const triple of triples) {
subgraph.push(triple);
// Collect objects as next-level entities for further expansion
// (only if we have more depth levels remaining)
if (depth < this.config.maxPathLength - 1) {
const objStr = termToString(triple.o);
if (!visited.has(objStr)) {
nextLevel.add(objStr);
}
}
if (subgraph.length >= this.config.maxSubgraphSize) {
return subgraph;
}
}
}
// Mark current level as visited and move to next
for (const e of currentLevel) {
visited.add(e);
}
currentLevel = nextLevel;
}
// TODO: Multi-hop traversal up to maxPathLength
return allTriples.slice(0, this.config.maxSubgraphSize);
return subgraph.slice(0, this.config.maxSubgraphSize);
}
private async scoreEdges(query: string, triples: Triple[]): Promise<Triple[]> {
// TODO: LLM-based edge scoring and filtering
// For now, return top N edges
return triples.slice(0, this.config.edgeLimit);
if (triples.length === 0) return [];
// If the subgraph is small enough, skip LLM scoring entirely
if (triples.length <= this.config.edgeLimit) {
return triples;
}
// Build a numbered list of edges for the LLM to score
const edgeDescriptions = triples.map((t, i) => ({
id: String(i),
s: termToString(t.s),
p: termToString(t.p),
o: termToString(t.o),
}));
// Limit how many edges we send for scoring to avoid overflowing context
const toScore = edgeDescriptions.slice(0, this.config.edgeScoreLimit);
const knowledgeJson = JSON.stringify(toScore, null, 2);
// Ask the LLM to score each edge for relevance to the query
const promptResp = await this.clients.prompt.request({
name: "kg-edge-scoring",
variables: {
query,
knowledge: knowledgeJson,
},
});
const llmResp = await this.clients.llm.request({
system: (promptResp as PromptResponse).system,
prompt: (promptResp as PromptResponse).prompt,
});
const responseText = (llmResp as TextCompletionResponse).response;
// Parse scores from LLM response
// Expected format: JSON array of { id: string, score: number }
// or newline-separated JSON objects
const scored: Array<{ id: string; score: number }> = [];
try {
// Try parsing as a JSON array first
const parsed = JSON.parse(responseText) as Array<{ id: string; score: number }>;
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item && typeof item.id === "string" && typeof item.score === "number") {
scored.push({ id: item.id, score: item.score });
}
}
}
} catch {
// Fall back to parsing line-by-line JSON objects
for (const line of responseText.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as { id?: string; score?: number };
if (obj && typeof obj.id === "string" && typeof obj.score === "number") {
scored.push({ id: obj.id, score: obj.score });
}
} catch {
// Skip unparseable lines
}
}
}
// Sort by score descending and keep top N
scored.sort((a, b) => b.score - a.score);
const topN = scored.slice(0, this.config.edgeLimit);
const selectedIds = new Set(topN.map((e) => e.id));
// Map back to triples
const result: Triple[] = [];
for (const entry of topN) {
const idx = parseInt(entry.id, 10);
if (!isNaN(idx) && idx >= 0 && idx < triples.length) {
result.push(triples[idx]);
}
}
// If scoring failed entirely, fall back to returning the first edgeLimit triples
if (result.length === 0) {
return triples.slice(0, this.config.edgeLimit);
}
return result;
}
private async synthesize(
@ -205,3 +335,13 @@ function termToString(term: Term): string {
return `(${termToString(term.triple.s)} ${termToString(term.triple.p)} ${termToString(term.triple.o)})`;
}
}
function stringToTerm(value: string): Term {
if (value.startsWith("http://") || value.startsWith("https://")) {
return { type: "IRI", iri: value };
}
if (value.startsWith("_:")) {
return { type: "BLANK", id: value.slice(2) };
}
return { type: "LITERAL", value };
}

View file

@ -0,0 +1,106 @@
/**
* Qdrant document embeddings write service.
*
* Stores document chunk embeddings in Qdrant for later similarity search.
* Collection naming: d_{user}_{collection}_{dimension}
* Collections are lazily created on first write with cosine distance.
*
* Python reference: trustgraph-flow/trustgraph/storage/doc_embeddings/qdrant/write.py
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import { randomUUID } from "node:crypto";
export interface QdrantDocEmbeddingsConfig {
url?: string;
apiKey?: string;
}
export interface DocEmbeddingChunk {
chunkId: string;
vector: number[];
}
export interface DocEmbeddingsMessage {
user: string;
collection: string;
chunks: DocEmbeddingChunk[];
}
export class QdrantDocEmbeddingsStore {
private client: QdrantClient;
private knownCollections = new Set<string>();
constructor(config: QdrantDocEmbeddingsConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
console.log("[QdrantDocEmbeddings] Store initialized");
}
private collectionName(user: string, collection: string, dim: number): string {
return `d_${user}_${collection}_${dim}`;
}
private async ensureCollection(name: string, dim: number): Promise<void> {
if (this.knownCollections.has(name)) return;
const exists = await this.client.collectionExists(name);
if (!exists.exists) {
console.log(`[QdrantDocEmbeddings] Creating collection ${name} (dim=${dim})`);
await this.client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
});
}
this.knownCollections.add(name);
}
async store(message: DocEmbeddingsMessage): Promise<void> {
for (const chunk of message.chunks) {
if (!chunk.chunkId || chunk.chunkId === "") continue;
if (!chunk.vector || chunk.vector.length === 0) continue;
const dim = chunk.vector.length;
const name = this.collectionName(message.user, message.collection, dim);
await this.ensureCollection(name, dim);
await this.client.upsert(name, {
points: [
{
id: randomUUID(),
vector: chunk.vector,
payload: { chunk_id: chunk.chunkId },
},
],
});
}
}
async deleteCollection(user: string, collection: string): Promise<void> {
const prefix = `d_${user}_${collection}_`;
const allCollections = await this.client.getCollections();
const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix),
);
if (matching.length === 0) {
console.log(`[QdrantDocEmbeddings] No collections matching prefix ${prefix}`);
return;
}
for (const coll of matching) {
await this.client.deleteCollection(coll.name);
this.knownCollections.delete(coll.name);
console.log(`[QdrantDocEmbeddings] Deleted collection: ${coll.name}`);
}
console.log(
`[QdrantDocEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
);
}
}

View file

@ -0,0 +1,127 @@
/**
* Qdrant graph embeddings write service.
*
* Stores entity/vector pairs in Qdrant for graph embeddings lookup.
* Collection naming: t_{user}_{collection}_{dimension}
* Collections are lazily created on first write with cosine distance.
*
* Python reference: trustgraph-flow/trustgraph/storage/graph_embeddings/qdrant/write.py
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import { randomUUID } from "node:crypto";
import type { Term } from "@trustgraph/base";
export interface QdrantGraphEmbeddingsConfig {
url?: string;
apiKey?: string;
}
export interface GraphEmbeddingEntity {
entity: Term;
vector: number[];
chunkId?: string;
}
export interface GraphEmbeddingsMessage {
user: string;
collection: string;
entities: GraphEmbeddingEntity[];
}
function getTermValue(term: Term): string | null {
switch (term.type) {
case "IRI":
return term.iri;
case "LITERAL":
return term.value;
case "BLANK":
return term.id;
case "TRIPLE":
return null;
}
}
export class QdrantGraphEmbeddingsStore {
private client: QdrantClient;
private knownCollections = new Set<string>();
constructor(config: QdrantGraphEmbeddingsConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
console.log("[QdrantGraphEmbeddings] Store initialized");
}
private collectionName(user: string, collection: string, dim: number): string {
return `t_${user}_${collection}_${dim}`;
}
private async ensureCollection(name: string, dim: number): Promise<void> {
if (this.knownCollections.has(name)) return;
const exists = await this.client.collectionExists(name);
if (!exists.exists) {
console.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`);
await this.client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
});
}
this.knownCollections.add(name);
}
async store(message: GraphEmbeddingsMessage): Promise<void> {
for (const entry of message.entities) {
const entityValue = getTermValue(entry.entity);
if (!entityValue || entityValue === "") continue;
if (!entry.vector || entry.vector.length === 0) continue;
const dim = entry.vector.length;
const name = this.collectionName(message.user, message.collection, dim);
await this.ensureCollection(name, dim);
const payload: Record<string, unknown> = { entity: entityValue };
if (entry.chunkId) {
payload.chunk_id = entry.chunkId;
}
await this.client.upsert(name, {
points: [
{
id: randomUUID(),
vector: entry.vector,
payload,
},
],
});
}
}
async deleteCollection(user: string, collection: string): Promise<void> {
const prefix = `t_${user}_${collection}_`;
const allCollections = await this.client.getCollections();
const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix),
);
if (matching.length === 0) {
console.log(`[QdrantGraphEmbeddings] No collections matching prefix ${prefix}`);
return;
}
for (const coll of matching) {
await this.client.deleteCollection(coll.name);
this.knownCollections.delete(coll.name);
console.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`);
}
console.log(
`[QdrantGraphEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
);
}
}

View file

@ -0,0 +1,116 @@
/**
* FalkorDB triples store writes RDF triples to a FalkorDB graph.
*
* FalkorDB is Redis-based and uses Cypher queries, same as the Python impl.
* Pairs well with Graphiti which also uses FalkorDB as its backend.
*
* Python reference: trustgraph-flow/trustgraph/storage/triples/falkordb/write.py
*/
import { createClient, Graph } from "falkordb";
import type { Term, Triple } from "@trustgraph/base";
export interface FalkorDBConfig {
url?: string;
database?: string;
}
function getTermValue(term: Term): string {
switch (term.type) {
case "IRI":
return term.iri;
case "LITERAL":
return term.value;
case "BLANK":
return term.id;
case "TRIPLE":
return getTermValue(term.triple.s); // fallback
}
}
export class FalkorDBTriplesStore {
private graph: Graph;
constructor(config: FalkorDBConfig = {}) {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
const client = createClient({ url });
this.graph = new Graph(client, database);
}
async createNode(uri: string, user: string, collection: string): Promise<void> {
await this.graph.query(
"MERGE (n:Node {uri: $uri, user: $user, collection: $collection})",
{ params: { uri, user, collection } },
);
}
async createLiteral(value: string, user: string, collection: string): Promise<void> {
await this.graph.query(
"MERGE (n:Literal {value: $value, user: $user, collection: $collection})",
{ params: { value, user, collection } },
);
}
async relateNode(
src: string, uri: string, dest: string,
user: string, collection: string,
): Promise<void> {
await this.graph.query(
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Node {uri: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
}
async relateLiteral(
src: string, uri: string, dest: string,
user: string, collection: string,
): Promise<void> {
await this.graph.query(
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Literal {value: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
}
async storeTriples(
triples: Triple[],
user = "default",
collection = "default",
): Promise<void> {
for (const t of triples) {
const s = getTermValue(t.s);
const p = getTermValue(t.p);
const o = getTermValue(t.o);
await this.createNode(s, user, collection);
if (t.o.type === "IRI") {
await this.createNode(o, user, collection);
await this.relateNode(s, p, o, user, collection);
} else {
await this.createLiteral(o, user, collection);
await this.relateLiteral(s, p, o, user, collection);
}
}
}
async deleteCollection(user: string, collection: string): Promise<void> {
await this.graph.query(
"MATCH (n:Node {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
await this.graph.query(
"MATCH (n:Literal {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
await this.graph.query(
"MATCH (c:CollectionMetadata {user: $user, collection: $collection}) DELETE c",
{ params: { user, collection } },
);
}
}

View file

@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"composite": true
},
"include": ["src"],
"references": [

View file

@ -12,11 +12,12 @@
},
"dependencies": {
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"@modelcontextprotocol/sdk": "^1.8.0",
"ws": "^8.18.0"
"zod": "^3.23.0"
},
"devDependencies": {
"@types/ws": "^8.5.0",
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
}

View file

@ -1,2 +1 @@
export { createMcpServer, run } from "./server.js";
export { SocketManager, type SocketManagerConfig } from "./socket-manager.js";

View file

@ -2,7 +2,7 @@
* TrustGraph MCP server.
*
* Exposes TrustGraph capabilities as MCP tools for AI assistants.
* Communicates with the TrustGraph gateway via WebSocket.
* Uses the vendored @trustgraph/client for all gateway communication.
*
* Python reference: trustgraph-mcp/trustgraph/mcp_server/mcp.py
*/
@ -10,10 +10,11 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { SocketManager } from "./socket-manager.js";
import { createTrustGraphSocket, type BaseApi, type Term } from "@trustgraph/client";
export function createMcpServer(config: {
gatewayUrl: string;
user?: string;
token?: string;
flowId?: string;
}) {
@ -22,13 +23,17 @@ export function createMcpServer(config: {
version: "0.1.0",
});
const socket = new SocketManager({
gatewayUrl: config.gatewayUrl,
token: config.token,
});
const user = config.user ?? "mcp";
const socket: BaseApi = createTrustGraphSocket(
user,
config.token,
config.gatewayUrl,
);
const flowId = config.flowId ?? "default";
// ===================== Flow-scoped tools =====================
// --- Text Completion ---
server.tool(
"text_completion",
@ -38,8 +43,9 @@ export function createMcpServer(config: {
prompt: z.string().describe("User prompt"),
},
async ({ system, prompt }) => {
const resp = await socket.request("text-completion", { system, prompt }, { flowId }) as Record<string, unknown>;
return { content: [{ type: "text" as const, text: String(resp.response ?? resp) }] };
const flow = socket.flow(flowId);
const response = await flow.textCompletion(system, prompt);
return { content: [{ type: "text" as const, text: response }] };
},
);
@ -51,14 +57,32 @@ export function createMcpServer(config: {
query: z.string().describe("Natural language query"),
entity_limit: z.number().optional().describe("Max entities to retrieve"),
triple_limit: z.number().optional().describe("Max triples per entity"),
collection: z.string().optional().describe("Collection name"),
},
async ({ query, entity_limit, triple_limit }) => {
const resp = await socket.request(
"graph-rag",
{ query, entity_limit, triple_limit },
{ flowId },
) as Record<string, unknown>;
return { content: [{ type: "text" as const, text: String(resp.response ?? resp) }] };
async ({ query, entity_limit, triple_limit, collection }) => {
const flow = socket.flow(flowId);
const response = await flow.graphRag(
query,
{ entityLimit: entity_limit, tripleLimit: triple_limit },
collection,
);
return { content: [{ type: "text" as const, text: response }] };
},
);
// --- Document RAG ---
server.tool(
"document_rag",
"Query documents using RAG",
{
query: z.string().describe("Natural language query"),
doc_limit: z.number().optional().describe("Max documents to retrieve"),
collection: z.string().optional().describe("Collection name"),
},
async ({ query, doc_limit, collection }) => {
const flow = socket.flow(flowId);
const response = await flow.documentRag(query, doc_limit, collection);
return { content: [{ type: "text" as const, text: response }] };
},
);
@ -70,8 +94,23 @@ export function createMcpServer(config: {
question: z.string().describe("Question for the agent"),
},
async ({ question }) => {
const resp = await socket.request("agent", { question }, { flowId }) as Record<string, unknown>;
return { content: [{ type: "text" as const, text: String(resp.answer ?? resp) }] };
const flow = socket.flow(flowId);
let fullAnswer = "";
await new Promise<void>((resolve, reject) => {
flow.agent(
question,
() => {}, // think — ignore for MCP
() => {}, // observe — ignore for MCP
(chunk, complete) => {
fullAnswer += chunk;
if (complete) resolve();
},
(err) => reject(new Error(err)),
);
});
return { content: [{ type: "text" as const, text: fullAnswer }] };
},
);
@ -83,8 +122,9 @@ export function createMcpServer(config: {
text: z.array(z.string()).describe("Texts to embed"),
},
async ({ text }) => {
const resp = await socket.request("embeddings", { text }, { flowId }) as Record<string, unknown>;
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
const flow = socket.flow(flowId);
const vectors = await flow.embeddings(text);
return { content: [{ type: "text" as const, text: JSON.stringify(vectors) }] };
},
);
@ -97,15 +137,15 @@ export function createMcpServer(config: {
p: z.string().optional().describe("Predicate IRI"),
o: z.string().optional().describe("Object IRI or literal"),
limit: z.number().optional().describe("Max results"),
collection: z.string().optional().describe("Collection name"),
},
async ({ s, p, o, limit }) => {
const request: Record<string, unknown> = { limit };
if (s) request.s = { type: "IRI", iri: s };
if (p) request.p = { type: "IRI", iri: p };
if (o) request.o = { type: "IRI", iri: o };
const resp = await socket.request("triples-query", request, { flowId }) as Record<string, unknown>;
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
async ({ s, p, o, limit, collection }) => {
const flow = socket.flow(flowId);
const sTerm: Term | undefined = s ? { t: "i", i: s } : undefined;
const pTerm: Term | undefined = p ? { t: "i", i: p } : undefined;
const oTerm: Term | undefined = o ? { t: "i", i: o } : undefined;
const triples = await flow.triplesQuery(sTerm, pTerm, oTerm, limit, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(triples, null, 2) }] };
},
);
@ -116,28 +156,48 @@ export function createMcpServer(config: {
{
query: z.string().describe("Text to find similar entities for"),
limit: z.number().optional().describe("Max results"),
collection: z.string().optional().describe("Collection name"),
},
async ({ query, limit }) => {
async ({ query, limit, collection }) => {
const flow = socket.flow(flowId);
// First embed the query, then search
const embResp = await socket.request("embeddings", { text: [query] }, { flowId }) as { vectors: number[][] };
const resp = await socket.request(
"graph-embeddings-query",
{ vectors: embResp.vectors, limit: limit ?? 10 },
{ flowId },
) as Record<string, unknown>;
const vectors = await flow.embeddings([query]);
const entities = await flow.graphEmbeddingsQuery(
vectors[0],
limit ?? 10,
collection,
);
return { content: [{ type: "text" as const, text: JSON.stringify(entities, null, 2) }] };
},
);
// ===================== Config tools =====================
server.tool(
"get_config_all",
"Get all configuration values",
{},
async () => {
const cfg = socket.config();
const resp = await cfg.getConfigAll();
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
);
// --- Config ---
server.tool(
"get_config",
"Get configuration values",
"Get specific configuration values",
{
keys: z.array(z.string()).describe("Config keys to retrieve"),
keys: z.array(
z.object({
type: z.string().describe("Config type"),
key: z.string().describe("Config key"),
}),
).describe("Config keys to retrieve"),
},
async ({ keys }) => {
const resp = await socket.request("config", { operation: "get", keys }) as Record<string, unknown>;
const cfg = socket.config();
const resp = await cfg.getConfig(keys);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
);
@ -146,10 +206,206 @@ export function createMcpServer(config: {
"put_config",
"Set configuration values",
{
values: z.record(z.unknown()).describe("Key-value pairs to set"),
values: z.array(
z.object({
type: z.string().describe("Config type"),
key: z.string().describe("Config key"),
value: z.string().describe("Config value (JSON-encoded)"),
}),
).describe("Key-value entries to set"),
},
async ({ values }) => {
const resp = await socket.request("config", { operation: "put", values }) as Record<string, unknown>;
const cfg = socket.config();
const resp = await cfg.putConfig(values);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
);
server.tool(
"delete_config",
"Delete a configuration entry",
{
type: z.string().describe("Config type"),
key: z.string().describe("Config key"),
},
async ({ type, key }) => {
const cfg = socket.config();
const resp = await cfg.deleteConfig({ type, key });
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
);
// ===================== Flow management tools =====================
server.tool(
"get_flows",
"List all available flows",
{},
async () => {
const flows = socket.flows();
const ids = await flows.getFlows();
return { content: [{ type: "text" as const, text: JSON.stringify(ids, null, 2) }] };
},
);
server.tool(
"get_flow",
"Get a specific flow definition",
{
flow_id: z.string().describe("Flow ID to retrieve"),
},
async ({ flow_id }) => {
const flows = socket.flows();
const def = await flows.getFlow(flow_id);
return { content: [{ type: "text" as const, text: JSON.stringify(def, null, 2) }] };
},
);
server.tool(
"start_flow",
"Start a flow instance",
{
flow_id: z.string().describe("Flow ID"),
blueprint_name: z.string().describe("Blueprint name"),
description: z.string().describe("Flow description"),
parameters: z.record(z.unknown()).optional().describe("Optional flow parameters"),
},
async ({ flow_id, blueprint_name, description, parameters }) => {
const flows = socket.flows();
const resp = await flows.startFlow(flow_id, blueprint_name, description, parameters);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
);
server.tool(
"stop_flow",
"Stop a running flow",
{
flow_id: z.string().describe("Flow ID to stop"),
},
async ({ flow_id }) => {
const flows = socket.flows();
const resp = await flows.stopFlow(flow_id);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
);
// ===================== Library (document) tools =====================
server.tool(
"get_documents",
"List all documents in the library",
{},
async () => {
const lib = socket.librarian();
const docs = await lib.getDocuments();
return { content: [{ type: "text" as const, text: JSON.stringify(docs, null, 2) }] };
},
);
server.tool(
"load_document",
"Upload a document to the library",
{
document: z.string().describe("Base64-encoded document content"),
mime_type: z.string().describe("Document MIME type"),
title: z.string().describe("Document title"),
comments: z.string().optional().describe("Additional comments"),
tags: z.array(z.string()).optional().describe("Document tags"),
id: z.string().optional().describe("Optional document ID"),
},
async ({ document, mime_type, title, comments, tags, id }) => {
const lib = socket.librarian();
const resp = await lib.loadDocument(
document,
mime_type,
title,
comments ?? "",
tags ?? [],
id,
);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
);
server.tool(
"remove_document",
"Remove a document from the library",
{
id: z.string().describe("Document ID to remove"),
collection: z.string().optional().describe("Collection name"),
},
async ({ id, collection }) => {
const lib = socket.librarian();
const resp = await lib.removeDocument(id, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
);
// ===================== Prompt tools =====================
server.tool(
"get_prompts",
"List available prompt templates",
{},
async () => {
const cfg = socket.config();
const prompts = await cfg.getPrompts();
return { content: [{ type: "text" as const, text: JSON.stringify(prompts, null, 2) }] };
},
);
server.tool(
"get_prompt",
"Get a specific prompt template",
{
id: z.string().describe("Prompt template ID"),
},
async ({ id }) => {
const cfg = socket.config();
const prompt = await cfg.getPrompt(id);
return { content: [{ type: "text" as const, text: JSON.stringify(prompt, null, 2) }] };
},
);
// ===================== Knowledge core tools =====================
server.tool(
"get_knowledge_cores",
"List available knowledge graph cores",
{},
async () => {
const knowledge = socket.knowledge();
const cores = await knowledge.getKnowledgeCores();
return { content: [{ type: "text" as const, text: JSON.stringify(cores, null, 2) }] };
},
);
server.tool(
"delete_kg_core",
"Delete a knowledge graph core",
{
id: z.string().describe("Knowledge core ID"),
collection: z.string().optional().describe("Collection name"),
},
async ({ id, collection }) => {
const knowledge = socket.knowledge();
const resp = await knowledge.deleteKgCore(id, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
);
server.tool(
"load_kg_core",
"Load a knowledge graph core",
{
id: z.string().describe("Knowledge core ID"),
flow: z.string().describe("Flow to use for loading"),
collection: z.string().optional().describe("Collection name"),
},
async ({ id, flow, collection }) => {
const knowledge = socket.knowledge();
const resp = await knowledge.loadKgCore(id, flow, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
);
@ -160,6 +416,7 @@ export function createMcpServer(config: {
export async function run(): Promise<void> {
const { server, socket } = createMcpServer({
gatewayUrl: process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/socket",
user: process.env.USER_ID ?? "mcp",
token: process.env.GATEWAY_SECRET,
flowId: process.env.FLOW_ID ?? "default",
});
@ -167,8 +424,8 @@ export async function run(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
process.on("SIGINT", async () => {
await socket.close();
process.on("SIGINT", () => {
socket.close();
process.exit(0);
});
}

View file

@ -1,147 +0,0 @@
/**
* WebSocket manager for communicating with the TrustGraph gateway.
*
* Maintains a persistent connection per user and handles request/response
* correlation via UUIDs.
*
* Python reference: trustgraph-mcp/trustgraph/mcp_server/tg_socket.py
*/
import WebSocket from "ws";
import { randomUUID } from "node:crypto";
export interface SocketManagerConfig {
gatewayUrl: string;
token?: string;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
responses: unknown[];
streaming: boolean;
onChunk?: (chunk: unknown) => void;
}
export class SocketManager {
private ws: WebSocket | null = null;
private pending = new Map<string, PendingRequest>();
private connected = false;
constructor(private readonly config: SocketManagerConfig) {}
async connect(): Promise<void> {
if (this.connected) return;
const url = new URL(this.config.gatewayUrl);
if (this.config.token) {
url.searchParams.set("token", this.config.token);
}
return new Promise((resolve, reject) => {
this.ws = new WebSocket(url.toString());
this.ws.on("open", () => {
this.connected = true;
resolve();
});
this.ws.on("error", (err) => {
if (!this.connected) reject(err);
else console.error("[SocketManager] WebSocket error:", err);
});
this.ws.on("message", (data) => {
try {
const msg = JSON.parse(data.toString());
const { id, response, error, complete } = msg;
const req = this.pending.get(id);
if (!req) return;
if (error) {
req.reject(new Error(`${error.type}: ${error.message}`));
this.pending.delete(id);
return;
}
if (req.streaming && req.onChunk) {
req.onChunk(response);
}
req.responses.push(response);
if (complete) {
req.resolve(req.streaming ? req.responses : response);
this.pending.delete(id);
}
} catch (err) {
console.error("[SocketManager] Failed to parse message:", err);
}
});
this.ws.on("close", () => {
this.connected = false;
// Reject all pending requests
for (const [id, req] of this.pending) {
req.reject(new Error("WebSocket closed"));
}
this.pending.clear();
});
});
}
async request(
service: string,
requestData: Record<string, unknown>,
options?: {
flowId?: string;
timeoutMs?: number;
onChunk?: (chunk: unknown) => void;
},
): Promise<unknown> {
await this.connect();
if (!this.ws) throw new Error("Not connected");
const id = randomUUID();
const timeoutMs = options?.timeoutMs ?? 300_000;
const msg = {
id,
service,
flow: options?.flowId ?? "default",
request: requestData,
};
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Request timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
responses: [],
streaming: !!options?.onChunk,
onChunk: options?.onChunk,
});
this.ws!.send(JSON.stringify(msg));
});
}
async close(): Promise<void> {
if (this.ws) {
this.ws.close();
this.ws = null;
this.connected = false;
}
}
}

View file

@ -2,10 +2,12 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"composite": true
},
"include": ["src"],
"references": [
{ "path": "../base" }
{ "path": "../base" },
{ "path": "../client" }
]
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TrustGraph Workbench</title>
</head>
<body class="dark">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,34 @@
{
"name": "@trustgraph/workbench",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.75.0",
"@trustgraph/client": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"lucide-react": "^0.513.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-force-graph-2d": "^1.29.1",
"react-markdown": "^10.1.0",
"react-router": "^7.6.0",
"tailwind-merge": "^3.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.8.0",
"vite": "^6.3.0"
}
}

View file

@ -0,0 +1,27 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router";
import { RootLayout } from "@/components/layout/root-layout";
import ChatPage from "@/pages/chat";
import LibraryPage from "@/pages/library";
import GraphPage from "@/pages/graph";
import FlowsPage from "@/pages/flows";
import SettingsPage from "@/pages/settings";
import { NotificationToasts } from "@/components/notification-toasts";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<RootLayout />}>
<Route index element={<Navigate to="/chat" replace />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/library" element={<LibraryPage />} />
<Route path="/graph" element={<GraphPage />} />
<Route path="/flows" element={<FlowsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
<NotificationToasts />
</BrowserRouter>
);
}

View file

@ -0,0 +1,25 @@
import { Workflow, Database } from "lucide-react";
import { useSessionStore } from "@/hooks/use-session-store";
import { useSettings } from "@/providers/settings-provider";
/**
* Compact badge showing the active flow and collection.
* Will be expanded later into a popover picker.
*/
export function FlowSelector() {
const flowId = useSessionStore((s) => s.flowId);
const collection = useSettings((s) => s.settings.collection);
return (
<div className="flex items-center gap-4 rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg-muted">
<span className="flex items-center gap-1.5">
<Database className="h-3.5 w-3.5" />
{collection}
</span>
<span className="flex items-center gap-1.5">
<Workflow className="h-3.5 w-3.5" />
{flowId || "<none>"}
</span>
</div>
);
}

View file

@ -0,0 +1,45 @@
import { Outlet } from "react-router";
import { Sidebar } from "./sidebar";
import { FlowSelector } from "./flow-selector";
import { useProgressStore } from "@/hooks/use-progress-store";
/**
* Top loading bar -- shown when any global activity is in progress.
*/
function LoadingBar() {
const isLoading = useProgressStore((s) => s.isLoading);
if (!isLoading) return null;
return (
<div className="absolute left-0 right-0 top-0 z-40 h-0.5 overflow-hidden bg-surface-200">
<div className="h-full w-1/3 animate-[slide_1.2s_ease-in-out_infinite] bg-brand-500" />
</div>
);
}
/**
* Root layout: fixed sidebar + scrollable main content area with a top bar.
*/
export function RootLayout() {
return (
<div className="relative flex h-screen w-full overflow-hidden bg-surface-0">
{/* Global loading bar */}
<LoadingBar />
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Top bar */}
<header className="flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50 px-6">
<FlowSelector />
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,168 @@
import { NavLink } from "react-router";
import {
MessageSquareText,
LibraryBig,
Rotate3d,
Workflow,
Settings,
TestTube2,
Wifi,
WifiOff,
Database,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useConnectionState } from "@/providers/socket-provider";
import { useSessionStore } from "@/hooks/use-session-store";
import { useFlows } from "@/hooks/use-flows";
import { useSettings } from "@/providers/settings-provider";
// ---------------------------------------------------------------------------
// Nav item
// ---------------------------------------------------------------------------
interface NavItemProps {
to: string;
icon: React.ElementType;
label: string;
}
function NavItem({ to, icon: Icon, label }: NavItemProps) {
return (
<NavLink to={to} className="w-full">
{({ isActive }) => (
<div
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-brand-600/20 text-brand-400"
: "text-fg-muted hover:bg-surface-200 hover:text-fg",
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{label}</span>
</div>
)}
</NavLink>
);
}
// ---------------------------------------------------------------------------
// Connection status badge
// ---------------------------------------------------------------------------
function ConnectionBadge() {
const state = useConnectionState();
const isConnected =
state.status === "connected" ||
state.status === "authenticated" ||
state.status === "unauthenticated";
return (
<div
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium",
isConnected ? "text-success" : "text-fg-subtle",
)}
>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
isConnected ? "bg-success animate-pulse" : "bg-fg-subtle",
)}
/>
{isConnected ? (
<Wifi className="h-3.5 w-3.5" />
) : (
<WifiOff className="h-3.5 w-3.5" />
)}
<span className="truncate capitalize">{state.status}</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Flow selector dropdown
// ---------------------------------------------------------------------------
function FlowSelectorDropdown() {
const { flows } = useFlows();
const flowId = useSessionStore((s) => s.flowId);
const setFlowId = useSessionStore((s) => s.setFlowId);
const collection = useSettings((s) => s.settings.collection);
return (
<div className="space-y-2 px-3">
{/* Flow selector */}
<div className="space-y-1">
<label className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
<Workflow className="h-3 w-3" />
Flow
</label>
<div className="relative">
<select
value={flowId}
onChange={(e) => setFlowId(e.target.value)}
className="w-full appearance-none rounded-md border border-border bg-surface-100 py-1.5 pl-2.5 pr-7 text-xs text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="default">default</option>
{flows.map((f) => (
<option key={f.id} value={f.id}>
{f.id}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-fg-subtle" />
</div>
</div>
{/* Collection badge */}
<div className="flex items-center gap-1.5 rounded-md bg-surface-100 px-2.5 py-1.5 text-xs text-fg-muted">
<Database className="h-3 w-3 shrink-0 text-fg-subtle" />
<span className="truncate">{collection}</span>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
export function Sidebar() {
return (
<aside className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50">
{/* Logo area */}
<div className="flex h-14 items-center gap-2 px-4">
<TestTube2 className="h-5 w-5 text-brand-500" />
<span className="text-lg font-bold text-fg">TrustGraph</span>
</div>
{/* Divider */}
<div className="mx-3 border-t border-border" />
{/* Flow & collection selectors */}
<div className="py-3">
<FlowSelectorDropdown />
</div>
{/* Divider */}
<div className="mx-3 border-t border-border" />
{/* Navigation links */}
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-3">
<NavItem to="/chat" icon={MessageSquareText} label="Chat" />
<NavItem to="/library" icon={LibraryBig} label="Library" />
<NavItem to="/graph" icon={Rotate3d} label="Graph" />
<NavItem to="/flows" icon={Workflow} label="Flows" />
<NavItem to="/settings" icon={Settings} label="Settings" />
</nav>
{/* Footer: connection badge */}
<div className="border-t border-border px-2 py-2">
<ConnectionBadge />
</div>
</aside>
);
}

View file

@ -0,0 +1,47 @@
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useNotification, type NotificationType } from "@/providers/notification-provider";
const typeStyles: Record<NotificationType, string> = {
success: "border-success/40 bg-success/10 text-success",
error: "border-error/40 bg-error/10 text-error",
warning: "border-warning/40 bg-warning/10 text-warning",
info: "border-brand-500/40 bg-brand-500/10 text-brand-300",
};
/**
* Renders the active notification stack in the bottom-right corner.
*/
export function NotificationToasts() {
const notifications = useNotification((s) => s.notifications);
const removeNotification = useNotification((s) => s.removeNotification);
if (notifications.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{notifications.map((n) => (
<div
key={n.id}
className={cn(
"flex items-start gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg",
typeStyles[n.type],
)}
>
<div className="flex-1">
<p className="font-medium">{n.title}</p>
{n.description && (
<p className="mt-0.5 text-xs opacity-80">{n.description}</p>
)}
</div>
<button
onClick={() => removeNotification(n.id)}
className="shrink-0 opacity-60 hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,31 @@
import { cn } from "@/lib/utils";
type BadgeVariant = "default" | "success" | "warning" | "error" | "info";
const variantStyles: Record<BadgeVariant, string> = {
default: "border-border bg-surface-200 text-fg-muted",
success: "border-success/30 bg-success/10 text-success",
warning: "border-warning/30 bg-warning/10 text-warning",
error: "border-error/30 bg-error/10 text-error",
info: "border-brand-500/30 bg-brand-500/10 text-brand-300",
};
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
}
export function Badge({ children, variant = "default", className }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
variantStyles[variant],
className,
)}
>
{children}
</span>
);
}

View file

@ -0,0 +1,85 @@
import {
type ReactNode,
type MouseEvent,
useCallback,
useEffect,
} from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface DialogProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
/** Max width class, defaults to max-w-lg */
className?: string;
}
/**
* Simple modal dialog built with Tailwind.
* Renders a backdrop overlay + centered content panel.
*/
export function Dialog({
open,
onClose,
title,
children,
footer,
className,
}: DialogProps) {
// Close on Escape key
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
const handleBackdrop = useCallback(
(e: MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={handleBackdrop}
>
<div
className={cn(
"relative w-full max-w-lg rounded-xl border border-border bg-surface-100 shadow-2xl",
className,
)}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-fg">{title}</h2>
<button
onClick={onClose}
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">{children}</div>
{/* Footer */}
{footer && (
<div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">
{footer}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,42 @@
import { cn } from "@/lib/utils";
interface TabItem {
value: string;
label: string;
}
interface TabsProps {
items: TabItem[];
value: string;
onChange: (value: string) => void;
className?: string;
}
/**
* Minimal segmented-control / tab bar.
*/
export function Tabs({ items, value, onChange, className }: TabsProps) {
return (
<div
className={cn(
"flex rounded-lg border border-border bg-surface-100 p-0.5",
className,
)}
>
{items.map((item) => (
<button
key={item.value}
onClick={() => onChange(item.value)}
className={cn(
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
value === item.value
? "bg-brand-600 text-white"
: "text-fg-muted hover:text-fg",
)}
>
{item.label}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,48 @@
import { useRef, useEffect, type TextareaHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
interface AutoTextareaProps
extends TextareaHTMLAttributes<HTMLTextAreaElement> {
/** Maximum number of rows before scrolling */
maxRows?: number;
}
/**
* Textarea that auto-resizes to fit its content, up to maxRows.
*/
export function AutoTextarea({
maxRows = 6,
className,
value,
...props
}: AutoTextareaProps) {
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Reset height so scrollHeight is recalculated
el.style.height = "auto";
// Compute line height from computed styles
const style = window.getComputedStyle(el);
const lineHeight = parseFloat(style.lineHeight) || 20;
const maxHeight = lineHeight * maxRows;
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
}, [value, maxRows]);
return (
<textarea
ref={ref}
value={value}
className={cn(
"w-full resize-none rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500",
className,
)}
rows={1}
{...props}
/>
);
}

View file

@ -0,0 +1,215 @@
import { useCallback } from "react";
import { useSocket } from "@/providers/socket-provider";
import {
useConversation,
nextMessageId,
type ChatMessage,
} from "./use-conversation";
import { useSessionStore } from "./use-session-store";
import { useProgressStore } from "./use-progress-store";
import { useSettings } from "@/providers/settings-provider";
import type { StreamingMetadata } from "@trustgraph/client";
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export interface UseChatReturn {
submitMessage: (opts: { input: string }) => void;
}
/**
* Orchestrates sending a chat message through the selected RAG / agent
* pipeline and accumulates streamed chunks into the conversation store.
*/
export function useChat(): UseChatReturn {
const socket = useSocket();
const flowId = useSessionStore((s) => s.flowId);
const chatMode = useConversation((s) => s.chatMode);
const addMessage = useConversation((s) => s.addMessage);
const updateLastMessage = useConversation((s) => s.updateLastMessage);
const setInput = useConversation((s) => s.setInput);
const collection = useSettings((s) => s.settings.collection);
const addActivity = useProgressStore((s) => s.addActivity);
const removeActivity = useProgressStore((s) => s.removeActivity);
const submitMessage = useCallback(
({ input }: { input: string }) => {
if (!input.trim()) return;
const activityLabel = "Chat request";
// 1. Add the user message
const userMsg: ChatMessage = {
id: nextMessageId(),
role: "user",
content: input,
timestamp: Date.now(),
};
addMessage(userMsg);
setInput("");
// 2. Add a placeholder assistant message for streaming
const assistantId = nextMessageId();
const isAgent = chatMode === "agent";
const assistantMsg: ChatMessage = {
id: assistantId,
role: "assistant",
content: "",
timestamp: Date.now(),
isStreaming: true,
...(isAgent
? {
agentPhases: { think: "", observe: "", answer: "" },
activePhase: "think" as const,
}
: {}),
};
addMessage(assistantMsg);
addActivity(activityLabel);
const flow = socket.flow(flowId);
// Shared handler for streaming responses (graph-rag / document-rag)
const onChunk = (
chunk: string,
complete: boolean,
metadata?: StreamingMetadata,
) => {
updateLastMessage((prev) => ({
...prev,
content: prev.content + chunk,
isStreaming: !complete,
...(complete && metadata
? {
metadata: {
model: metadata.model,
inTokens: metadata.in_token,
outTokens: metadata.out_token,
},
}
: {}),
}));
if (complete) {
removeActivity(activityLabel);
}
};
const onError = (error: string) => {
updateLastMessage((prev) => ({
...prev,
content: prev.content || `Error: ${error}`,
isStreaming: false,
}));
removeActivity(activityLabel);
};
// 3. Dispatch based on chat mode
switch (chatMode) {
case "graph-rag":
flow.graphRagStreaming(input, onChunk, onError, undefined, collection);
break;
case "document-rag":
flow.documentRagStreaming(input, onChunk, onError, undefined, collection);
break;
case "agent": {
// Agent has separate think / observe / answer streams.
// We track each phase in agentPhases and display the answer
// as the main content.
flow.agent(
input,
// think
(chunk, complete) => {
updateLastMessage((prev) => {
const phases = prev.agentPhases ?? {
think: "",
observe: "",
answer: "",
};
return {
...prev,
agentPhases: {
...phases,
think: phases.think + chunk,
},
activePhase: complete ? prev.activePhase : "think",
};
});
},
// observe
(chunk, complete) => {
updateLastMessage((prev) => {
const phases = prev.agentPhases ?? {
think: "",
observe: "",
answer: "",
};
return {
...prev,
agentPhases: {
...phases,
observe: phases.observe + chunk,
},
activePhase: complete ? prev.activePhase : "observe",
};
});
},
// answer
(chunk, complete, metadata) => {
updateLastMessage((prev) => {
const phases = prev.agentPhases ?? {
think: "",
observe: "",
answer: "",
};
const newAnswer = phases.answer + chunk;
return {
...prev,
content: newAnswer,
agentPhases: {
...phases,
answer: newAnswer,
},
activePhase: complete ? undefined : "answer",
isStreaming: !complete,
...(complete && metadata
? {
metadata: {
model: metadata.model,
inTokens: metadata.in_token,
outTokens: metadata.out_token,
},
}
: {}),
};
});
if (complete) {
removeActivity(activityLabel);
}
},
// error
onError,
);
break;
}
}
},
[
socket,
flowId,
chatMode,
collection,
addMessage,
updateLastMessage,
setInput,
addActivity,
removeActivity,
],
);
return { submitMessage };
}

View file

@ -0,0 +1,91 @@
import { create } from "zustand";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ChatMode = "graph-rag" | "document-rag" | "agent";
export type MessageRole = "user" | "assistant" | "system";
/** Phase labels for agent-mode messages */
export type AgentPhase = "think" | "observe" | "answer";
export interface ChatMessage {
id: string;
role: MessageRole;
content: string;
/** Timestamp (epoch ms) */
timestamp: number;
/** If true the message is still being streamed */
isStreaming?: boolean;
/** Optional metadata attached on completion */
metadata?: {
model?: string;
inTokens?: number;
outTokens?: number;
};
/** Agent-mode phases with their accumulated content */
agentPhases?: {
think: string;
observe: string;
answer: string;
};
/** Indicates the current active phase during streaming */
activePhase?: AgentPhase;
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
interface ConversationState {
messages: ChatMessage[];
input: string;
chatMode: ChatMode;
setInput: (value: string) => void;
setChatMode: (mode: ChatMode) => void;
addMessage: (message: ChatMessage) => void;
/**
* Update the last message in the list (used during streaming to append
* chunks). The `updater` receives the current last message and must
* return the replacement.
*/
updateLastMessage: (
updater: (prev: ChatMessage) => ChatMessage,
) => void;
clearMessages: () => void;
}
let _nextMsgId = 0;
export function nextMessageId(): string {
return `msg-${++_nextMsgId}-${Date.now()}`;
}
export const useConversation = create<ConversationState>()((set) => ({
messages: [],
input: "",
chatMode: "graph-rag",
setInput: (value) => set({ input: value }),
setChatMode: (mode) => set({ chatMode: mode }),
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),
updateLastMessage: (updater) =>
set((state) => {
if (state.messages.length === 0) return state;
const last = state.messages[state.messages.length - 1]!;
const updated = updater(last);
return {
messages: [...state.messages.slice(0, -1), updated],
};
}),
clearMessages: () => set({ messages: [] }),
}));

View file

@ -0,0 +1,130 @@
import { useCallback, useEffect, useState } from "react";
import { useSocket } from "@/providers/socket-provider";
import { useConnectionState } from "@/providers/socket-provider";
import { useProgressStore } from "./use-progress-store";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FlowSummary {
id: string;
description?: string;
[key: string]: unknown;
}
export interface UseFlowsReturn {
flows: FlowSummary[];
loading: boolean;
error: string | null;
/** Refresh the flow list from the server */
getFlows: () => Promise<void>;
/** Start a new flow */
startFlow: (
id: string,
blueprintName: string,
description: string,
parameters?: Record<string, unknown>,
) => Promise<void>;
/** Stop a running flow */
stopFlow: (id: string) => Promise<void>;
/** Fetch a single flow definition */
getFlow: (id: string) => Promise<FlowSummary>;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useFlows(): UseFlowsReturn {
const socket = useSocket();
const connectionState = useConnectionState();
const addActivity = useProgressStore((s) => s.addActivity);
const removeActivity = useProgressStore((s) => s.removeActivity);
const [flows, setFlows] = useState<FlowSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getFlows = useCallback(async () => {
const act = "Load flows";
try {
setLoading(true);
setError(null);
addActivity(act);
const ids: string[] = await socket.flows().getFlows();
const results = await Promise.all(
ids.map(async (id) => {
const def = await socket.flows().getFlow(id);
return { id, ...def } as FlowSummary;
}),
);
setFlows(results);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
console.error("useFlows.getFlows error:", err);
} finally {
setLoading(false);
removeActivity(act);
}
}, [socket, addActivity, removeActivity]);
const startFlow = useCallback(
async (
id: string,
blueprintName: string,
description: string,
parameters?: Record<string, unknown>,
) => {
const act = `Start flow ${id}`;
try {
addActivity(act);
await socket.flows().startFlow(id, blueprintName, description, parameters);
// Refresh list after starting
await getFlows();
} finally {
removeActivity(act);
}
},
[socket, addActivity, removeActivity, getFlows],
);
const stopFlow = useCallback(
async (id: string) => {
const act = `Stop flow ${id}`;
try {
addActivity(act);
await socket.flows().stopFlow(id);
await getFlows();
} finally {
removeActivity(act);
}
},
[socket, addActivity, removeActivity, getFlows],
);
const getFlow = useCallback(
async (id: string): Promise<FlowSummary> => {
const def = await socket.flows().getFlow(id);
return { id, ...def } as FlowSummary;
},
[socket],
);
// Auto-load flows when the connection becomes ready
useEffect(() => {
if (
connectionState.status === "connected" ||
connectionState.status === "authenticated" ||
connectionState.status === "unauthenticated"
) {
getFlows();
}
}, [connectionState.status, getFlows]);
return { flows, loading, error, getFlows, startFlow, stopFlow, getFlow };
}

View file

@ -0,0 +1,134 @@
import { useCallback, useState } from "react";
import { useSocket } from "@/providers/socket-provider";
import { useProgressStore } from "./use-progress-store";
import type { DocumentMetadata } from "@trustgraph/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ProcessingMetadata {
id: string;
"document-id": string;
flow: string;
collection: string;
[key: string]: unknown;
}
export interface UseLibraryReturn {
documents: DocumentMetadata[];
processing: ProcessingMetadata[];
loading: boolean;
error: string | null;
/** Refresh the documents list */
getDocuments: () => Promise<void>;
/** Upload a new document */
uploadDocument: (
document: string,
mimeType: string,
title: string,
comments: string,
tags: string[],
id?: string,
) => Promise<void>;
/** Remove a document */
removeDocument: (id: string, collection?: string) => Promise<void>;
/** Get the list of currently-processing documents */
getProcessing: () => Promise<void>;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useLibrary(): UseLibraryReturn {
const socket = useSocket();
const addActivity = useProgressStore((s) => s.addActivity);
const removeActivity = useProgressStore((s) => s.removeActivity);
const [documents, setDocuments] = useState<DocumentMetadata[]>([]);
const [processing, setProcessing] = useState<ProcessingMetadata[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getDocuments = useCallback(async () => {
const act = "Load documents";
try {
setLoading(true);
setError(null);
addActivity(act);
const docs = await socket.librarian().getDocuments();
setDocuments(docs);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
console.error("useLibrary.getDocuments error:", err);
} finally {
setLoading(false);
removeActivity(act);
}
}, [socket, addActivity, removeActivity]);
const uploadDocument = useCallback(
async (
document: string,
mimeType: string,
title: string,
comments: string,
tags: string[],
id?: string,
) => {
const act = "Upload document";
try {
addActivity(act);
await socket
.librarian()
.loadDocument(document, mimeType, title, comments, tags, id);
// Refresh list after upload
await getDocuments();
} finally {
removeActivity(act);
}
},
[socket, addActivity, removeActivity, getDocuments],
);
const removeDocument = useCallback(
async (id: string, collection?: string) => {
const act = "Remove document";
try {
addActivity(act);
await socket.librarian().removeDocument(id, collection);
await getDocuments();
} finally {
removeActivity(act);
}
},
[socket, addActivity, removeActivity, getDocuments],
);
const getProcessing = useCallback(async () => {
const act = "Load processing";
try {
addActivity(act);
const procs = await socket.librarian().getProcessing();
setProcessing(procs as ProcessingMetadata[]);
} catch (err) {
console.error("useLibrary.getProcessing error:", err);
} finally {
removeActivity(act);
}
}, [socket, addActivity, removeActivity]);
return {
documents,
processing,
loading,
error,
getDocuments,
uploadDocument,
removeDocument,
getProcessing,
};
}

View file

@ -0,0 +1,39 @@
import { create } from "zustand";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ProgressState {
/** Set of currently-running activity labels */
activities: Set<string>;
/** Derived: true when at least one activity is running */
isLoading: boolean;
addActivity: (label: string) => void;
removeActivity: (label: string) => void;
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export const useProgressStore = create<ProgressState>()((set) => ({
activities: new Set<string>(),
isLoading: false,
addActivity: (label) =>
set((state) => {
const next = new Set(state.activities);
next.add(label);
return { activities: next, isLoading: next.size > 0 };
}),
removeActivity: (label) =>
set((state) => {
const next = new Set(state.activities);
next.delete(label);
return { activities: next, isLoading: next.size > 0 };
}),
}));

View file

@ -0,0 +1,34 @@
import { create } from "zustand";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Minimal flow description kept in session state after selection. */
export interface FlowInfo {
id: string;
description?: string;
[key: string]: unknown;
}
interface SessionState {
/** Currently-selected flow id */
flowId: string;
/** Cached flow definition for the selected flow */
flow: FlowInfo | null;
setFlowId: (id: string) => void;
setFlow: (flow: FlowInfo | null) => void;
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export const useSessionStore = create<SessionState>()((set) => ({
flowId: "default",
flow: null,
setFlowId: (id) => set({ flowId: id }),
setFlow: (flow) => set({ flow }),
}));

View file

@ -0,0 +1,126 @@
@import "tailwindcss";
/*
* TrustGraph Workbench -- Dark-mode-first design tokens
*
* Tailwind CSS v4 uses CSS-first configuration.
* Custom theme values are declared as CSS custom properties.
*/
@theme {
/* Brand palette */
--color-brand-50: #eef2ff;
--color-brand-100: #dce4ff;
--color-brand-200: #b9c9ff;
--color-brand-300: #8aa5ff;
--color-brand-400: #5b80ff;
--color-brand-500: #3b63ed;
--color-brand-600: #2d4ec4;
--color-brand-700: #213a9b;
--color-brand-800: #162872;
--color-brand-900: #0e1a4d;
/* Surface / background colors (dark-first) */
--color-surface-0: #09090b;
--color-surface-50: #111113;
--color-surface-100: #18181b;
--color-surface-200: #27272a;
--color-surface-300: #3f3f46;
--color-surface-400: #52525b;
/* Foreground / text colors */
--color-fg: #fafafa;
--color-fg-muted: #a1a1aa;
--color-fg-subtle: #71717a;
/* Border colors */
--color-border: #27272a;
--color-border-hover: #3f3f46;
/* Semantic: success / warning / error */
--color-success: #22c55e;
--color-warning: #eab308;
--color-error: #ef4444;
/* Sidebar width */
--spacing-sidebar: 16rem;
--spacing-sidebar-collapsed: 4rem;
/* Font families */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}
/* Base layer: dark background, light text by default */
@layer base {
*,
*::before,
*::after {
border-color: var(--color-border);
}
body {
background-color: var(--color-surface-0);
color: var(--color-fg);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling for dark mode */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-surface-100);
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-300);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-surface-400);
}
}
/* Loading bar slide animation */
@keyframes slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
/* Prose overrides for dark mode markdown rendering */
@layer base {
.prose-invert code {
color: var(--color-brand-300);
}
.prose-invert pre {
background: var(--color-surface-200);
}
}
/* Light mode overrides (activated by .light class on <html>) */
html.light {
--color-surface-0: #ffffff;
--color-surface-50: #fafafa;
--color-surface-100: #f4f4f5;
--color-surface-200: #e4e4e7;
--color-surface-300: #d4d4d8;
--color-surface-400: #a1a1aa;
--color-fg: #18181b;
--color-fg-muted: #52525b;
--color-fg-subtle: #71717a;
--color-border: #e4e4e7;
--color-border-hover: #d4d4d8;
}

View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -0,0 +1,36 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "@/App";
import { SocketProvider } from "@/providers/socket-provider";
import { useSettings } from "@/providers/settings-provider";
import "@/index.css";
const queryClient = new QueryClient();
/**
* AppRoot reads settings from the Zustand store and passes them
* into the SocketProvider so the WebSocket connection is configured
* before any child component mounts.
*/
function AppRoot() {
const settings = useSettings((s) => s.settings);
return (
<SocketProvider
user={settings.user}
apiKey={settings.apiKey || undefined}
socketUrl={settings.gatewayUrl || undefined}
>
<App />
</SocketProvider>
);
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<AppRoot />
</QueryClientProvider>
</React.StrictMode>,
);

View file

@ -0,0 +1,305 @@
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent,
} from "react";
import {
MessageSquareText,
Send,
Trash2,
Brain,
Eye,
CheckCircle,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import Markdown from "react-markdown";
import { cn } from "@/lib/utils";
import { useConversation, type ChatMessage } from "@/hooks/use-conversation";
import { useChat } from "@/hooks/use-chat";
import { useSettings } from "@/providers/settings-provider";
import { useProgressStore } from "@/hooks/use-progress-store";
import { AutoTextarea } from "@/components/ui/textarea";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MODES = [
{ value: "graph-rag" as const, label: "Graph RAG" },
{ value: "document-rag" as const, label: "Doc RAG" },
{ value: "agent" as const, label: "Agent" },
];
// ---------------------------------------------------------------------------
// Agent phase section (collapsible)
// ---------------------------------------------------------------------------
function AgentPhaseBlock({
phase,
icon,
label,
content,
isActive,
}: {
phase: string;
icon: React.ReactNode;
label: string;
content: string;
isActive: boolean;
}) {
const [expanded, setExpanded] = useState(false);
if (!content && !isActive) return null;
const phaseColors: Record<string, string> = {
think: "border-amber-500/30 bg-amber-500/5",
observe: "border-sky-500/30 bg-sky-500/5",
answer: "border-emerald-500/30 bg-emerald-500/5",
};
const badgeColors: Record<string, string> = {
think: "bg-amber-500/20 text-amber-400",
observe: "bg-sky-500/20 text-sky-400",
answer: "bg-emerald-500/20 text-emerald-400",
};
return (
<div
className={cn(
"rounded-md border",
phaseColors[phase] ?? "border-border bg-surface-100",
)}
>
<button
onClick={() => setExpanded((p) => !p)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
>
{expanded ? (
<ChevronDown className="h-3 w-3 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 shrink-0" />
)}
{icon}
<span
className={cn(
"rounded px-1.5 py-0.5",
badgeColors[phase] ?? "bg-surface-200 text-fg-muted",
)}
>
{label}
</span>
{isActive && (
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
)}
</button>
{expanded && content && (
<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>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Single message bubble
// ---------------------------------------------------------------------------
function MessageBubble({ msg }: { msg: ChatMessage }) {
const isUser = msg.role === "user";
const hasAgentPhases = msg.agentPhases != null;
return (
<div
className={cn(
"rounded-lg px-4 py-3 text-sm leading-relaxed",
isUser
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
)}
>
{/* Agent phase blocks (only for agent messages) */}
{hasAgentPhases && msg.agentPhases && (
<div className="mb-2 space-y-1.5">
<AgentPhaseBlock
phase="think"
icon={<Brain className="h-3 w-3" />}
label="Thinking"
content={msg.agentPhases.think}
isActive={msg.activePhase === "think"}
/>
<AgentPhaseBlock
phase="observe"
icon={<Eye className="h-3 w-3" />}
label="Observing"
content={msg.agentPhases.observe}
isActive={msg.activePhase === "observe"}
/>
{msg.agentPhases.answer && (
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
<CheckCircle className="h-3 w-3" />
<span className="font-medium">Answer</span>
</div>
)}
</div>
)}
{/* Main content (markdown for assistant, plain for user) */}
{isUser ? (
<p className="whitespace-pre-wrap">{msg.content}</p>
) : (
<div className="prose prose-invert prose-sm max-w-none prose-p:my-1 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
<Markdown>{msg.content || (msg.isStreaming ? "" : "(empty)")}</Markdown>
</div>
)}
{/* Streaming indicator */}
{msg.isStreaming && (
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
)}
{/* Token metadata */}
{msg.metadata && (
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
{msg.metadata.model && <span>{msg.metadata.model}</span>}
{msg.metadata.inTokens != null && (
<span>in: {msg.metadata.inTokens}</span>
)}
{msg.metadata.outTokens != null && (
<span>out: {msg.metadata.outTokens}</span>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Chat page
// ---------------------------------------------------------------------------
export default function ChatPage() {
const messages = useConversation((s) => s.messages);
const input = useConversation((s) => s.input);
const chatMode = useConversation((s) => s.chatMode);
const setInput = useConversation((s) => s.setInput);
const setChatMode = useConversation((s) => s.setChatMode);
const clearMessages = useConversation((s) => s.clearMessages);
const { submitMessage } = useChat();
const collection = useSettings((s) => s.settings.collection);
const isLoading = useProgressStore((s) => s.isLoading);
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = useCallback(() => {
if (input.trim()) {
submitMessage({ input });
}
}, [input, submitMessage]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<MessageSquareText className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Chat</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
{collection}
</span>
</div>
<div className="flex items-center gap-2">
{/* Mode selector */}
<div className="flex rounded-lg border border-border bg-surface-100 p-0.5">
{MODES.map((mode) => (
<button
key={mode.value}
onClick={() => setChatMode(mode.value)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
chatMode === mode.value
? "bg-brand-600 text-white"
: "text-fg-muted hover:text-fg",
)}
>
{mode.label}
</button>
))}
</div>
<button
onClick={clearMessages}
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
title="Clear messages"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 space-y-4 overflow-y-auto pb-4">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-fg-subtle">
<MessageSquareText className="mb-3 h-10 w-10 opacity-30" />
<p>Send a message to start a conversation.</p>
<p className="mt-1 text-xs">
Mode: <span className="text-fg-muted">{chatMode}</span>
</p>
</div>
)}
{messages.map((msg) => (
<MessageBubble key={msg.id} msg={msg} />
))}
<div ref={scrollRef} />
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex items-center gap-2 pb-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Processing...</span>
</div>
)}
{/* Input area */}
<div className="flex items-end gap-2 border-t border-border pt-4">
<AutoTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
maxRows={6}
/>
<button
onClick={handleSubmit}
disabled={!input.trim() || isLoading}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-600 text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,490 @@
import { useCallback, useEffect, useState } from "react";
import {
Workflow,
Plus,
Square,
RefreshCw,
ChevronDown,
ChevronRight,
Loader2,
AlertTriangle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFlows, type FlowSummary } from "@/hooks/use-flows";
import { useSocket } from "@/providers/socket-provider";
import { useNotification } from "@/providers/notification-provider";
import { Dialog } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
// ---------------------------------------------------------------------------
// Start flow dialog
// ---------------------------------------------------------------------------
function StartFlowDialog({
open,
onClose,
onStart,
}: {
open: boolean;
onClose: () => void;
onStart: (
id: string,
blueprint: string,
description: string,
params: Record<string, unknown>,
) => Promise<void>;
}) {
const socket = useSocket();
const [blueprints, setBlueprints] = useState<string[]>([]);
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
const [id, setId] = useState("");
const [blueprint, setBlueprint] = useState("");
const [description, setDescription] = useState("");
const [paramsJson, setParamsJson] = useState("{}");
const [submitting, setSubmitting] = useState(false);
const [paramsError, setParamsError] = useState<string | null>(null);
// Fetch blueprints when dialog opens
useEffect(() => {
if (!open) return;
setLoadingBlueprints(true);
socket
.flows()
.getFlowBlueprints()
.then((names) => {
const list = names ?? [];
setBlueprints(list);
if (list.length > 0 && !blueprint) {
setBlueprint(list[0]!);
}
})
.catch(() => setBlueprints([]))
.finally(() => setLoadingBlueprints(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, socket]);
const reset = () => {
setId("");
setBlueprint("");
setDescription("");
setParamsJson("{}");
setParamsError(null);
setSubmitting(false);
};
const handleSubmit = async () => {
let params: Record<string, unknown> = {};
try {
params = JSON.parse(paramsJson);
setParamsError(null);
} catch {
setParamsError("Invalid JSON");
return;
}
setSubmitting(true);
try {
await onStart(id, blueprint, description, params);
reset();
onClose();
} catch {
setSubmitting(false);
}
};
const isValid = id.trim().length > 0 && blueprint.length > 0 && description.trim().length > 0;
return (
<Dialog
open={open}
onClose={() => {
if (!submitting) {
reset();
onClose();
}
}}
title="Start Flow"
footer={
<>
<button
onClick={() => {
reset();
onClose();
}}
disabled={submitting}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!isValid || submitting}
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
<Plus className="h-3.5 w-3.5" />
Start
</button>
</>
}
>
{/* Flow ID */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
Flow ID <span className="text-error">*</span>
</label>
<input
type="text"
value={id}
onChange={(e) => setId(e.target.value)}
placeholder="my-flow-id"
className="w-full rounded-lg border border-border bg-surface-100 px-3 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>
{/* Blueprint name */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
Blueprint <span className="text-error">*</span>
</label>
{loadingBlueprints ? (
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
</div>
) : (
<select
value={blueprint}
onChange={(e) => setBlueprint(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="" disabled>
Select a blueprint
</option>
{blueprints.map((bp) => (
<option key={bp} value={bp}>
{bp}
</option>
))}
</select>
)}
</div>
{/* Description */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
Description <span className="text-error">*</span>
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Human-readable description"
className="w-full rounded-lg border border-border bg-surface-100 px-3 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>
{/* Parameters (JSON) */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
Parameters (JSON)
</label>
<textarea
value={paramsJson}
onChange={(e) => {
setParamsJson(e.target.value);
setParamsError(null);
}}
rows={4}
className={cn(
"w-full resize-none rounded-lg border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus:ring-1",
paramsError
? "border-error focus:border-error focus:ring-error"
: "border-border focus:border-brand-500 focus:ring-brand-500",
)}
/>
{paramsError && (
<p className="text-xs text-error">{paramsError}</p>
)}
</div>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Stop flow confirm dialog
// ---------------------------------------------------------------------------
function StopFlowDialog({
open,
flowId,
onClose,
onConfirm,
}: {
open: boolean;
flowId: string;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog
open={open}
onClose={onClose}
title="Stop Flow"
footer={
<>
<button
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
>
Cancel
</button>
<button
onClick={onConfirm}
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
>
Stop
</button>
</>
}
>
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
<p className="text-sm text-fg-muted">
Are you sure you want to stop flow{" "}
<span className="font-mono font-medium text-fg">{flowId}</span>?
</p>
</div>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Flow detail row (expandable)
// ---------------------------------------------------------------------------
function FlowRow({
flow,
onStop,
}: {
flow: FlowSummary;
onStop: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
// Determine all the extra keys beyond id/description
const detailKeys = Object.keys(flow).filter(
(k) => k !== "id" && k !== "description",
);
return (
<>
<tr
className="cursor-pointer hover:bg-surface-100/50"
onClick={() => setExpanded((p) => !p)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
)}
<span className="font-mono text-sm text-fg">{flow.id}</span>
</div>
</td>
<td className="px-4 py-3 text-fg-muted">
{flow.description || "--"}
</td>
<td className="px-4 py-3">
<Badge variant="success">Running</Badge>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={(e) => {
e.stopPropagation();
onStop(flow.id);
}}
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
title="Stop flow"
>
<Square className="h-3.5 w-3.5" />
</button>
</td>
</tr>
{/* Detail row */}
{expanded && detailKeys.length > 0 && (
<tr>
<td colSpan={4} className="bg-surface-50 px-8 py-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
{detailKeys.map((key) => (
<div key={key}>
<span className="font-medium text-fg-muted">{key}: </span>
<span className="text-fg-subtle">
{typeof flow[key] === "object"
? JSON.stringify(flow[key])
: String(flow[key] ?? "")}
</span>
</div>
))}
</div>
</td>
</tr>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Flows page
// ---------------------------------------------------------------------------
export default function FlowsPage() {
const { flows, loading, error, getFlows, startFlow, stopFlow } = useFlows();
const notify = useNotification();
const [createOpen, setCreateOpen] = useState(false);
const [stopTarget, setStopTarget] = useState<string | null>(null);
// Auto-refresh every 10 seconds
useEffect(() => {
const interval = setInterval(() => {
getFlows();
}, 10_000);
return () => clearInterval(interval);
}, [getFlows]);
// Also refresh on window focus
useEffect(() => {
const handler = () => getFlows();
window.addEventListener("focus", handler);
return () => window.removeEventListener("focus", handler);
}, [getFlows]);
const handleStart = async (
id: string,
blueprint: string,
description: string,
params: Record<string, unknown>,
) => {
try {
await startFlow(id, blueprint, description, params);
notify.success("Flow started", `Flow "${id}" has been started.`);
} catch (err) {
notify.error(
"Failed to start flow",
err instanceof Error ? err.message : String(err),
);
throw err; // re-throw so dialog stays open
}
};
const handleStop = async () => {
if (!stopTarget) return;
try {
await stopFlow(stopTarget);
notify.success("Flow stopped", `Flow "${stopTarget}" has been stopped.`);
} catch (err) {
notify.error(
"Failed to stop flow",
err instanceof Error ? err.message : String(err),
);
}
setStopTarget(null);
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Workflow className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Flows</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
{flows.length} active
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => getFlows()}
disabled={loading}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
Refresh
</button>
<button
onClick={() => setCreateOpen(true)}
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
>
<Plus className="h-4 w-4" />
Start Flow
</button>
</div>
</div>
{/* Content */}
{loading && flows.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
<span className="text-fg-subtle">Loading flows...</span>
</div>
)}
{error && (
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
{error}
</p>
)}
{!loading && !error && flows.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center">
<Workflow className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
<p className="text-fg-subtle">No flows configured.</p>
<p className="mt-1 text-xs text-fg-subtle">
Click "Start Flow" to create one.
</p>
</div>
)}
{flows.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-sm">
<thead className="border-b border-border bg-surface-100 text-fg-muted">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Description</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{flows.map((flow) => (
<FlowRow
key={flow.id}
flow={flow}
onStop={(id) => setStopTarget(id)}
/>
))}
</tbody>
</table>
</div>
)}
{/* Dialogs */}
<StartFlowDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onStart={handleStart}
/>
<StopFlowDialog
open={stopTarget != null}
flowId={stopTarget ?? ""}
onClose={() => setStopTarget(null)}
onConfirm={handleStop}
/>
</div>
);
}

View file

@ -0,0 +1,586 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Rotate3d,
Search,
ZoomIn,
ZoomOut,
Maximize,
Loader2,
X,
ArrowRight,
ArrowLeft,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useSocket } from "@/providers/socket-provider";
import { useSessionStore } from "@/hooks/use-session-store";
import { useSettings } from "@/providers/settings-provider";
import { useProgressStore } from "@/hooks/use-progress-store";
import { Badge } from "@/components/ui/badge";
import type { Triple, Term } from "@trustgraph/client";
// ---------------------------------------------------------------------------
// Lazy-load ForceGraph2D to keep bundle size down
// ---------------------------------------------------------------------------
// react-force-graph-2d ships a default export
import ForceGraph2D, {
type ForceGraphMethods,
type NodeObject,
type LinkObject,
} from "react-force-graph-2d";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface GraphNode extends NodeObject {
id: string;
label: string;
color?: string;
/** Number of connections (used for sizing) */
degree: number;
}
interface GraphLink extends LinkObject {
source: string;
target: string;
label: string;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
// ---------------------------------------------------------------------------
// Helpers -- Term value extraction
// ---------------------------------------------------------------------------
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
function termValue(t: Term): string {
switch (t.t) {
case "i":
return t.i;
case "l":
return t.v;
case "b":
return t.d;
case "t":
return "[triple]";
}
}
function isIri(t: Term): boolean {
return t.t === "i";
}
/** Extract the local name from a URI for display */
function localName(uri: string): string {
const hash = uri.lastIndexOf("#");
const slash = uri.lastIndexOf("/");
const idx = Math.max(hash, slash);
if (idx >= 0 && idx < uri.length - 1) return uri.substring(idx + 1);
return uri;
}
/** Deterministic color from a string (for node types) */
function hashColor(s: string): string {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = s.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = ((hash % 360) + 360) % 360;
return `hsl(${hue}, 60%, 55%)`;
}
// ---------------------------------------------------------------------------
// Build graph data from triples
// ---------------------------------------------------------------------------
function triplesToGraph(triples: Triple[]): {
data: GraphData;
labelMap: Map<string, string>;
typeMap: Map<string, string>;
} {
const labelMap = new Map<string, string>();
const typeMap = new Map<string, string>();
// First pass: collect labels and types
for (const t of triples) {
const pred = termValue(t.p);
if (pred === RDFS_LABEL && t.o.t === "l") {
labelMap.set(termValue(t.s), t.o.v);
}
if (pred === RDF_TYPE && isIri(t.o)) {
typeMap.set(termValue(t.s), termValue(t.o));
}
}
// Second pass: build nodes and links (skip structural triples)
const nodeMap = new Map<string, GraphNode>();
const links: GraphLink[] = [];
const ensureNode = (uri: string): void => {
if (!nodeMap.has(uri)) {
const type = typeMap.get(uri);
nodeMap.set(uri, {
id: uri,
label: labelMap.get(uri) ?? localName(uri),
color: type ? hashColor(localName(type)) : "#5b80ff",
degree: 0,
});
}
};
for (const t of triples) {
const sVal = termValue(t.s);
const pVal = termValue(t.p);
const oVal = termValue(t.o);
// Skip label and type predicates -- they are metadata, not graph edges
if (pVal === RDFS_LABEL) continue;
if (pVal === RDF_TYPE) continue;
// Only build edges when both endpoints are IRIs (entity-to-entity)
if (!isIri(t.s) || !isIri(t.o)) continue;
ensureNode(sVal);
ensureNode(oVal);
nodeMap.get(sVal)!.degree++;
nodeMap.get(oVal)!.degree++;
links.push({
source: sVal,
target: oVal,
label: labelMap.get(pVal) ?? localName(pVal),
});
}
return {
data: { nodes: Array.from(nodeMap.values()), links },
labelMap,
typeMap,
};
}
// ---------------------------------------------------------------------------
// Node detail panel
// ---------------------------------------------------------------------------
function NodeDetailPanel({
nodeId,
label,
triples,
labelMap,
onClose,
}: {
nodeId: string;
label: string;
triples: Triple[];
labelMap: Map<string, string>;
onClose: () => void;
}) {
// Find triples where this node is subject or object
const related = useMemo(() => {
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
for (const t of triples) {
const sVal = termValue(t.s);
const pVal = termValue(t.p);
const oVal = termValue(t.o);
if (pVal === RDFS_LABEL || pVal === RDF_TYPE) continue;
if (sVal === nodeId) {
outbound.push({
predicate: labelMap.get(pVal) ?? localName(pVal),
object: oVal,
objectLabel: labelMap.get(oVal) ?? localName(oVal),
});
}
if (oVal === nodeId) {
inbound.push({
predicate: labelMap.get(pVal) ?? localName(pVal),
subject: sVal,
subjectLabel: labelMap.get(sVal) ?? localName(sVal),
});
}
}
return { outbound, inbound };
}, [nodeId, triples, labelMap]);
return (
<div className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-surface-50">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h3 className="truncate text-sm font-semibold text-fg">{label}</h3>
<button
onClick={onClose}
className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<p className="mb-3 truncate font-mono text-[10px] text-fg-subtle">
{nodeId}
</p>
{/* Outbound relationships */}
{related.outbound.length > 0 && (
<div className="mb-4">
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
<ArrowRight className="h-3 w-3" />
Outbound ({related.outbound.length})
</h4>
<div className="space-y-1">
{related.outbound.map((r, i) => (
<div
key={i}
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
>
<Badge variant="default">{r.predicate}</Badge>
<span className="truncate text-fg-muted">{r.objectLabel}</span>
</div>
))}
</div>
</div>
)}
{/* Inbound relationships */}
{related.inbound.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
<ArrowLeft className="h-3 w-3" />
Inbound ({related.inbound.length})
</h4>
<div className="space-y-1">
{related.inbound.map((r, i) => (
<div
key={i}
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
>
<span className="truncate text-fg-muted">{r.subjectLabel}</span>
<Badge variant="default">{r.predicate}</Badge>
</div>
))}
</div>
</div>
)}
{related.outbound.length === 0 && related.inbound.length === 0 && (
<p className="text-xs text-fg-subtle">No relationships found.</p>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Graph page
// ---------------------------------------------------------------------------
export default function GraphPage() {
const socket = useSocket();
const flowId = useSessionStore((s) => s.flowId);
const collection = useSettings((s) => s.settings.collection);
const addActivity = useProgressStore((s) => s.addActivity);
const removeActivity = useProgressStore((s) => s.removeActivity);
const [triples, setTriples] = useState<Triple[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
undefined,
);
// Fetch triples
const fetchTriples = useCallback(async () => {
const act = "Load graph";
try {
setLoading(true);
setError(null);
addActivity(act);
const flow = socket.flow(flowId);
const result = await flow.triplesQuery(
undefined,
undefined,
undefined,
2000,
collection,
);
setTriples(result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
removeActivity(act);
}
}, [socket, flowId, collection, addActivity, removeActivity]);
useEffect(() => {
fetchTriples();
}, [fetchTriples]);
// Build graph
const { data: graphData, labelMap } = useMemo(
() => triplesToGraph(triples),
[triples],
);
// Search filter -- highlight matching nodes
const searchLower = searchTerm.toLowerCase();
const matchingIds = useMemo(() => {
if (!searchLower) return new Set<string>();
return new Set(
graphData.nodes
.filter(
(n) =>
n.label.toLowerCase().includes(searchLower) ||
n.id.toLowerCase().includes(searchLower),
)
.map((n) => n.id),
);
}, [graphData.nodes, searchLower]);
const selectedLabel = selectedNode
? labelMap.get(selectedNode) ?? localName(selectedNode)
: "";
// Zoom helpers
const zoomIn = () => fgRef.current?.zoom(2, 300);
const zoomOut = () => fgRef.current?.zoom(0.5, 300);
const zoomFit = () =>
fgRef.current?.zoomToFit(400, 40);
// Node paint callback
const paintNode = useCallback(
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
const isSelected = node.id === selectedNode;
const isMatch = matchingIds.size > 0 && matchingIds.has(node.id);
const dim = matchingIds.size > 0 && !isMatch && !isSelected;
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
const x = node.x ?? 0;
const y = node.y ?? 0;
// Node circle
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = dim
? "rgba(100,100,100,0.3)"
: isSelected
? "#fbbf24"
: isMatch
? "#22c55e"
: node.color ?? "#5b80ff";
ctx.fill();
if (isSelected || isMatch) {
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
ctx.lineWidth = 1.5 / globalScale;
ctx.stroke();
}
// Label
const fontSize = Math.max(10 / globalScale, 2);
ctx.font = `${fontSize}px Inter, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillStyle = dim ? "rgba(100,100,100,0.3)" : "rgba(250,250,250,0.9)";
ctx.fillText(node.label, x, y + radius + 1);
},
[selectedNode, matchingIds],
);
// Link label painting
const paintLink = useCallback(
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
if (globalScale < 1.5) return; // only show labels when zoomed in enough
const src = link.source as unknown as GraphNode;
const tgt = link.target as unknown as GraphNode;
if (!src.x || !tgt.x) return;
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
const fontSize = Math.max(8 / globalScale, 1.5);
ctx.font = `${fontSize}px Inter, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "rgba(161,161,170,0.7)";
ctx.fillText(link.label, midX, midY);
},
[],
);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Rotate3d className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Graph</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
{graphData.nodes.length} nodes, {graphData.links.length} edges
</span>
</div>
<div className="flex items-center gap-2">
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-subtle" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search nodes..."
className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs 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-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Zoom controls */}
<div className="flex rounded-lg border border-border bg-surface-100">
<button
onClick={zoomIn}
className="px-2 py-1.5 text-fg-muted hover:text-fg"
title="Zoom in"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
onClick={zoomOut}
className="border-l border-r border-border px-2 py-1.5 text-fg-muted hover:text-fg"
title="Zoom out"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
onClick={zoomFit}
className="px-2 py-1.5 text-fg-muted hover:text-fg"
title="Fit to view"
>
<Maximize className="h-3.5 w-3.5" />
</button>
</div>
<button
onClick={fetchTriples}
disabled={loading}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-fg-muted hover:bg-surface-200 disabled:opacity-40"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Rotate3d className="h-3.5 w-3.5" />
)}
Reload
</button>
</div>
</div>
{/* Content */}
{error && (
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
{error}
</p>
)}
{loading && triples.length === 0 && (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
<span className="text-fg-subtle">Loading graph data...</span>
</div>
)}
{!loading && graphData.nodes.length === 0 && (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border">
<div className="text-center">
<Rotate3d className="mx-auto mb-3 h-10 w-10 text-fg-subtle opacity-30" />
<p className="text-fg-subtle">No graph data in this collection.</p>
<p className="mt-1 text-xs text-fg-subtle">
Upload documents and process them to populate the knowledge graph.
</p>
</div>
</div>
)}
{graphData.nodes.length > 0 && (
<div className="flex flex-1 overflow-hidden rounded-lg border border-border">
{/* Graph canvas */}
<div className="relative flex-1 bg-surface-0">
<ForceGraph2D
ref={fgRef}
graphData={graphData}
nodeCanvasObject={paintNode}
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, radius + 2, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
}}
linkCanvasObjectMode={() => "after"}
linkCanvasObject={paintLink}
linkColor={() => "rgba(91,128,255,0.25)"}
linkDirectionalArrowLength={4}
linkDirectionalArrowRelPos={0.85}
onNodeClick={(node: GraphNode) => {
setSelectedNode((prev) =>
prev === node.id ? null : node.id,
);
}}
onBackgroundClick={() => setSelectedNode(null)}
backgroundColor="transparent"
width={undefined}
height={undefined}
/>
{/* Search results badge overlay */}
{searchTerm && matchingIds.size > 0 && (
<div className="absolute bottom-3 left-3">
<Badge variant="success">
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
</Badge>
</div>
)}
</div>
{/* Detail panel */}
{selectedNode && (
<NodeDetailPanel
nodeId={selectedNode}
label={selectedLabel}
triples={triples}
labelMap={labelMap}
onClose={() => setSelectedNode(null)}
/>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,486 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
LibraryBig,
Upload,
Trash2,
RefreshCw,
FileText,
FileType2,
Loader2,
X,
AlertTriangle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useLibrary } from "@/hooks/use-library";
import { useSettings } from "@/providers/settings-provider";
import { useNotification } from "@/providers/notification-provider";
import { Dialog } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import type { DocumentMetadata } from "@trustgraph/client";
// ---------------------------------------------------------------------------
// Upload dialog
// ---------------------------------------------------------------------------
function UploadDialog({
open,
onClose,
onUpload,
}: {
open: boolean;
onClose: () => void;
onUpload: (
data: string,
mimeType: string,
title: string,
comments: string,
tags: string[],
) => Promise<void>;
}) {
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [comments, setComments] = useState("");
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const reset = () => {
setFile(null);
setTitle("");
setTags("");
setComments("");
setUploading(false);
};
const handleFile = (f: File) => {
setFile(f);
if (!title) setTitle(f.name.replace(/\.[^/.]+$/, ""));
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit = async () => {
if (!file) return;
setUploading(true);
try {
const base64 = await fileToBase64(file);
const tagList = tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
await onUpload(base64, file.type || "application/octet-stream", title, comments, tagList);
reset();
onClose();
} catch {
setUploading(false);
}
};
return (
<Dialog
open={open}
onClose={() => {
if (!uploading) {
reset();
onClose();
}
}}
title="Upload Document"
footer={
<>
<button
onClick={() => {
reset();
onClose();
}}
disabled={uploading}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!file || !title.trim() || uploading}
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"
>
{uploading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Upload
</button>
</>
}
>
{/* Drop zone */}
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={cn(
"mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 transition-colors",
dragOver
? "border-brand-500 bg-brand-500/10"
: "border-border hover:border-border-hover",
)}
>
<Upload className="mb-2 h-8 w-8 text-fg-subtle" />
{file ? (
<div className="flex items-center gap-2 text-sm text-fg">
<FileText className="h-4 w-4" />
<span>{file.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
setFile(null);
}}
className="ml-1 text-fg-subtle hover:text-fg"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<>
<p className="text-sm text-fg-muted">
Drop a file here or click to browse
</p>
<p className="mt-1 text-xs text-fg-subtle">PDF, TXT, or other text formats</p>
</>
)}
<input
ref={inputRef}
type="file"
className="hidden"
accept=".pdf,.txt,.md,.csv,.json,.xml,.html"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
</div>
{/* Title */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Document title"
className="w-full rounded-lg border border-border bg-surface-100 px-3 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>
{/* Comments */}
<div className="mb-3 space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">Comments</label>
<input
type="text"
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Optional comments"
className="w-full rounded-lg border border-border bg-surface-100 px-3 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>
{/* Tags */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">Tags</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Comma-separated tags"
className="w-full rounded-lg border border-border bg-surface-100 px-3 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>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Confirm delete dialog
// ---------------------------------------------------------------------------
function ConfirmDeleteDialog({
open,
docTitle,
onClose,
onConfirm,
}: {
open: boolean;
docTitle: string;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog
open={open}
onClose={onClose}
title="Delete Document"
footer={
<>
<button
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
>
Cancel
</button>
<button
onClick={onConfirm}
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
>
Delete
</button>
</>
}
>
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
<p className="text-sm text-fg-muted">
Are you sure you want to delete{" "}
<span className="font-medium text-fg">{docTitle || "this document"}</span>?
This action cannot be undone.
</p>
</div>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Library page
// ---------------------------------------------------------------------------
export default function LibraryPage() {
const {
documents,
processing,
loading,
error,
getDocuments,
uploadDocument,
removeDocument,
getProcessing,
} = useLibrary();
const collection = useSettings((s) => s.settings.collection);
const notify = useNotification();
const [uploadOpen, setUploadOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DocumentMetadata | null>(null);
// Load documents and processing on mount
useEffect(() => {
getDocuments();
getProcessing();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleUpload = async (
data: string,
mimeType: string,
title: string,
comments: string,
tags: string[],
) => {
try {
await uploadDocument(data, mimeType, title, comments, tags);
notify.success("Document uploaded", `"${title}" is being processed.`);
getProcessing();
} catch {
notify.error("Upload failed", "Could not upload the document.");
}
};
const handleDelete = async () => {
if (!deleteTarget?.id) return;
try {
await removeDocument(deleteTarget.id, collection);
notify.success("Document deleted");
} catch {
notify.error("Delete failed");
}
setDeleteTarget(null);
};
const handleRefresh = () => {
getDocuments();
getProcessing();
};
const guessKind = (doc: DocumentMetadata): string => {
const kind = doc.kind ?? doc["document-type"] ?? "";
if (kind.includes("pdf")) return "PDF";
if (kind.includes("text") || kind.includes("plain")) return "Text";
if (kind.includes("html")) return "HTML";
if (kind.includes("json")) return "JSON";
return kind || "--";
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<LibraryBig className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Library</h1>
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
{collection}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
disabled={loading}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
Refresh
</button>
<button
onClick={() => setUploadOpen(true)}
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
>
<Upload className="h-4 w-4" />
Upload
</button>
</div>
</div>
{/* Processing status */}
{processing.length > 0 && (
<div className="mb-4 rounded-lg border border-brand-500/30 bg-brand-500/5 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-brand-300">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processing ({processing.length})
</div>
<div className="space-y-1">
{processing.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-xs text-fg-muted">
<FileType2 className="h-3 w-3" />
<span className="truncate">{p["document-id"] || p.id}</span>
<Badge variant="info" className="ml-auto">
{p.flow || "processing"}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Content */}
{loading && documents.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
<span className="text-fg-subtle">Loading documents...</span>
</div>
)}
{error && (
<p className="py-8 text-center text-error">Error: {error}</p>
)}
{!loading && !error && documents.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center">
<LibraryBig className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
<p className="text-fg-subtle">
No documents yet. Upload one to get started.
</p>
</div>
)}
{documents.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-sm">
<thead className="border-b border-border bg-surface-100 text-fg-muted">
<tr>
<th className="px-4 py-3 font-medium">Title</th>
<th className="px-4 py-3 font-medium">Type</th>
<th className="px-4 py-3 font-medium">Tags</th>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{documents.map((doc) => (
<tr key={doc.id} className="hover:bg-surface-100/50">
<td className="px-4 py-3 text-fg">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-fg-subtle" />
{doc.title || "Untitled"}
</div>
</td>
<td className="px-4 py-3">
<Badge variant="default">{guessKind(doc)}</Badge>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(doc.tags ?? []).map((tag) => (
<Badge key={tag} variant="info">{tag}</Badge>
))}
{(!doc.tags || doc.tags.length === 0) && (
<span className="text-fg-subtle">--</span>
)}
</div>
</td>
<td className="max-w-[12rem] truncate px-4 py-3 font-mono text-xs text-fg-subtle">
{doc.id}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setDeleteTarget(doc)}
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
title="Delete document"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Dialogs */}
<UploadDialog
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUpload={handleUpload}
/>
<ConfirmDeleteDialog
open={deleteTarget != null}
docTitle={deleteTarget?.title ?? deleteTarget?.id ?? ""}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Strip the data URL prefix (e.g. "data:application/pdf;base64,")
const base64 = result.includes(",") ? result.split(",")[1]! : result;
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}

View file

@ -0,0 +1,340 @@
import { useCallback, useEffect, useState } from "react";
import {
Settings as SettingsIcon,
Wifi,
WifiOff,
Key,
Eye,
EyeOff,
Database,
Workflow,
Info,
Loader2,
Moon,
Sun,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettings } from "@/providers/settings-provider";
import { useSocket } from "@/providers/socket-provider";
import { useConnectionState } from "@/providers/socket-provider";
import { useFlows } from "@/hooks/use-flows";
import { useSessionStore } from "@/hooks/use-session-store";
import { useNotification } from "@/providers/notification-provider";
import { Badge } from "@/components/ui/badge";
// ---------------------------------------------------------------------------
// Section wrapper
// ---------------------------------------------------------------------------
function Section({
title,
icon,
children,
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="rounded-lg border border-border bg-surface-50 p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-fg">
{icon}
{title}
</h2>
<div className="space-y-4">{children}</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Settings page
// ---------------------------------------------------------------------------
export default function SettingsPage() {
const { settings, updateSetting } = useSettings();
const connectionState = useConnectionState();
const socket = useSocket();
const { flows } = useFlows();
const notify = useNotification();
const flowId = useSessionStore((s) => s.flowId);
const setFlowId = useSessionStore((s) => s.setFlowId);
const [showApiKey, setShowApiKey] = useState(false);
const [collections, setCollections] = useState<
Array<{ id?: string; name?: string; [key: string]: unknown }>
>([]);
const [loadingCollections, setLoadingCollections] = useState(false);
// Dark mode toggle -- uses a class on <html> and persists to localStorage
const [isDark, setIsDark] = useState(() => {
if (typeof window === "undefined") return true;
return !document.documentElement.classList.contains("light");
});
const toggleTheme = useCallback(() => {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.remove("light");
localStorage.setItem("tg-theme", "dark");
} else {
document.documentElement.classList.add("light");
localStorage.setItem("tg-theme", "light");
}
}, [isDark]);
// Fetch collections
useEffect(() => {
let cancelled = false;
setLoadingCollections(true);
socket
.collectionManagement()
.listCollections()
.then((cols) => {
if (!cancelled) {
setCollections(
Array.isArray(cols)
? (cols as Array<{ id?: string; name?: string; [key: string]: unknown }>)
: [],
);
}
})
.catch(() => {
/* silent -- collections endpoint may not be available */
})
.finally(() => {
if (!cancelled) setLoadingCollections(false);
});
return () => {
cancelled = true;
};
}, [socket]);
// Connection status helpers
const isConnected =
connectionState.status === "connected" ||
connectionState.status === "authenticated" ||
connectionState.status === "unauthenticated";
const statusBadge = isConnected ? (
<Badge variant="success">
<Wifi className="h-3 w-3" /> {connectionState.status}
</Badge>
) : (
<Badge variant="error">
<WifiOff className="h-3 w-3" /> {connectionState.status}
</Badge>
);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-6 flex items-center gap-3">
<SettingsIcon className="h-6 w-6 text-brand-400" />
<h1 className="text-2xl font-bold text-fg">Settings</h1>
</div>
{/* Form */}
<div className="max-w-2xl space-y-5">
{/* Connection */}
<Section
title="Connection"
icon={<Wifi className="h-4 w-4 text-fg-subtle" />}
>
<div className="flex items-center gap-3">
<span className="text-sm text-fg-muted">Status:</span>
{statusBadge}
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
Gateway URL
</label>
<input
type="text"
value={settings.gatewayUrl}
onChange={(e) => updateSetting("gatewayUrl", e.target.value)}
placeholder="Leave blank to use the default proxy"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<p className="text-xs text-fg-subtle">
The WebSocket URL for the TrustGraph gateway.
</p>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
User ID
</label>
<input
type="text"
value={settings.user}
onChange={(e) => updateSetting("user", e.target.value)}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</Section>
{/* Authentication */}
<Section
title="Authentication"
icon={<Key className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
API Key
</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => updateSetting("apiKey", e.target.value)}
placeholder="Leave blank for unauthenticated access"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 pr-10 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={() => setShowApiKey((p) => !p)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<p className="text-xs text-fg-subtle">
Changing the API key will reconnect the WebSocket.
</p>
</div>
</Section>
{/* Collection */}
<Section
title="Collection"
icon={<Database className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
Active Collection
</label>
{loadingCollections ? (
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" /> Loading
collections...
</div>
) : collections.length > 0 ? (
<select
value={settings.collection}
onChange={(e) => updateSetting("collection", e.target.value)}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
{collections.map((c) => {
const id = c.id ?? String(c.name ?? c);
return (
<option key={id} value={id}>
{c.name ?? id}
</option>
);
})}
</select>
) : (
<input
type="text"
value={settings.collection}
onChange={(e) => updateSetting("collection", e.target.value)}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
)}
</div>
</Section>
{/* Flow */}
<Section
title="Active Flow"
icon={<Workflow className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-fg-muted">
Flow
</label>
{flows.length > 0 ? (
<select
value={flowId}
onChange={(e) => setFlowId(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="default">default</option>
{flows.map((f) => (
<option key={f.id} value={f.id}>
{f.id}
{f.description ? ` -- ${f.description}` : ""}
</option>
))}
</select>
) : (
<input
type="text"
value={flowId}
onChange={(e) => setFlowId(e.target.value)}
placeholder="default"
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
)}
<p className="text-xs text-fg-subtle">
The flow ID used for chat, graph queries, and document processing.
</p>
</div>
</Section>
{/* Theme */}
<Section
title="Appearance"
icon={isDark ? <Moon className="h-4 w-4 text-fg-subtle" /> : <Sun className="h-4 w-4 text-fg-subtle" />}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-fg">Theme</p>
<p className="text-xs text-fg-subtle">
Toggle between dark and light mode.
</p>
</div>
<button
onClick={toggleTheme}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
isDark ? "bg-brand-600" : "bg-surface-300",
)}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
isDark ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
</div>
</Section>
{/* About */}
<Section
title="About"
icon={<Info className="h-4 w-4 text-fg-subtle" />}
>
<div className="space-y-2 text-sm text-fg-muted">
<p>
<span className="font-medium text-fg">TrustGraph Workbench</span>{" "}
v0.1.0
</p>
<p>
A web-based interface for interacting with the TrustGraph
knowledge-graph system.
</p>
</div>
</Section>
</div>
</div>
);
}

View file

@ -0,0 +1,92 @@
import { create } from "zustand";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type NotificationType = "success" | "error" | "warning" | "info";
export interface Notification {
id: string;
type: NotificationType;
title: string;
description?: string;
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
interface NotificationState {
notifications: Notification[];
addNotification: (
type: NotificationType,
title: string,
description?: string,
) => string;
removeNotification: (id: string) => void;
/** Convenience wrappers */
success: (title: string, description?: string) => string;
error: (title: string, description?: string) => string;
warning: (title: string, description?: string) => string;
info: (title: string, description?: string) => string;
}
let _nextId = 0;
function nextId(): string {
return `notif-${++_nextId}-${Date.now()}`;
}
/**
* Simple toast-notification system backed by Zustand.
*
* Components can call `useNotification().success("Done!")` and render the
* current `notifications` array however they like (e.g. a shadcn Toast list).
*
* Notifications are auto-dismissed after 5 seconds.
*/
export const useNotification = create<NotificationState>()((set, get) => {
const AUTO_DISMISS_MS = 5_000;
const addNotification: NotificationState["addNotification"] = (
type,
title,
description,
) => {
const id = nextId();
const notification: Notification = { id, type, title, description };
set((state) => ({
notifications: [...state.notifications, notification],
}));
// Auto-dismiss
setTimeout(() => {
get().removeNotification(id);
}, AUTO_DISMISS_MS);
return id;
};
return {
notifications: [],
addNotification,
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
success: (title, description) =>
addNotification("success", title, description),
error: (title, description) =>
addNotification("error", title, description),
warning: (title, description) =>
addNotification("warning", title, description),
info: (title, description) => addNotification("info", title, description),
};
});

View file

@ -0,0 +1,110 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FeatureSwitches {
flowClasses: boolean;
submissions: boolean;
tokenCost: boolean;
schemas: boolean;
structuredQuery: boolean;
ontologyEditor: boolean;
agentTools: boolean;
mcpTools: boolean;
llmModels: boolean;
}
export interface Settings {
/** Display name / identifier sent with every request */
user: string;
/** Optional API key for gateway authentication */
apiKey: string;
/** Active knowledge-graph collection */
collection: string;
/** Gateway base URL (used when building the WebSocket URL) */
gatewayUrl: string;
/** Toggle optional sections of the UI */
featureSwitches: FeatureSwitches;
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_FEATURE_SWITCHES: FeatureSwitches = {
flowClasses: false,
submissions: false,
tokenCost: false,
schemas: false,
structuredQuery: false,
ontologyEditor: false,
agentTools: false,
mcpTools: false,
llmModels: false,
};
const DEFAULT_SETTINGS: Settings = {
user: "user",
apiKey: "",
collection: "default",
gatewayUrl: "",
featureSwitches: DEFAULT_FEATURE_SWITCHES,
};
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
interface SettingsState {
settings: Settings;
isLoaded: boolean;
/** Replace the entire settings object */
setSettings: (settings: Settings) => void;
/** Update a single top-level key */
updateSetting: <K extends keyof Settings>(
key: K,
value: Settings[K],
) => void;
/** Merge partial feature-switch overrides */
updateFeatureSwitches: (partial: Partial<FeatureSwitches>) => void;
}
export const useSettings = create<SettingsState>()(
persist(
(set) => ({
settings: DEFAULT_SETTINGS,
isLoaded: true,
setSettings: (settings) => set({ settings }),
updateSetting: (key, value) =>
set((state) => ({
settings: { ...state.settings, [key]: value },
})),
updateFeatureSwitches: (partial) =>
set((state) => ({
settings: {
...state.settings,
featureSwitches: {
...state.settings.featureSwitches,
...partial,
},
},
})),
}),
{
name: "trustgraph-settings",
// Mark loaded once rehydration completes
onRehydrateStorage: () => (state) => {
if (state) state.isLoaded = true;
},
},
),
);

View file

@ -0,0 +1,125 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import { BaseApi, type ConnectionState } from "@trustgraph/client";
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
interface SocketContextValue {
api: BaseApi;
}
const SocketContext = createContext<SocketContextValue | null>(null);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export interface SocketProviderProps {
/** Username sent with every API request */
user: string;
/** Optional API key for authenticated connections */
apiKey?: string;
/** WebSocket URL (defaults to "/api/socket", proxied by Vite in dev) */
socketUrl?: string;
children: ReactNode;
}
/**
* SocketProvider creates a single BaseApi instance that lives for the
* lifetime of the provider and tears down the WebSocket on unmount.
*
* The BaseApi is recreated if `user`, `apiKey`, or `socketUrl` change.
*/
export function SocketProvider({
user,
apiKey,
socketUrl,
children,
}: SocketProviderProps) {
const apiRef = useRef<BaseApi | null>(null);
// Re-create the API instance when connection parameters change.
// We track a serial number so downstream consumers re-render.
const [serial, setSerial] = useState(0);
useEffect(() => {
// Close the previous socket if it exists
apiRef.current?.close();
const api = new BaseApi(user, apiKey, socketUrl);
apiRef.current = api;
setSerial((s) => s + 1);
return () => {
api.close();
if (apiRef.current === api) {
apiRef.current = null;
}
};
}, [user, apiKey, socketUrl]);
// Don't render children until the first API instance is ready
if (!apiRef.current) return null;
return (
<SocketContext.Provider
// eslint-disable-next-line react/no-children-prop
key={serial}
value={{ api: apiRef.current }}
>
{children}
</SocketContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
/**
* Returns the shared BaseApi instance.
*
* Must be called inside a `<SocketProvider>`.
*/
export function useSocket(): BaseApi {
const ctx = useContext(SocketContext);
if (!ctx) {
throw new Error("useSocket must be used within a <SocketProvider>");
}
return ctx.api;
}
/**
* Subscribes to connection-state changes emitted by BaseApi.
*
* Uses `useSyncExternalStore` for tear-free reads.
*/
export function useConnectionState(): ConnectionState {
const api = useSocket();
// We store the latest snapshot in a ref so the getSnapshot function is stable.
const stateRef = useRef<ConnectionState>({
status: "connecting",
hasApiKey: false,
});
const subscribe = (onStoreChange: () => void) => {
return api.onConnectionStateChange((next) => {
stateRef.current = next;
onStoreChange();
});
};
const getSnapshot = () => stateRef.current;
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

View file

@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"noEmit": true,
"composite": true
},
"include": ["src", "vite-env.d.ts"]
}

1
ts/packages/workbench/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api/socket": {
target: "ws://localhost:8088/",
ws: true,
rewrite: (p) => p.replace("/api/socket", "/api/v1/socket"),
},
"/api/v1": {
target: "http://localhost:8088/",
},
},
},
});