mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-02 02:58:10 +02:00
Advance TS port Effect workbench
This commit is contained in:
parent
92dae8c374
commit
3515106670
116 changed files with 12286 additions and 9584 deletions
|
|
@ -20,12 +20,19 @@
|
|||
"test": "bunx --bun vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.65",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"nats": "^2.29.0",
|
||||
"prom-client": "^15.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
MessagingRuntimeLive,
|
||||
ProducerSpec,
|
||||
PubSub,
|
||||
runFlowProcessorDefinitionScoped,
|
||||
runProcessorScoped,
|
||||
topics,
|
||||
type BackendConsumer,
|
||||
|
|
@ -212,4 +213,45 @@ describe("Effect-native FlowProcessor runtime", () => {
|
|||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"runs flow specs without a FlowProcessor subclass",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new FlowProcessorBackend();
|
||||
const events: Array<string> = [];
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* runFlowProcessorDefinitionScoped({
|
||||
id: "functional-flow-processor-test",
|
||||
pubsub: backend,
|
||||
specifications: [new ProducerSpec<string>("output")],
|
||||
configHandlers: [
|
||||
(_config, version) => Effect.sync(() => {
|
||||
events.push(`handler:${version}`);
|
||||
}),
|
||||
],
|
||||
}).pipe(
|
||||
Effect.provide(MessagingRuntimeLive),
|
||||
Effect.provide(PubSub.layer(backend)),
|
||||
Effect.provide(fastMessagingConfig),
|
||||
Effect.forkChild,
|
||||
);
|
||||
|
||||
yield* waitFor(() => backend.consumerOptions.length === 1, "config subscription");
|
||||
|
||||
backend.pushConfig(1, { default: { topics: { output: "functional-output" } } });
|
||||
yield* waitFor(() => backend.producers.length === 1, "functional flow producer");
|
||||
yield* waitFor(() => backend.configConsumer.acknowledged.length === 1, "functional config ack");
|
||||
|
||||
yield* Fiber.interrupt(fiber);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(backend.producerOptions.map((options) => options.topic)).toEqual(["functional-output"]);
|
||||
expect(events).toEqual(["handler:1"]);
|
||||
expect(backend.configConsumer.closeCount).toBeGreaterThanOrEqual(1);
|
||||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/flow_processor.py
|
||||
*/
|
||||
|
||||
import { AsyncProcessor, type ProcessorConfig } from "./async-processor.js";
|
||||
import {
|
||||
AsyncProcessor,
|
||||
type EffectConfigHandler,
|
||||
type ProcessorConfig,
|
||||
} from "./async-processor.js";
|
||||
import type { Spec } from "../spec/types.js";
|
||||
import type { BackendConsumer } from "../backend/types.js";
|
||||
import type { BackendConsumer, PubSubBackend } from "../backend/types.js";
|
||||
import { Flow, type FlowDefinition } from "./flow.js";
|
||||
import { topics } from "../schema/topics.js";
|
||||
import {
|
||||
|
|
@ -42,11 +46,241 @@ interface ActiveFlow {
|
|||
readonly scope: Scope.Closeable;
|
||||
}
|
||||
|
||||
export interface FlowProcessorRuntimeOptions<
|
||||
FlowRequirements = never,
|
||||
ConfigHandlerError = never,
|
||||
ConfigHandlerRequirements = never,
|
||||
> {
|
||||
readonly id: string;
|
||||
readonly pubsub: PubSubBackend;
|
||||
readonly specifications: ReadonlyArray<Spec<FlowRequirements>>;
|
||||
readonly configHandlers?: ReadonlyArray<
|
||||
EffectConfigHandler<ConfigHandlerError, ConfigHandlerRequirements>
|
||||
>;
|
||||
readonly isRunning?: () => boolean;
|
||||
}
|
||||
|
||||
const ConfigPushSchema = S.Struct({
|
||||
version: S.Number,
|
||||
config: S.Record(S.String, S.Unknown),
|
||||
});
|
||||
|
||||
export function runFlowProcessorDefinitionScoped<
|
||||
FlowRequirements = never,
|
||||
ConfigHandlerError = never,
|
||||
ConfigHandlerRequirements = never,
|
||||
>(
|
||||
options: FlowProcessorRuntimeOptions<
|
||||
FlowRequirements,
|
||||
ConfigHandlerError,
|
||||
ConfigHandlerRequirements
|
||||
>,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
PubSubError | FlowRuntimeError | ConfigHandlerError,
|
||||
| PubSub
|
||||
| FlowRuntime
|
||||
| ProducerFactory
|
||||
| ConsumerFactory
|
||||
| RequestResponseFactory
|
||||
| Scope.Scope
|
||||
| FlowRequirements
|
||||
| ConfigHandlerRequirements
|
||||
> {
|
||||
const flows = new Map<string, ActiveFlow>();
|
||||
let configConsumer: BackendConsumer<ConfigPush> | null = null;
|
||||
let lastFlowsJson = "";
|
||||
const isRunning = options.isRunning ?? (() => true);
|
||||
|
||||
const closeFlowEffect = (name: string, activeFlow: ActiveFlow): Effect.Effect<void> =>
|
||||
Scope.close(activeFlow.scope, Exit.void).pipe(
|
||||
Effect.tap(() => Effect.log(`[${options.id}] Flow "${name}" stopped`)),
|
||||
);
|
||||
|
||||
const closeAllFlowsEffect = Effect.gen(function* () {
|
||||
const activeFlows = Array.from(flows.entries());
|
||||
for (const [name, activeFlow] of activeFlows) {
|
||||
yield* closeFlowEffect(name, activeFlow);
|
||||
}
|
||||
flows.clear();
|
||||
});
|
||||
|
||||
const closeConfigConsumerEffect = (): Effect.Effect<void> => {
|
||||
const consumer = configConsumer;
|
||||
configConsumer = null;
|
||||
if (consumer === null) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.tryPromise({
|
||||
try: () => consumer.close(),
|
||||
catch: (error) => pubSubError("close:config-push", error),
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError(`[${options.id}] Failed to close config consumer`, {
|
||||
error: error.message,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const startFlowEffect = (
|
||||
name: string,
|
||||
definition: FlowDefinition,
|
||||
): Effect.Effect<
|
||||
ActiveFlow,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> =>
|
||||
Effect.gen(function* () {
|
||||
const flowRuntime = yield* FlowRuntime;
|
||||
const scope = yield* Scope.make();
|
||||
const flow = new Flow<FlowRequirements>(
|
||||
name,
|
||||
options.id,
|
||||
options.pubsub,
|
||||
definition,
|
||||
options.specifications,
|
||||
);
|
||||
return yield* flowRuntime.run(flow).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.as({ scope } satisfies ActiveFlow),
|
||||
Effect.catch((error) =>
|
||||
Scope.close(scope, Exit.void).pipe(
|
||||
Effect.flatMap(() => Effect.fail(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const onConfigureFlowsEffect = (
|
||||
config: Record<string, unknown>,
|
||||
_version: number,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> =>
|
||||
Effect.gen(function* () {
|
||||
const flowDefs = config.flows as Record<string, FlowDefinition> | undefined;
|
||||
if (flowDefs === undefined) {
|
||||
yield* Effect.log(`[${options.id}] No flows in config push, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe(
|
||||
Effect.catch((error) => Effect.succeed(String(error))),
|
||||
);
|
||||
if (lastFlowsJson.length > 0 && flowsJson === lastFlowsJson && flows.size > 0) {
|
||||
yield* Effect.log(`[${options.id}] Flow definitions unchanged, skipping restart`);
|
||||
return;
|
||||
}
|
||||
lastFlowsJson = flowsJson;
|
||||
|
||||
for (const [name, activeFlow] of flows) {
|
||||
if (!(name in flowDefs)) {
|
||||
yield* Effect.log(`[${options.id}] Stopping removed flow: ${name}`);
|
||||
yield* closeFlowEffect(name, activeFlow);
|
||||
flows.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, defn] of Object.entries(flowDefs)) {
|
||||
if (typeof defn !== "object" || defn === null) {
|
||||
yield* Effect.logWarning(`[${options.id}] Skipping flow "${name}": definition is not an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = flows.get(name);
|
||||
if (existing !== undefined) {
|
||||
yield* Effect.log(`[${options.id}] Restarting flow "${name}" with updated config`);
|
||||
yield* closeFlowEffect(name, existing);
|
||||
flows.delete(name);
|
||||
}
|
||||
|
||||
yield* Effect.log(`[${options.id}] Starting flow "${name}"`);
|
||||
const activeFlow = yield* startFlowEffect(name, defn);
|
||||
flows.set(name, activeFlow);
|
||||
yield* Effect.log(`[${options.id}] Flow "${name}" started`);
|
||||
}
|
||||
});
|
||||
|
||||
const processNextConfigPushEffect = (): Effect.Effect<
|
||||
void,
|
||||
never,
|
||||
| FlowRuntime
|
||||
| ProducerFactory
|
||||
| ConsumerFactory
|
||||
| RequestResponseFactory
|
||||
| FlowRequirements
|
||||
| ConfigHandlerRequirements
|
||||
> =>
|
||||
Effect.gen(function* () {
|
||||
const consumer = configConsumer;
|
||||
if (consumer === null) {
|
||||
yield* Effect.sleep(Duration.millis(1000));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = yield* Effect.tryPromise({
|
||||
try: () => consumer.receive(2000),
|
||||
catch: (error) => pubSubError("receive:config-push", error),
|
||||
});
|
||||
if (msg === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const push = msg.value();
|
||||
yield* Effect.log(`[${options.id}] Received config push version=${push.version}`);
|
||||
|
||||
yield* onConfigureFlowsEffect(push.config, push.version);
|
||||
|
||||
for (const handler of options.configHandlers ?? []) {
|
||||
yield* handler(push.config, push.version);
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => consumer.acknowledge(msg),
|
||||
catch: (error) => pubSubError("acknowledge:config-push", error),
|
||||
});
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!isRunning()) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.logError(`[${options.id}] Config consumer error`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
|
||||
configConsumer = yield* pubsub.createConsumer<ConfigPush>({
|
||||
topic: topics.configPush,
|
||||
subscription: `${options.id}-config-push`,
|
||||
initialPosition: "earliest",
|
||||
schema: ConfigPushSchema,
|
||||
});
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
closeConfigConsumerEffect().pipe(
|
||||
Effect.flatMap(() => closeAllFlowsEffect),
|
||||
),
|
||||
);
|
||||
|
||||
yield* Effect.log(`[${options.id}] Listening for config pushes on ${topics.configPush}`);
|
||||
|
||||
yield* Effect.whileLoop({
|
||||
while: isRunning,
|
||||
body: processNextConfigPushEffect,
|
||||
step: () => undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export abstract class FlowProcessor<FlowRequirements = never> extends AsyncProcessor<
|
||||
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
|
||||
| PubSub
|
||||
|
|
@ -58,9 +292,6 @@ export abstract class FlowProcessor<FlowRequirements = never> extends AsyncProce
|
|||
| FlowRequirements
|
||||
> {
|
||||
private specifications: Array<Spec<FlowRequirements>> = [];
|
||||
private flows = new Map<string, ActiveFlow>();
|
||||
private configConsumer: BackendConsumer<ConfigPush> | null = null;
|
||||
private lastFlowsJson = "";
|
||||
|
||||
protected constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
|
@ -104,216 +335,24 @@ export abstract class FlowProcessor<FlowRequirements = never> extends AsyncProce
|
|||
| FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
|
||||
// Subscribe to config-push topic to receive flow definitions.
|
||||
// Use "earliest" to replay any config pushes that arrived before this service started.
|
||||
processor.configConsumer = yield* pubsub.createConsumer<ConfigPush>({
|
||||
topic: topics.configPush,
|
||||
subscription: `${processor.config.id}-config-push`,
|
||||
initialPosition: "earliest",
|
||||
schema: ConfigPushSchema,
|
||||
});
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
processor.closeConfigConsumerEffect().pipe(
|
||||
Effect.flatMap(() => processor.closeAllFlowsEffect()),
|
||||
),
|
||||
);
|
||||
|
||||
yield* Effect.log(`[${processor.config.id}] Listening for config pushes on ${topics.configPush}`);
|
||||
|
||||
yield* Effect.whileLoop({
|
||||
while: () => processor.running,
|
||||
body: () => processor.processNextConfigPushEffect(),
|
||||
step: () => undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private onConfigureFlowsEffect(
|
||||
config: Record<string, unknown>,
|
||||
_version: number,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const flowDefs = config.flows as Record<string, FlowDefinition> | undefined;
|
||||
if (flowDefs === undefined) {
|
||||
yield* Effect.log(`[${processor.config.id}] No flows in config push, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip flow restart if the flow definitions haven't changed.
|
||||
// This prevents disrupting in-flight requests when non-flow config
|
||||
// sections (prompts, tools, mcp) are updated.
|
||||
const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe(
|
||||
Effect.catch((error) => Effect.succeed(String(error))),
|
||||
);
|
||||
if (processor.lastFlowsJson.length > 0 && flowsJson === processor.lastFlowsJson && processor.flows.size > 0) {
|
||||
yield* Effect.log(`[${processor.config.id}] Flow definitions unchanged, skipping restart`);
|
||||
return;
|
||||
}
|
||||
processor.lastFlowsJson = flowsJson;
|
||||
|
||||
// Stop removed flows
|
||||
for (const [name, activeFlow] of processor.flows) {
|
||||
if (!(name in flowDefs)) {
|
||||
yield* Effect.log(`[${processor.config.id}] Stopping removed flow: ${name}`);
|
||||
yield* processor.closeFlowEffect(name, activeFlow);
|
||||
processor.flows.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Start or update flows
|
||||
for (const [name, defn] of Object.entries(flowDefs)) {
|
||||
// Skip invalid definitions (e.g., stringified JSON)
|
||||
if (typeof defn !== "object" || defn === null) {
|
||||
yield* Effect.logWarning(`[${processor.config.id}] Skipping flow "${name}": definition is not an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stop existing flow before (re)starting with new config
|
||||
const existing = processor.flows.get(name);
|
||||
if (existing !== undefined) {
|
||||
yield* Effect.log(`[${processor.config.id}] Restarting flow "${name}" with updated config`);
|
||||
yield* processor.closeFlowEffect(name, existing);
|
||||
processor.flows.delete(name);
|
||||
}
|
||||
|
||||
yield* Effect.log(`[${processor.config.id}] Starting flow "${name}"`);
|
||||
const activeFlow = yield* processor.startFlowEffect(name, defn);
|
||||
processor.flows.set(name, activeFlow);
|
||||
yield* Effect.log(`[${processor.config.id}] Flow "${name}" started`);
|
||||
}
|
||||
const configHandlers = processor.configHandlers.map(
|
||||
(handler): EffectConfigHandler<PubSubError> =>
|
||||
(config, version) =>
|
||||
Effect.tryPromise({
|
||||
try: () => handler(config, version),
|
||||
catch: (error) => pubSubError("config-handler", error),
|
||||
}),
|
||||
);
|
||||
return runFlowProcessorDefinitionScoped({
|
||||
id: processor.config.id,
|
||||
pubsub: processor.pubsub,
|
||||
specifications: processor.specifications,
|
||||
configHandlers,
|
||||
isRunning: () => processor.running,
|
||||
});
|
||||
}
|
||||
|
||||
override stopEffect(): Effect.Effect<void, ProcessorLifecycleError> {
|
||||
return this.closeConfigConsumerEffect().pipe(
|
||||
Effect.flatMap(() => this.closeAllFlowsEffect()),
|
||||
Effect.flatMap(() => super.stopEffect()),
|
||||
);
|
||||
}
|
||||
|
||||
private processNextConfigPushEffect(): Effect.Effect<
|
||||
void,
|
||||
never,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const consumer = processor.configConsumer;
|
||||
if (consumer === null) {
|
||||
yield* Effect.sleep(Duration.millis(1000));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = yield* Effect.tryPromise({
|
||||
try: () => consumer.receive(2000),
|
||||
catch: (error) => pubSubError("receive:config-push", error),
|
||||
});
|
||||
if (msg === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const push = msg.value();
|
||||
yield* Effect.log(`[${processor.config.id}] Received config push version=${push.version}`);
|
||||
|
||||
yield* processor.onConfigureFlowsEffect(push.config, push.version);
|
||||
|
||||
for (const handler of processor.configHandlers) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => handler(push.config, push.version),
|
||||
catch: (error) => pubSubError("config-handler", error),
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => consumer.acknowledge(msg),
|
||||
catch: (error) => pubSubError("acknowledge:config-push", error),
|
||||
});
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!processor.running) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.logError(`[${processor.config.id}] Config consumer error`, {
|
||||
error: error.message,
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private startFlowEffect(
|
||||
name: string,
|
||||
definition: FlowDefinition,
|
||||
): Effect.Effect<
|
||||
ActiveFlow,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const flowRuntime = yield* FlowRuntime;
|
||||
const scope = yield* Scope.make();
|
||||
const flow = new Flow<FlowRequirements>(
|
||||
name,
|
||||
processor.config.id,
|
||||
processor.pubsub,
|
||||
definition,
|
||||
processor.specifications,
|
||||
);
|
||||
return yield* flowRuntime.run(flow).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.as({ scope } satisfies ActiveFlow),
|
||||
Effect.catch((error) =>
|
||||
Scope.close(scope, Exit.void).pipe(
|
||||
Effect.flatMap(() => Effect.fail(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private closeFlowEffect(name: string, activeFlow: ActiveFlow): Effect.Effect<void> {
|
||||
return Scope.close(activeFlow.scope, Exit.void).pipe(
|
||||
Effect.tap(() => Effect.log(`[${this.config.id}] Flow "${name}" stopped`)),
|
||||
);
|
||||
}
|
||||
|
||||
private closeAllFlowsEffect(): Effect.Effect<void> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const flows = Array.from(processor.flows.entries());
|
||||
for (const [name, activeFlow] of flows) {
|
||||
yield* processor.closeFlowEffect(name, activeFlow);
|
||||
}
|
||||
processor.flows.clear();
|
||||
});
|
||||
}
|
||||
|
||||
private closeConfigConsumerEffect(): Effect.Effect<void> {
|
||||
const consumer = this.configConsumer;
|
||||
this.configConsumer = null;
|
||||
if (consumer === null) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.tryPromise({
|
||||
try: () => consumer.close(),
|
||||
catch: (error) => pubSubError("close:config-push", error),
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError(`[${this.config.id}] Failed to close config consumer`, {
|
||||
error: error.message,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return super.stopEffect();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ export {
|
|||
type EffectConfigHandler,
|
||||
type ProcessorConfig,
|
||||
} from "./async-processor.js";
|
||||
export { FlowProcessor } from "./flow-processor.js";
|
||||
export {
|
||||
FlowProcessor,
|
||||
runFlowProcessorDefinitionScoped,
|
||||
type FlowProcessorRuntimeOptions,
|
||||
} from "./flow-processor.js";
|
||||
export {
|
||||
Flow,
|
||||
type FlowConsumer,
|
||||
|
|
@ -18,5 +22,6 @@ export {
|
|||
makeFlowProcessorProgram,
|
||||
makeProcessorProgram,
|
||||
runProcessorScoped,
|
||||
type FlowProcessorProgramOptions,
|
||||
type ProcessorProgramOptions,
|
||||
} from "./program.js";
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@
|
|||
* executable path while the processor internals remain Promise-based.
|
||||
*/
|
||||
|
||||
import { Effect, Scope } from "effect";
|
||||
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
|
||||
import { Config as EffectConfig, Effect, Layer, Scope } from "effect";
|
||||
import {
|
||||
processorLifecycleError,
|
||||
type FlowRuntimeError,
|
||||
type ProcessorLifecycleError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
import { NatsBackend } from "../backend/nats.js";
|
||||
import { makePubSubService, PubSub } from "../backend/pubsub.js";
|
||||
import {
|
||||
|
|
@ -24,7 +29,13 @@ import {
|
|||
type ProcessorRuntimeConfigOptions,
|
||||
} from "../runtime/config.js";
|
||||
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
|
||||
import type { AsyncProcessor, ProcessorConfig } from "./async-processor.js";
|
||||
import type {
|
||||
AsyncProcessor,
|
||||
EffectConfigHandler,
|
||||
ProcessorConfig,
|
||||
} from "./async-processor.js";
|
||||
import { runFlowProcessorDefinitionScoped } from "./flow-processor.js";
|
||||
import type { Spec } from "../spec/types.js";
|
||||
|
||||
type ProcessorRunError<Processor> = Processor extends AsyncProcessor<infer Error, unknown> ? Error : never;
|
||||
type ProcessorRunRequirements<Processor> = Processor extends AsyncProcessor<unknown, infer Requirements> ? Requirements : never;
|
||||
|
|
@ -40,6 +51,23 @@ export interface ProcessorProgramOptions<
|
|||
readonly loadConfig?: Effect.Effect<Config, Error, Requirements>;
|
||||
}
|
||||
|
||||
export interface FlowProcessorProgramOptions<
|
||||
Config extends ProcessorConfig,
|
||||
Error = never,
|
||||
FlowRequirements = never,
|
||||
LayerRequirements = never,
|
||||
> {
|
||||
readonly id: string;
|
||||
readonly loadConfig?: Effect.Effect<Config, Error, LayerRequirements>;
|
||||
readonly specs: (config: Config) => ReadonlyArray<Spec<FlowRequirements>>;
|
||||
readonly configHandlers?: (
|
||||
config: Config,
|
||||
) => ReadonlyArray<EffectConfigHandler<Error, FlowRequirements>>;
|
||||
readonly layer?: (
|
||||
config: Config,
|
||||
) => Layer.Layer<FlowRequirements, Error, LayerRequirements>;
|
||||
}
|
||||
|
||||
export function runProcessorScoped<
|
||||
Config extends ProcessorConfig,
|
||||
Processor extends AsyncProcessor<unknown, unknown>,
|
||||
|
|
@ -136,4 +164,74 @@ export function makeProcessorProgram<
|
|||
}
|
||||
|
||||
export const makeAsyncProcessorProgram = makeProcessorProgram;
|
||||
export const makeFlowProcessorProgram = makeProcessorProgram;
|
||||
|
||||
export function makeFlowProcessorProgram<
|
||||
Config extends ProcessorConfig,
|
||||
Error = never,
|
||||
FlowRequirements = never,
|
||||
LayerRequirements = never,
|
||||
>(
|
||||
options: FlowProcessorProgramOptions<Config, Error, FlowRequirements, LayerRequirements>,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
Error | EffectConfig.ConfigError | PubSubError | FlowRuntimeError,
|
||||
LayerRequirements
|
||||
> {
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* (
|
||||
options.loadConfig ??
|
||||
loadProcessorRuntimeConfig(options.id, {
|
||||
manageProcessSignals: false,
|
||||
} satisfies ProcessorRuntimeConfigOptions)
|
||||
);
|
||||
|
||||
const runtimeConfig = {
|
||||
...config,
|
||||
manageProcessSignals: false,
|
||||
} as Config;
|
||||
|
||||
const pubsub = makePubSubService(new NatsBackend(runtimeConfig.pubsubUrl ?? "nats://localhost:4222"));
|
||||
const messagingConfig = yield* loadMessagingRuntimeConfig();
|
||||
yield* Effect.addFinalizer(() =>
|
||||
pubsub.close.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[PubSub] Failed to close processor backend", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const configHandlers = options.configHandlers?.(runtimeConfig);
|
||||
const processorOptions = {
|
||||
id: runtimeConfig.id,
|
||||
pubsub: pubsub.backend,
|
||||
specifications: options.specs(runtimeConfig),
|
||||
...(configHandlers === undefined ? {} : { configHandlers }),
|
||||
};
|
||||
const processorLayer = Layer.effectDiscard(
|
||||
runFlowProcessorDefinitionScoped<FlowRequirements, Error, FlowRequirements>(processorOptions),
|
||||
);
|
||||
const runtimeLayer = Layer.mergeAll(
|
||||
Layer.succeed(PubSub, PubSub.of(pubsub)),
|
||||
Layer.succeed(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))),
|
||||
Layer.succeed(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))),
|
||||
Layer.succeed(
|
||||
RequestResponseFactory,
|
||||
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)),
|
||||
),
|
||||
Layer.succeed(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
|
||||
);
|
||||
const dependencyLayer = options.layer?.(runtimeConfig) ??
|
||||
(Layer.empty as unknown as Layer.Layer<FlowRequirements, Error, LayerRequirements>);
|
||||
const providedProcessorLayer = processorLayer.pipe(
|
||||
Layer.provide(dependencyLayer),
|
||||
Layer.provide(runtimeLayer),
|
||||
);
|
||||
|
||||
return yield* Layer.launch(providedProcessorLayer);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,15 +179,16 @@ export const ConfigOperation = S.Union([
|
|||
S.Literal("put"),
|
||||
S.Literal("config"),
|
||||
S.Literal("getvalues"),
|
||||
S.Literal("getvalues-all-ws"),
|
||||
]);
|
||||
export type ConfigOperation = typeof ConfigOperation.Type;
|
||||
|
||||
export const ConfigRequest = S.Struct({
|
||||
operation: ConfigOperation,
|
||||
keys: S.optionalKey(StringArray),
|
||||
values: S.optionalKey(UnknownRecord),
|
||||
type: S.optionalKey(S.String),
|
||||
});
|
||||
export const ConfigRequest = S.StructWithRest(
|
||||
S.Struct({
|
||||
operation: ConfigOperation,
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
export type ConfigRequest = typeof ConfigRequest.Type;
|
||||
|
||||
export const ConfigResponse = S.Struct({
|
||||
|
|
@ -271,7 +272,9 @@ export const DocumentMetadata = S.Struct({
|
|||
user: S.String,
|
||||
tags: StringArray,
|
||||
parentId: S.optionalKey(S.String),
|
||||
documentType: S.String,
|
||||
"parent-id": S.optionalKey(S.String),
|
||||
documentType: S.optionalKey(S.String),
|
||||
"document-type": S.optionalKey(S.String),
|
||||
metadata: OptionalMutableArray(Triple),
|
||||
});
|
||||
export type DocumentMetadata = typeof DocumentMetadata.Type;
|
||||
|
|
@ -279,6 +282,7 @@ export type DocumentMetadata = typeof DocumentMetadata.Type;
|
|||
export const ProcessingMetadata = S.Struct({
|
||||
id: S.String,
|
||||
documentId: S.String,
|
||||
"document-id": S.optionalKey(S.String),
|
||||
time: S.Number,
|
||||
flow: S.String,
|
||||
user: S.String,
|
||||
|
|
@ -291,6 +295,7 @@ export type ProcessingMetadata = typeof ProcessingMetadata.Type;
|
|||
export const LibrarianOperation = S.Literals([
|
||||
"add-document",
|
||||
"remove-document",
|
||||
"update-document",
|
||||
"list-documents",
|
||||
"get-document-metadata",
|
||||
"get-document-content",
|
||||
|
|
@ -299,15 +304,26 @@ export const LibrarianOperation = S.Literals([
|
|||
"add-processing",
|
||||
"remove-processing",
|
||||
"list-processing",
|
||||
"begin-upload",
|
||||
"upload-chunk",
|
||||
"complete-upload",
|
||||
"get-upload-status",
|
||||
"abort-upload",
|
||||
"list-uploads",
|
||||
"stream-document",
|
||||
]);
|
||||
export type LibrarianOperation = typeof LibrarianOperation.Type;
|
||||
|
||||
export const LibrarianRequest = S.Struct({
|
||||
operation: LibrarianOperation,
|
||||
documentId: S.optionalKey(S.String),
|
||||
"document-id": S.optionalKey(S.String),
|
||||
processingId: S.optionalKey(S.String),
|
||||
"processing-id": S.optionalKey(S.String),
|
||||
documentMetadata: S.optionalKey(DocumentMetadata),
|
||||
"document-metadata": S.optionalKey(DocumentMetadata),
|
||||
processingMetadata: S.optionalKey(ProcessingMetadata),
|
||||
"processing-metadata": S.optionalKey(ProcessingMetadata),
|
||||
content: S.optionalKey(S.String),
|
||||
user: S.optionalKey(S.String),
|
||||
collection: S.optionalKey(S.String),
|
||||
|
|
@ -317,9 +333,13 @@ export type LibrarianRequest = typeof LibrarianRequest.Type;
|
|||
export const LibrarianResponse = S.Struct({
|
||||
error: S.optionalKey(TgError),
|
||||
documentMetadata: S.optionalKey(DocumentMetadata),
|
||||
"document-metadata": S.optionalKey(DocumentMetadata),
|
||||
content: S.optionalKey(S.String),
|
||||
documents: OptionalMutableArray(DocumentMetadata),
|
||||
"document-metadatas": OptionalMutableArray(DocumentMetadata),
|
||||
processing: OptionalMutableArray(ProcessingMetadata),
|
||||
"processing-metadata": OptionalMutableArray(ProcessingMetadata),
|
||||
"processing-metadatas": OptionalMutableArray(ProcessingMetadata),
|
||||
});
|
||||
export type LibrarianResponse = typeof LibrarianResponse.Type;
|
||||
|
||||
|
|
@ -330,6 +350,12 @@ export const KnowledgeOperation = S.Literals([
|
|||
"delete-kg-core",
|
||||
"put-kg-core",
|
||||
"load-kg-core",
|
||||
"unload-kg-core",
|
||||
"list-de-cores",
|
||||
"get-de-core",
|
||||
"delete-de-core",
|
||||
"put-de-core",
|
||||
"load-de-core",
|
||||
]);
|
||||
export type KnowledgeOperation = typeof KnowledgeOperation.Type;
|
||||
|
||||
|
|
@ -338,6 +364,14 @@ const GraphEmbedding = S.Struct({
|
|||
vectors: NumberArrays,
|
||||
});
|
||||
|
||||
const DocumentEmbeddingsCore = S.StructWithRest(
|
||||
S.Struct({
|
||||
metadata: S.optionalKey(UnknownRecord),
|
||||
chunks: S.optionalKey(S.Unknown.pipe(S.Array, S.mutable)),
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
|
||||
export const KnowledgeRequest = S.Struct({
|
||||
operation: KnowledgeOperation,
|
||||
user: S.optionalKey(S.String),
|
||||
|
|
@ -346,6 +380,7 @@ export const KnowledgeRequest = S.Struct({
|
|||
collection: S.optionalKey(S.String),
|
||||
triples: OptionalMutableArray(Triple),
|
||||
graphEmbeddings: OptionalMutableArray(GraphEmbedding),
|
||||
documentEmbeddings: S.optionalKey(DocumentEmbeddingsCore),
|
||||
});
|
||||
export type KnowledgeRequest = typeof KnowledgeRequest.Type;
|
||||
|
||||
|
|
@ -355,6 +390,7 @@ export const KnowledgeResponse = S.Struct({
|
|||
eos: S.optionalKey(S.Boolean),
|
||||
triples: OptionalMutableArray(Triple),
|
||||
graphEmbeddings: OptionalMutableArray(GraphEmbedding),
|
||||
documentEmbeddings: S.optionalKey(DocumentEmbeddingsCore),
|
||||
});
|
||||
export type KnowledgeResponse = typeof KnowledgeResponse.Type;
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type { EmbeddingsRequest, EmbeddingsResponse } from "../schema/messages.j
|
|||
import { ConsumerSpec } from "../spec/consumer-spec.js";
|
||||
import { ParameterSpec } from "../spec/parameter-spec.js";
|
||||
import { ProducerSpec } from "../spec/producer-spec.js";
|
||||
import type { Spec } from "../spec/types.js";
|
||||
|
||||
export interface EmbeddingsServiceShape {
|
||||
readonly embed: (
|
||||
|
|
@ -30,53 +31,55 @@ export class Embeddings extends Context.Service<Embeddings, EmbeddingsServiceSha
|
|||
"@trustgraph/base/services/embeddings-service/Embeddings",
|
||||
) {}
|
||||
|
||||
const onEmbeddingsRequest = Effect.fn("EmbeddingsService.onRequest")(function* (
|
||||
msg: EmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<Embeddings>,
|
||||
): Effect.fn.Return<void, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<EmbeddingsResponse>("embeddings-response");
|
||||
const embeddings = yield* Embeddings;
|
||||
const response = yield* embeddings.embed(msg.text, msg.model).pipe(
|
||||
Effect.map((vectors) => ({ vectors }) satisfies EmbeddingsResponse),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[EmbeddingsService] Error processing request", {
|
||||
error: errorMessage(error),
|
||||
operation: error.operation,
|
||||
provider: error.provider ?? "unknown",
|
||||
}).pipe(
|
||||
Effect.as({
|
||||
vectors: [],
|
||||
error: {
|
||||
type: "embeddings-error",
|
||||
message: errorMessage(error),
|
||||
},
|
||||
} satisfies EmbeddingsResponse),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
yield* responseProducer.send(requestId, response);
|
||||
});
|
||||
|
||||
export const makeEmbeddingsSpecs = (): ReadonlyArray<Spec<Embeddings>> => [
|
||||
new ConsumerSpec<EmbeddingsRequest, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings>(
|
||||
"embeddings-request",
|
||||
onEmbeddingsRequest,
|
||||
),
|
||||
new ProducerSpec<EmbeddingsResponse>("embeddings-response"),
|
||||
new ParameterSpec("model"),
|
||||
];
|
||||
|
||||
export class EmbeddingsService extends FlowProcessor<Embeddings> {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<EmbeddingsRequest, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings>(
|
||||
"embeddings-request",
|
||||
this.onRequestEffect.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<EmbeddingsResponse>("embeddings-response"));
|
||||
this.registerSpecification(new ParameterSpec("model"));
|
||||
}
|
||||
|
||||
private onRequestEffect(
|
||||
msg: EmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<Embeddings>,
|
||||
): Effect.Effect<void, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
return Effect.void;
|
||||
for (const spec of makeEmbeddingsSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<EmbeddingsResponse>("embeddings-response");
|
||||
const embeddings = yield* Embeddings;
|
||||
const response = yield* embeddings.embed(msg.text, msg.model).pipe(
|
||||
Effect.map((vectors) => ({ vectors }) satisfies EmbeddingsResponse),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[EmbeddingsService] Error processing request", {
|
||||
error: errorMessage(error),
|
||||
operation: error.operation,
|
||||
provider: error.provider ?? "unknown",
|
||||
}).pipe(
|
||||
Effect.as({
|
||||
vectors: [],
|
||||
error: {
|
||||
type: "embeddings-error",
|
||||
message: errorMessage(error),
|
||||
},
|
||||
} satisfies EmbeddingsResponse),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
yield* responseProducer.send(requestId, response);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
export { LlmService } from "./llm-service.js";
|
||||
export {
|
||||
Llm,
|
||||
LlmService,
|
||||
LlmServiceError,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type LlmProvider,
|
||||
type LlmServiceShape,
|
||||
} from "./llm-service.js";
|
||||
export {
|
||||
Embeddings,
|
||||
EmbeddingsService,
|
||||
makeEmbeddingsSpecs,
|
||||
type EmbeddingsServiceShape,
|
||||
} from "./embeddings-service.js";
|
||||
|
|
|
|||
|
|
@ -1,125 +1,247 @@
|
|||
/**
|
||||
* Base LLM service — handles message plumbing, subclasses implement the LLM call.
|
||||
* Base LLM capability contract and message-bus adapter.
|
||||
*
|
||||
* Python reference: trustgraph-base/trustgraph/base/llm_service.py
|
||||
*/
|
||||
|
||||
import {FlowProcessor} from "../processor/index.js";
|
||||
import { Context, Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
import {
|
||||
ConsumerSpec, ProducerSpec,
|
||||
ParameterSpec
|
||||
} from "../spec/index.js";
|
||||
import type {ProcessorConfig} from "../processor/index.js";
|
||||
import type {FlowContext} from "../messaging/consumer.js";
|
||||
errorMessage,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
} from "../errors.js";
|
||||
import type { FlowContext } from "../messaging/consumer.js";
|
||||
import { FlowProcessor } from "../processor/flow-processor.js";
|
||||
import type { ProcessorConfig } from "../processor/async-processor.js";
|
||||
import type {
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
} from "../schema/messages.js";
|
||||
import type {LlmResult, LlmChunk} from "../schema/index.js";
|
||||
import type { LlmChunk, LlmResult } from "../schema/primitives.js";
|
||||
import { ConsumerSpec } from "../spec/consumer-spec.js";
|
||||
import { ParameterSpec } from "../spec/parameter-spec.js";
|
||||
import { ProducerSpec } from "../spec/producer-spec.js";
|
||||
import type { Spec } from "../spec/types.js";
|
||||
|
||||
export abstract class LlmService extends FlowProcessor {
|
||||
protected constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
export class LlmServiceError extends S.TaggedErrorClass<LlmServiceError>()(
|
||||
"LlmServiceError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<TextCompletionRequest>(
|
||||
"text-completion-request",
|
||||
this.onRequest.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<TextCompletionResponse>("text-completion-response"));
|
||||
this.registerSpecification(new ParameterSpec("model"));
|
||||
this.registerSpecification(new ParameterSpec("temperature"));
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: TextCompletionRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("text-completion-response");
|
||||
|
||||
try {
|
||||
if (msg.streaming === true && this.supportsStreaming()) {
|
||||
for await (const chunk of this.generateContentStream(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
)) {
|
||||
const response = {
|
||||
response: chunk.text,
|
||||
...(chunk.model !== undefined ? { model: chunk.model } : {}),
|
||||
...(chunk.inToken !== null ? { inToken: chunk.inToken } : {}),
|
||||
...(chunk.outToken !== null ? { outToken: chunk.outToken } : {}),
|
||||
endOfStream: chunk.isFinal,
|
||||
};
|
||||
await responseProducer.send(
|
||||
requestId,
|
||||
response
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const result = await this.generateContent(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
);
|
||||
const response = {
|
||||
response: result.text,
|
||||
...(result.model !== undefined ? { model: result.model } : {}),
|
||||
...(result.inToken !== undefined ? { inToken: result.inToken } : {}),
|
||||
...(result.outToken !== undefined ? { outToken: result.outToken } : {}),
|
||||
endOfStream: true,
|
||||
};
|
||||
|
||||
await responseProducer.send(
|
||||
requestId,
|
||||
response
|
||||
);
|
||||
}
|
||||
} 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract generateContent(
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
): Promise<LlmResult>;
|
||||
|
||||
abstract generateContentStream(
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
): AsyncGenerator<LlmChunk>;
|
||||
|
||||
supportsStreaming(): boolean {
|
||||
return false;
|
||||
}
|
||||
export interface LlmProvider {
|
||||
readonly generateContent: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => Promise<LlmResult>;
|
||||
readonly generateContentStream: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => AsyncGenerator<LlmChunk>;
|
||||
readonly supportsStreaming: () => boolean;
|
||||
}
|
||||
|
||||
export interface LlmServiceShape {
|
||||
readonly generateContent: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => Effect.Effect<LlmResult, LlmServiceError>;
|
||||
readonly generateContentStream: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => AsyncGenerator<LlmChunk>;
|
||||
readonly supportsStreaming: () => boolean;
|
||||
}
|
||||
|
||||
export class Llm extends Context.Service<Llm, LlmServiceShape>()(
|
||||
"@trustgraph/base/services/llm-service/Llm",
|
||||
) {}
|
||||
|
||||
const llmServiceError = (operation: string, cause: unknown) =>
|
||||
new LlmServiceError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
});
|
||||
|
||||
export const makeLlmServiceShape = (provider: LlmProvider): LlmServiceShape => ({
|
||||
generateContent: Effect.fn("Llm.generateContent")((
|
||||
system,
|
||||
prompt,
|
||||
model,
|
||||
temperature,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => provider.generateContent(system, prompt, model, temperature),
|
||||
catch: (cause) => llmServiceError("generate-content", cause),
|
||||
}),
|
||||
),
|
||||
generateContentStream: (
|
||||
system,
|
||||
prompt,
|
||||
model,
|
||||
temperature,
|
||||
) => provider.generateContentStream(system, prompt, model, temperature),
|
||||
supportsStreaming: () => provider.supportsStreaming(),
|
||||
});
|
||||
|
||||
type LlmHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError;
|
||||
|
||||
const resultToResponse = (result: LlmResult): TextCompletionResponse => ({
|
||||
response: result.text,
|
||||
model: result.model,
|
||||
inToken: result.inToken,
|
||||
outToken: result.outToken,
|
||||
endOfStream: true,
|
||||
});
|
||||
|
||||
const chunkToResponse = (chunk: LlmChunk): TextCompletionResponse => ({
|
||||
response: chunk.text,
|
||||
model: chunk.model,
|
||||
...(chunk.inToken !== null ? { inToken: chunk.inToken } : {}),
|
||||
...(chunk.outToken !== null ? { outToken: chunk.outToken } : {}),
|
||||
endOfStream: chunk.isFinal,
|
||||
});
|
||||
|
||||
const llmErrorResponse = (error: LlmServiceError): TextCompletionResponse => ({
|
||||
response: "",
|
||||
error: {
|
||||
type: "llm-error",
|
||||
message: error.message,
|
||||
},
|
||||
endOfStream: true,
|
||||
});
|
||||
|
||||
const sendStreamingResponse = Effect.fn("LlmService.sendStreamingResponse")(function* (
|
||||
llm: LlmServiceShape,
|
||||
requestId: string,
|
||||
msg: TextCompletionRequest,
|
||||
responseProducer: {
|
||||
readonly send: (
|
||||
id: string,
|
||||
message: TextCompletionResponse,
|
||||
) => Effect.Effect<void, MessagingDeliveryError>;
|
||||
},
|
||||
) {
|
||||
const context = yield* Effect.context<never>();
|
||||
yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
for await (const chunk of llm.generateContentStream(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
)) {
|
||||
await Effect.runPromiseWith(context)(
|
||||
responseProducer.send(requestId, chunkToResponse(chunk)),
|
||||
);
|
||||
}
|
||||
},
|
||||
catch: (cause) => llmServiceError("generate-content-stream", cause),
|
||||
});
|
||||
});
|
||||
|
||||
const onLlmRequest = Effect.fn("LlmService.onRequest")(function* (
|
||||
msg: TextCompletionRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<Llm>,
|
||||
): Effect.fn.Return<void, LlmHandlerError, Llm> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<TextCompletionResponse>(
|
||||
"text-completion-response",
|
||||
);
|
||||
const llm = yield* Llm;
|
||||
|
||||
if (msg.streaming === true && llm.supportsStreaming()) {
|
||||
yield* sendStreamingResponse(llm, requestId, msg, responseProducer).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[LlmService] Error processing streaming request", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
responseProducer.send(requestId, llmErrorResponse(error)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = yield* llm.generateContent(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
).pipe(
|
||||
Effect.map(resultToResponse),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[LlmService] Error processing request", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.as(llmErrorResponse(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
yield* responseProducer.send(requestId, response);
|
||||
});
|
||||
|
||||
export const makeLlmSpecs = (): ReadonlyArray<Spec<Llm>> => [
|
||||
new ConsumerSpec<TextCompletionRequest, LlmHandlerError, Llm>(
|
||||
"text-completion-request",
|
||||
onLlmRequest,
|
||||
),
|
||||
new ProducerSpec<TextCompletionResponse>("text-completion-response"),
|
||||
new ParameterSpec("model"),
|
||||
new ParameterSpec("temperature"),
|
||||
];
|
||||
|
||||
export abstract class LlmService extends FlowProcessor<Llm> implements LlmProvider {
|
||||
protected constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
for (const spec of makeLlmSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
}
|
||||
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(Llm, Llm.of(makeLlmServiceShape(this))),
|
||||
);
|
||||
}
|
||||
|
||||
abstract generateContent(
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
): Promise<LlmResult>;
|
||||
|
||||
abstract generateContentStream(
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
): AsyncGenerator<LlmChunk>;
|
||||
|
||||
supportsStreaming(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,18 @@
|
|||
"@trustgraph/base": "workspace:*",
|
||||
"@trustgraph/client": "workspace:*",
|
||||
"commander": "^13.1.0",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ program
|
|||
.name("tg")
|
||||
.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("-g, --gateway <url>", "Gateway WebSocket URL", "ws://localhost:8088/api/v1/rpc")
|
||||
.option("-u, --user <id>", "User identifier", "cli")
|
||||
.option("-t, --token <token>", "Authentication token")
|
||||
.option("-f, --flow <id>", "Flow ID", "default");
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"test": "bunx --bun vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.65"
|
||||
"effect": "4.0.0-beta.74"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.0.0"
|
||||
|
|
@ -23,9 +23,10 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6",
|
||||
"happy-dom": "^20.0.0"
|
||||
|
|
|
|||
|
|
@ -1,285 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
195
ts/packages/client/src/__tests__/workbench-contracts.test.ts
Normal file
195
ts/packages/client/src/__tests__/workbench-contracts.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BaseApi,
|
||||
ConfigApi,
|
||||
KnowledgeApi,
|
||||
LibrarianApi,
|
||||
} from "../socket/trustgraph-socket";
|
||||
|
||||
function makeApi() {
|
||||
const makeRequest = vi.fn();
|
||||
const base = {
|
||||
user: "alice",
|
||||
makeRequest,
|
||||
} as unknown as BaseApi;
|
||||
return { base, makeRequest };
|
||||
}
|
||||
|
||||
describe("workbench API contracts", () => {
|
||||
describe("ConfigApi", () => {
|
||||
it("returns Python-style getvalues entries", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({
|
||||
values: [{ type: "prompt", key: "welcome", value: "hello" }],
|
||||
});
|
||||
|
||||
const result = await new ConfigApi(base).getValues("prompt");
|
||||
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
"config",
|
||||
{ operation: "getvalues", type: "prompt" },
|
||||
60000,
|
||||
);
|
||||
expect(result).toEqual([{ type: "prompt", key: "welcome", value: "hello" }]);
|
||||
});
|
||||
|
||||
it("parses token-cost values stored as config JSON strings", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({
|
||||
values: [
|
||||
{
|
||||
type: "token-cost",
|
||||
key: "gpt-test",
|
||||
value: JSON.stringify({ input_price: 0.1, output_price: 0.2 }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await new ConfigApi(base).getTokenCosts();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ model: "gpt-test", input_price: 0.1, output_price: 0.2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes and deletes config using Python-style key/value arrays", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({});
|
||||
const config = new ConfigApi(base);
|
||||
|
||||
await config.putConfig([{ type: "tool", key: "search", value: "{}" }]);
|
||||
await config.deleteConfig({ type: "tool", key: "search" });
|
||||
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
values: [{ type: "tool", key: "search", value: "{}" }],
|
||||
},
|
||||
60000,
|
||||
);
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [{ type: "tool", key: "search" }],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LibrarianApi", () => {
|
||||
it("reads Python-style document and processing list responses", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
const document = { id: "doc-1", title: "Document" };
|
||||
const processing = { id: "proc-1", "document-id": "doc-1" };
|
||||
const librarian = new LibrarianApi(base);
|
||||
|
||||
makeRequest
|
||||
.mockResolvedValueOnce({ "document-metadatas": [document] })
|
||||
.mockResolvedValueOnce({ "processing-metadatas": [processing] });
|
||||
|
||||
await expect(librarian.getDocuments()).resolves.toEqual([document]);
|
||||
await expect(librarian.getProcessing()).resolves.toEqual([processing]);
|
||||
});
|
||||
|
||||
it("sends both kebab-case and camel-case document identifiers", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
const document = { id: "doc-1", title: "Document" };
|
||||
makeRequest.mockResolvedValue({ "document-metadata": document });
|
||||
|
||||
const result = await new LibrarianApi(base).getDocumentMetadata("doc-1");
|
||||
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
"librarian",
|
||||
{
|
||||
operation: "get-document-metadata",
|
||||
"document-id": "doc-1",
|
||||
documentId: "doc-1",
|
||||
user: "alice",
|
||||
},
|
||||
30000,
|
||||
);
|
||||
expect(result).toEqual(document);
|
||||
});
|
||||
|
||||
it("uploads documents with Python and TypeScript metadata aliases", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({});
|
||||
|
||||
await new LibrarianApi(base).loadDocument(
|
||||
"SGVsbG8=",
|
||||
"text/plain",
|
||||
"Hello",
|
||||
"comment",
|
||||
["tag"],
|
||||
"doc-1",
|
||||
);
|
||||
|
||||
const request = makeRequest.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(request["document-metadata"]).toMatchObject({
|
||||
id: "doc-1",
|
||||
kind: "text/plain",
|
||||
title: "Hello",
|
||||
user: "alice",
|
||||
"document-type": "source",
|
||||
documentType: "source",
|
||||
});
|
||||
expect(request.documentMetadata).toEqual(request["document-metadata"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KnowledgeApi", () => {
|
||||
it("lists and loads document embedding cores", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
const knowledge = new KnowledgeApi(base);
|
||||
|
||||
makeRequest
|
||||
.mockResolvedValueOnce({ ids: ["de-core"] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await expect(knowledge.getDocumentEmbeddingCores()).resolves.toEqual(["de-core"]);
|
||||
await knowledge.loadDeCore("de-core", "default", "library");
|
||||
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"knowledge",
|
||||
{ operation: "list-de-cores", user: "alice" },
|
||||
60000,
|
||||
);
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"knowledge",
|
||||
{
|
||||
operation: "load-de-core",
|
||||
id: "de-core",
|
||||
flow: "default",
|
||||
user: "alice",
|
||||
collection: "library",
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
|
||||
it("unloads knowledge graph cores from a flow", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({});
|
||||
|
||||
await new KnowledgeApi(base).unloadKgCore("kg-core", "default");
|
||||
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "unload-kg-core",
|
||||
id: "kg-core",
|
||||
flow: "default",
|
||||
user: "alice",
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ export * from "./models/namespaces.js";
|
|||
|
||||
// Export socket client
|
||||
export * from "./socket/trustgraph-socket.js";
|
||||
export * from "./rpc/contract.js";
|
||||
|
||||
// Export WebSocket adapter (isomorphic helpers and types)
|
||||
export * from "./socket/websocket-adapter.js";
|
||||
|
|
|
|||
|
|
@ -280,12 +280,16 @@ export interface DocumentMetadata {
|
|||
metadata?: Triple[];
|
||||
user?: string;
|
||||
tags?: string[];
|
||||
parentId?: string;
|
||||
documentType?: string;
|
||||
"parent-id"?: string;
|
||||
"document-type"?: string;
|
||||
}
|
||||
|
||||
export interface ProcessingMetadata {
|
||||
id?: string;
|
||||
"document-id"?: string;
|
||||
documentId?: string;
|
||||
time?: number;
|
||||
flow?: string;
|
||||
user?: string;
|
||||
|
|
@ -295,7 +299,9 @@ export interface ProcessingMetadata {
|
|||
|
||||
export interface LibraryRequest {
|
||||
operation: string;
|
||||
documentId?: string;
|
||||
"document-id"?: string;
|
||||
processingId?: string;
|
||||
"processing-id"?: string;
|
||||
"document-metadata"?: DocumentMetadata;
|
||||
documentMetadata?: DocumentMetadata;
|
||||
|
|
@ -309,12 +315,15 @@ export interface LibraryRequest {
|
|||
}
|
||||
|
||||
export interface LibraryResponse {
|
||||
error: Error;
|
||||
error?: Error;
|
||||
"document-metadata"?: DocumentMetadata;
|
||||
documentMetadata?: DocumentMetadata;
|
||||
content?: string;
|
||||
"document-metadatas"?: DocumentMetadata[];
|
||||
documents?: DocumentMetadata[];
|
||||
"processing-metadata"?: ProcessingMetadata;
|
||||
"processing-metadatas"?: ProcessingMetadata[];
|
||||
processing?: ProcessingMetadata[];
|
||||
}
|
||||
|
||||
export interface KnowledgeRequest {
|
||||
|
|
@ -325,6 +334,9 @@ export interface KnowledgeRequest {
|
|||
collection?: string;
|
||||
triples?: Triple[];
|
||||
"graph-embeddings"?: GraphEmbeddings;
|
||||
graphEmbeddings?: GraphEmbeddings;
|
||||
"document-embeddings"?: unknown;
|
||||
documentEmbeddings?: unknown;
|
||||
}
|
||||
|
||||
export interface KnowledgeResponse {
|
||||
|
|
@ -333,6 +345,9 @@ export interface KnowledgeResponse {
|
|||
eos?: boolean;
|
||||
triples?: Triple[];
|
||||
"graph-embeddings"?: GraphEmbeddings;
|
||||
graphEmbeddings?: GraphEmbeddings;
|
||||
"document-embeddings"?: unknown;
|
||||
documentEmbeddings?: unknown;
|
||||
}
|
||||
|
||||
export interface FlowRequest {
|
||||
|
|
|
|||
35
ts/packages/client/src/rpc/contract.ts
Normal file
35
ts/packages/client/src/rpc/contract.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Schema as S } from "effect";
|
||||
import * as Rpc from "effect/unstable/rpc/Rpc";
|
||||
import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
|
||||
|
||||
export class DispatchPayload extends S.Class<DispatchPayload>("DispatchPayload")({
|
||||
scope: S.Literals(["global", "flow"]),
|
||||
service: S.String,
|
||||
flow: S.optionalKey(S.String),
|
||||
request: S.Record(S.String, S.Unknown),
|
||||
}) {}
|
||||
|
||||
export class DispatchStreamChunk extends S.Class<DispatchStreamChunk>("DispatchStreamChunk")({
|
||||
response: S.Unknown,
|
||||
complete: S.Boolean,
|
||||
}) {}
|
||||
|
||||
export class DispatchError extends S.ErrorClass<DispatchError>("DispatchError")({
|
||||
_tag: S.tag("DispatchError"),
|
||||
message: S.String,
|
||||
}) {}
|
||||
|
||||
export class Dispatch extends Rpc.make("Dispatch", {
|
||||
payload: DispatchPayload,
|
||||
success: S.Unknown,
|
||||
error: DispatchError,
|
||||
}) {}
|
||||
|
||||
export class DispatchStream extends Rpc.make("DispatchStream", {
|
||||
payload: DispatchPayload,
|
||||
success: DispatchStreamChunk,
|
||||
error: DispatchError,
|
||||
stream: true,
|
||||
}) {}
|
||||
|
||||
export const TrustGraphRpcs = RpcGroup.make(Dispatch, DispatchStream);
|
||||
192
ts/packages/client/src/socket/effect-rpc-client.ts
Normal file
192
ts/packages/client/src/socket/effect-rpc-client.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { Context, Data, Effect, Exit, Layer, Scope, Stream } from "effect";
|
||||
import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";
|
||||
import * as RpcClient from "effect/unstable/rpc/RpcClient";
|
||||
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as Socket from "effect/unstable/socket/Socket";
|
||||
import { DispatchPayload, DispatchError, TrustGraphRpcs, type DispatchStreamChunk } from "../rpc/contract.js";
|
||||
|
||||
type TrustGraphRpcClient = RpcClient.RpcClient<
|
||||
RpcGroup.Rpcs<typeof TrustGraphRpcs>,
|
||||
RpcClientError
|
||||
>;
|
||||
|
||||
class TrustGraphRpcClientService extends Context.Service<
|
||||
TrustGraphRpcClientService,
|
||||
TrustGraphRpcClient
|
||||
>()("@trustgraph/client/socket/effect-rpc-client/TrustGraphRpcClientService") {}
|
||||
|
||||
export type RpcConnectionStatus = "connecting" | "connected" | "failed" | "closed";
|
||||
|
||||
export interface RpcConnectionState {
|
||||
status: RpcConnectionStatus;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface DispatchInput {
|
||||
scope: "global" | "flow";
|
||||
service: string;
|
||||
flow?: string;
|
||||
request: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class EffectRpcClient {
|
||||
private readonly url: string;
|
||||
private readonly onConnect: (() => void) | undefined;
|
||||
private readonly onDisconnect: (() => void) | undefined;
|
||||
private readonly scopePromise: Promise<Scope.Scope>;
|
||||
private readonly clientPromise: Promise<TrustGraphRpcClient>;
|
||||
private readonly listeners = new Set<(state: RpcConnectionState) => void>();
|
||||
private state: RpcConnectionState = { status: "connecting" };
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
onConnect?: () => void,
|
||||
onDisconnect?: () => void,
|
||||
) {
|
||||
this.url = url;
|
||||
this.onConnect = onConnect;
|
||||
this.onDisconnect = onDisconnect;
|
||||
this.scopePromise = Effect.runPromise(Scope.make());
|
||||
this.clientPromise = this.scopePromise.then((scope) =>
|
||||
Effect.runPromise(this.makeClient().pipe(Scope.provide(scope))),
|
||||
);
|
||||
this.clientPromise.catch((cause) => {
|
||||
this.setState({
|
||||
status: "failed",
|
||||
lastError: errorMessage(cause),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(listener: (state: RpcConnectionState) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.state);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async dispatch(input: DispatchInput): Promise<unknown> {
|
||||
const client = await this.clientPromise;
|
||||
return await Effect.runPromise(client.Dispatch(new DispatchPayload(input)));
|
||||
}
|
||||
|
||||
async dispatchStream(
|
||||
input: DispatchInput,
|
||||
receiver: (chunk: DispatchStreamChunk) => boolean,
|
||||
): Promise<DispatchStreamChunk | undefined> {
|
||||
const client = await this.clientPromise;
|
||||
let last: DispatchStreamChunk | undefined;
|
||||
await Effect.runPromise(
|
||||
client.DispatchStream(new DispatchPayload(input)).pipe(
|
||||
Stream.runForEach((chunk) =>
|
||||
Effect.suspend(() => {
|
||||
last = chunk;
|
||||
if (receiver(chunk)) return Effect.fail(new StopStreaming());
|
||||
return Effect.void;
|
||||
}),
|
||||
),
|
||||
Effect.catchIf(
|
||||
(cause): cause is StopStreaming => cause instanceof StopStreaming,
|
||||
() => Effect.void,
|
||||
),
|
||||
),
|
||||
);
|
||||
return last;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.setState({ status: "closed" });
|
||||
const scope = await this.scopePromise;
|
||||
await Effect.runPromise(Scope.close(scope, Exit.void));
|
||||
}
|
||||
|
||||
private makeClient(): Effect.Effect<TrustGraphRpcClient, never, Scope.Scope> {
|
||||
const socketLayer = Layer.effect(
|
||||
Socket.Socket,
|
||||
Socket.makeWebSocket(this.url, {
|
||||
closeCodeIsError: (code) => code !== 1000,
|
||||
openTimeout: "10 seconds",
|
||||
}),
|
||||
).pipe(Layer.provide(webSocketConstructorLayer));
|
||||
|
||||
const hooksLayer = Layer.succeed(
|
||||
RpcClient.ConnectionHooks,
|
||||
RpcClient.ConnectionHooks.of({
|
||||
onConnect: Effect.sync(() => {
|
||||
this.setState({ status: "connected" });
|
||||
this.onConnect?.();
|
||||
}),
|
||||
onDisconnect: Effect.sync(() => {
|
||||
if (!this.closed) {
|
||||
this.setState({
|
||||
status: "connecting",
|
||||
lastError: "Disconnected from gateway",
|
||||
});
|
||||
}
|
||||
this.onDisconnect?.();
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const protocolLayer = RpcClient.layerProtocolSocket({
|
||||
retryTransientErrors: true,
|
||||
}).pipe(
|
||||
Layer.provide(socketLayer),
|
||||
Layer.provide(RpcSerialization.layerNdjson),
|
||||
Layer.provide(hooksLayer),
|
||||
);
|
||||
|
||||
const clientLayer = Layer.effect(
|
||||
TrustGraphRpcClientService,
|
||||
RpcClient.make(TrustGraphRpcs),
|
||||
).pipe(Layer.provide(protocolLayer));
|
||||
|
||||
return Effect.map(
|
||||
Layer.build(clientLayer),
|
||||
(context) => Context.get(context, TrustGraphRpcClientService),
|
||||
);
|
||||
}
|
||||
|
||||
private setState(state: RpcConnectionState): void {
|
||||
this.state = state;
|
||||
for (const listener of this.listeners) {
|
||||
listener(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StopStreaming extends Data.TaggedError("StopStreaming")<{}> {}
|
||||
|
||||
const webSocketConstructorLayer: Layer.Layer<Socket.WebSocketConstructor> = Layer.effect(
|
||||
Socket.WebSocketConstructor,
|
||||
Effect.promise(async () => {
|
||||
if (typeof globalThis !== "undefined" && "WebSocket" in globalThis) {
|
||||
return (url, protocols) => new globalThis.WebSocket(url, protocols);
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await import("ws");
|
||||
const WS = mod.WebSocket;
|
||||
return (url, protocols) => new WS(url, protocols) as unknown as globalThis.WebSocket;
|
||||
} catch (cause) {
|
||||
throw new DispatchError({
|
||||
message: `WebSocket is not available: ${errorMessage(cause)}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
function errorMessage(cause: unknown): string {
|
||||
if (cause instanceof Error) return cause.message;
|
||||
if (typeof cause === "string") return cause;
|
||||
if (cause !== null && typeof cause === "object" && "message" in cause) {
|
||||
const message = (cause as { message?: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return String(cause);
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import type { 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 | null | undefined;
|
||||
inflight: {
|
||||
[key: string]: {
|
||||
onReceived: (resp: object) => void;
|
||||
retryNow: () => void;
|
||||
error: (err: object | string) => void;
|
||||
};
|
||||
};
|
||||
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> | undefined = undefined;
|
||||
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 !== null && this.socket.ws !== undefined && 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 !== null &&
|
||||
this.socket.ws !== undefined &&
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
import type { 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 | null | undefined;
|
||||
inflight: {
|
||||
[key: string]: {
|
||||
onReceived: (resp: object) => void;
|
||||
retryNow: () => void;
|
||||
error: (err: object | string) => void;
|
||||
};
|
||||
};
|
||||
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> | undefined = undefined; // 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) {
|
||||
// Guard: ignore duplicate responses after completion
|
||||
if (this.complete) {
|
||||
console.log(this.mid, "should not happen, request is already complete");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 !== null && typeof resp === "object" && "error" in resp) {
|
||||
errorToHandle = (resp as Record<string, unknown>).error;
|
||||
}
|
||||
// Check for nested error under response property
|
||||
else if (resp !== null && typeof resp === "object" && "response" in resp) {
|
||||
const response = (resp as Record<string, unknown>).response;
|
||||
if (response !== null && typeof response === "object" && "error" in response) {
|
||||
errorToHandle = (response as Record<string, unknown>).error;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorToHandle !== null && errorToHandle !== undefined) {
|
||||
// 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() {
|
||||
// Guard: ignore timeout after completion
|
||||
if (this.complete) {
|
||||
console.log(
|
||||
this.mid,
|
||||
"timeout should not happen, request is already complete",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
// Guard: don't retry completed requests
|
||||
if (this.complete) {
|
||||
console.log(
|
||||
this.mid,
|
||||
"attempt should not be called, request is already complete",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 !== null && this.socket.ws !== undefined && 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,7 @@
|
|||
// Import core types and classes for the TrustGraph API
|
||||
import type { Term, Triple } from "../models/Triple.js";
|
||||
import { ServiceCallMulti } from "./service-call-multi.js";
|
||||
import { ServiceCall } from "./service-call.js";
|
||||
import {
|
||||
getWebSocketConstructor,
|
||||
getDefaultSocketUrl,
|
||||
getRandomValues,
|
||||
WS_CONNECTING,
|
||||
WS_OPEN,
|
||||
WS_CLOSED,
|
||||
type IsomorphicWebSocket,
|
||||
type WsMessageEvent,
|
||||
type WsCloseEvent,
|
||||
type WsEvent,
|
||||
} from "./websocket-adapter.js";
|
||||
import { EffectRpcClient, type DispatchInput, type RpcConnectionState } from "./effect-rpc-client.js";
|
||||
import { getDefaultSocketUrl, getRandomValues } from "./websocket-adapter.js";
|
||||
|
||||
// Import all message types for different services
|
||||
import type {
|
||||
|
|
@ -51,7 +39,6 @@ import type {
|
|||
PromptRequest,
|
||||
PromptResponse,
|
||||
// ProcessingMetadata,
|
||||
RequestMessage,
|
||||
ResponseError,
|
||||
StructuredQueryRequest,
|
||||
StructuredQueryResponse,
|
||||
|
|
@ -107,8 +94,6 @@ export interface ExplainEvent {
|
|||
}
|
||||
|
||||
// Configuration constants
|
||||
const SOCKET_RECONNECTION_TIMEOUT = 2000; // 2 seconds between reconnection
|
||||
// attempts
|
||||
const SOCKET_URL = getDefaultSocketUrl(); // WebSocket endpoint path (isomorphic)
|
||||
|
||||
function isNonEmptyString(value: string | undefined): value is string {
|
||||
|
|
@ -165,6 +150,38 @@ function throwIfResponseError(error: ResponseError | undefined): void {
|
|||
}
|
||||
}
|
||||
|
||||
interface ConfigValueEntry {
|
||||
workspace?: string;
|
||||
type?: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function asConfigValues(response: unknown): ConfigValueEntry[] {
|
||||
if (response === null || typeof response !== "object") return [];
|
||||
const values = (response as { values?: unknown }).values;
|
||||
if (!Array.isArray(values)) return [];
|
||||
return values.flatMap((value) => {
|
||||
if (value === null || typeof value !== "object") return [];
|
||||
const item = value as Record<string, unknown>;
|
||||
const key = item.key;
|
||||
if (typeof key !== "string") return [];
|
||||
const entry: ConfigValueEntry = { key, value: item.value };
|
||||
if (typeof item.workspace === "string") entry.workspace = item.workspace;
|
||||
if (typeof item.type === "string") entry.type = item.type;
|
||||
return [entry];
|
||||
});
|
||||
}
|
||||
|
||||
function parseConfigJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket interface defining all available operations for the TrustGraph API
|
||||
* This provides a unified interface for various AI/ML and knowledge graph
|
||||
|
|
@ -297,22 +314,17 @@ export interface ConnectionState {
|
|||
}
|
||||
|
||||
export class BaseApi {
|
||||
ws: IsomorphicWebSocket | undefined = undefined; // WebSocket connection instance
|
||||
tag: string; // Unique client identifier
|
||||
id: number; // Counter for generating unique message IDs
|
||||
token: string | undefined; // Optional authentication token
|
||||
user: string; // User identifier for API requests
|
||||
socketUrl: string; // WebSocket URL
|
||||
inflight: { [key: string]: ServiceCall | ServiceCallMulti } = {}; // Track active requests by
|
||||
// message ID
|
||||
reconnectAttempts: number = 0; // Track reconnection attempts
|
||||
maxReconnectAttempts: number = 10; // Maximum reconnection attempts
|
||||
reconnectTimer: number | undefined = undefined; // Timer for reconnection attempts
|
||||
reconnectionState: "idle" | "reconnecting" | "failed" = "idle"; // Connection state
|
||||
private readonly rpc: EffectRpcClient;
|
||||
|
||||
// Connection state tracking for UI
|
||||
private connectionStateListeners: ((state: ConnectionState) => void)[] = [];
|
||||
private lastError: string | undefined = undefined;
|
||||
private rpcState: RpcConnectionState = { status: "connecting" };
|
||||
|
||||
constructor(user: string, token?: string, socketUrl?: string) {
|
||||
this.tag = makeid(16); // Generate unique client tag
|
||||
|
|
@ -320,6 +332,12 @@ export class BaseApi {
|
|||
this.token = token; // Store authentication token
|
||||
this.user = user; // Store user identifier
|
||||
this.socketUrl = withDefault(socketUrl, SOCKET_URL); // Use provided URL or default
|
||||
this.rpc = new EffectRpcClient(this.socketUrlWithToken());
|
||||
this.rpc.subscribe((state) => {
|
||||
this.rpcState = state;
|
||||
this.lastError = state.lastError;
|
||||
this.notifyStateChange();
|
||||
});
|
||||
|
||||
console.log(
|
||||
"SOCKET: opening socket...",
|
||||
|
|
@ -327,8 +345,6 @@ export class BaseApi {
|
|||
"user:",
|
||||
user,
|
||||
);
|
||||
this.openSocket(); // Establish WebSocket connection
|
||||
console.log("SOCKET: socket opened");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -353,25 +369,7 @@ export class BaseApi {
|
|||
*/
|
||||
private getConnectionState(): ConnectionState {
|
||||
const hasApiKey = isNonEmptyString(this.token);
|
||||
|
||||
// Determine status based on WebSocket state and reconnection state
|
||||
let status: ConnectionState["status"];
|
||||
|
||||
if (this.ws === undefined || this.ws.readyState === WS_CLOSED) {
|
||||
if (this.reconnectionState === "failed") {
|
||||
status = "failed";
|
||||
} else if (this.reconnectionState === "reconnecting") {
|
||||
status = "reconnecting";
|
||||
} else {
|
||||
status = "connecting";
|
||||
}
|
||||
} else if (this.ws.readyState === WS_CONNECTING) {
|
||||
status = "connecting";
|
||||
} else if (this.ws.readyState === WS_OPEN) {
|
||||
status = hasApiKey ? "authenticated" : "unauthenticated";
|
||||
} else {
|
||||
status = "connecting";
|
||||
}
|
||||
const status = this.connectionStatusFromRpc(hasApiKey);
|
||||
|
||||
const state: ConnectionState = {
|
||||
status,
|
||||
|
|
@ -381,12 +379,6 @@ export class BaseApi {
|
|||
state.lastError = this.lastError;
|
||||
}
|
||||
|
||||
// Add reconnection details if applicable
|
||||
if (status === "reconnecting") {
|
||||
state.reconnectAttempt = this.reconnectAttempts;
|
||||
state.maxAttempts = this.maxReconnectAttempts;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
|
|
@ -404,208 +396,13 @@ export class BaseApi {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes WebSocket connection and sets up event handlers
|
||||
*/
|
||||
openSocket() {
|
||||
// Don't create multiple connections
|
||||
if (
|
||||
this.ws !== undefined &&
|
||||
(this.ws.readyState === WS_CONNECTING ||
|
||||
this.ws.readyState === WS_OPEN)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up old socket if exists
|
||||
if (this.ws !== undefined) {
|
||||
this.ws.removeEventListener("message", this.onMessage);
|
||||
this.ws.removeEventListener("close", this.onClose);
|
||||
this.ws.removeEventListener("open", this.onOpen);
|
||||
this.ws.removeEventListener("error", this.onError);
|
||||
this.ws = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build WebSocket URL with optional token parameter
|
||||
const wsUrl = isNonEmptyString(this.token)
|
||||
? `${this.socketUrl}?token=${this.token}`
|
||||
: this.socketUrl;
|
||||
console.log(
|
||||
"SOCKET: connecting to",
|
||||
wsUrl.replace(/token=[^&]*/, "token=***"),
|
||||
);
|
||||
const WS = getWebSocketConstructor();
|
||||
this.ws = new WS(wsUrl);
|
||||
} catch (e) {
|
||||
console.error("[socket creation error]", e);
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Bind event handlers to maintain proper 'this' context
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onOpen = this.onOpen.bind(this);
|
||||
this.onError = this.onError.bind(this);
|
||||
|
||||
// Attach event listeners
|
||||
this.ws.addEventListener("message", this.onMessage);
|
||||
this.ws.addEventListener("close", this.onClose);
|
||||
this.ws.addEventListener("open", this.onOpen);
|
||||
this.ws.addEventListener("error", this.onError);
|
||||
}
|
||||
|
||||
// Handle incoming messages from server
|
||||
onMessage(message: WsMessageEvent) {
|
||||
if (message.data === undefined || message.data === null || message.data === "") return;
|
||||
|
||||
try {
|
||||
const obj: unknown = JSON.parse(String(message.data));
|
||||
|
||||
// Skip messages without ID (can't route them)
|
||||
if (obj === null || typeof obj !== "object" || !("id" in obj)) return;
|
||||
const id = (obj as { id?: unknown }).id;
|
||||
if (typeof id !== "string" || id.length === 0) return;
|
||||
|
||||
// Route response to the corresponding inflight request
|
||||
const call = this.inflight[id];
|
||||
if (call !== undefined) {
|
||||
// Pass the whole message object so receiver can access 'complete' flag
|
||||
call.onReceived(obj);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[socket message parse error]", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle connection closure - automatically attempt reconnection
|
||||
onClose(event: WsCloseEvent) {
|
||||
console.log("[socket close]", event.code, event.reason);
|
||||
this.lastError = `Connection closed: ${event.reason.length > 0 ? event.reason : "Unknown reason"}`;
|
||||
this.ws = undefined;
|
||||
this.notifyStateChange();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
// Handle successful connection
|
||||
onOpen(_event: WsEvent) {
|
||||
console.log("[socket open]");
|
||||
this.reconnectAttempts = 0; // Reset reconnection attempts on success
|
||||
this.reconnectionState = "idle"; // Reset connection state
|
||||
this.lastError = undefined; // Clear any previous errors
|
||||
|
||||
// Clear any pending reconnect timer
|
||||
if (this.reconnectTimer !== undefined) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
|
||||
// Notify UI of successful connection
|
||||
this.notifyStateChange();
|
||||
|
||||
// Immediately retry any pending requests that were waiting for connection
|
||||
for (const mid in this.inflight) {
|
||||
this.inflight[mid].retryNow();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle socket errors
|
||||
onError(event: WsEvent) {
|
||||
console.error("[socket error]", event);
|
||||
this.lastError = "Connection error occurred";
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a reconnection attempt with exponential backoff
|
||||
*/
|
||||
scheduleReconnect() {
|
||||
// Prevent concurrent reconnection attempts
|
||||
if (this.reconnectionState === "reconnecting") {
|
||||
console.log("[socket] Reconnection already in progress, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't schedule if already scheduled
|
||||
if (this.reconnectTimer !== undefined) return;
|
||||
|
||||
this.reconnectionState = "reconnecting";
|
||||
this.reconnectAttempts++;
|
||||
this.notifyStateChange(); // Notify UI of reconnection attempt
|
||||
|
||||
if (this.reconnectAttempts > this.maxReconnectAttempts) {
|
||||
console.error("[socket] Max reconnection attempts reached");
|
||||
this.reconnectionState = "failed";
|
||||
this.lastError = "Max reconnection attempts exceeded";
|
||||
this.notifyStateChange();
|
||||
// Notify all pending requests of the failure
|
||||
for (const mid in this.inflight) {
|
||||
this.inflight[mid].error(new Error("WebSocket connection failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate exponential backoff with jitter
|
||||
const backoffDelay = Math.min(
|
||||
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, this.reconnectAttempts - 1) +
|
||||
Math.random() * 1000,
|
||||
30000, // Max 30 seconds
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[socket] Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
|
||||
);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
this.reopen();
|
||||
}, backoffDelay) as unknown as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopens the WebSocket connection (used after connection failures)
|
||||
*/
|
||||
reopen() {
|
||||
console.log("[socket reopen]");
|
||||
// Check if we're already connected or connecting
|
||||
if (
|
||||
this.ws !== undefined &&
|
||||
(this.ws.readyState === WS_OPEN ||
|
||||
this.ws.readyState === WS_CONNECTING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.openSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the WebSocket connection and cleans up
|
||||
*/
|
||||
close() {
|
||||
// Clear reconnection timer
|
||||
if (this.reconnectTimer !== undefined) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
|
||||
// Clean up WebSocket
|
||||
if (this.ws !== undefined) {
|
||||
// Remove event listeners to prevent memory leaks
|
||||
this.ws.removeEventListener("message", this.onMessage);
|
||||
this.ws.removeEventListener("close", this.onClose);
|
||||
this.ws.removeEventListener("open", this.onOpen);
|
||||
this.ws.removeEventListener("error", this.onError);
|
||||
|
||||
this.ws.close();
|
||||
this.ws = undefined;
|
||||
}
|
||||
|
||||
// Clear any remaining inflight requests
|
||||
for (const mid in this.inflight) {
|
||||
this.inflight[mid].error(new Error("Socket closed"));
|
||||
}
|
||||
this.inflight = {};
|
||||
this.rpc.close().catch((err) => {
|
||||
console.error("[socket close error]", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -630,42 +427,11 @@ export class BaseApi {
|
|||
makeRequest<RequestType extends object, ResponseType>(
|
||||
service: string,
|
||||
request: RequestType,
|
||||
timeout?: number,
|
||||
retries?: number,
|
||||
_timeout?: number,
|
||||
_retries?: number,
|
||||
flow?: string,
|
||||
) {
|
||||
const mid = this.getNextId();
|
||||
|
||||
// Set default values
|
||||
if (timeout === undefined) timeout = 10000;
|
||||
if (retries === undefined) retries = 3;
|
||||
|
||||
// Construct the request message
|
||||
const msg: RequestMessage = {
|
||||
id: mid,
|
||||
service: service,
|
||||
request: request,
|
||||
};
|
||||
|
||||
// Add flow identifier if provided
|
||||
if (isNonEmptyString(flow)) msg.flow = flow;
|
||||
|
||||
// Return a Promise that will be resolved/rejected by the ServiceCall
|
||||
return new Promise<ResponseType>((resolve, reject) => {
|
||||
const call = new ServiceCall(
|
||||
mid,
|
||||
msg,
|
||||
resolve as (resp: unknown) => void,
|
||||
reject as (err: object | string) => void,
|
||||
timeout,
|
||||
retries,
|
||||
this,
|
||||
);
|
||||
|
||||
call.start();
|
||||
// Commented out debug logging: console.log("-->", msg);
|
||||
}).then((obj) => {
|
||||
// Commented out success logging: console.log("Success for", mid);
|
||||
return this.rpc.dispatch(this.dispatchInput(service, request, flow)).then((obj) => {
|
||||
return obj as ResponseType;
|
||||
});
|
||||
}
|
||||
|
|
@ -678,38 +444,12 @@ export class BaseApi {
|
|||
service: string,
|
||||
request: RequestType,
|
||||
receiver: (resp: unknown) => boolean, // Callback to handle each response chunk
|
||||
timeout?: number,
|
||||
retries?: number,
|
||||
_timeout?: number,
|
||||
_retries?: number,
|
||||
flow?: string,
|
||||
) {
|
||||
const mid = this.getNextId();
|
||||
|
||||
// Set defaults
|
||||
if (timeout === undefined) timeout = 10000;
|
||||
if (retries === undefined) retries = 3;
|
||||
|
||||
// Construct request message
|
||||
const msg: RequestMessage = {
|
||||
id: mid,
|
||||
service: service,
|
||||
request: request,
|
||||
};
|
||||
|
||||
if (isNonEmptyString(flow)) msg.flow = flow;
|
||||
|
||||
return new Promise<ResponseType>((resolve, reject) => {
|
||||
const call = new ServiceCallMulti(
|
||||
mid,
|
||||
msg,
|
||||
resolve as (resp: unknown) => void,
|
||||
reject as (err: object | string) => void,
|
||||
timeout,
|
||||
retries,
|
||||
this,
|
||||
receiver,
|
||||
);
|
||||
|
||||
call.start();
|
||||
return this.rpc.dispatchStream(this.dispatchInput(service, request, flow), (chunk) => {
|
||||
return receiver({ response: chunk.response, complete: chunk.complete });
|
||||
}).then((obj) => {
|
||||
return obj as ResponseType;
|
||||
});
|
||||
|
|
@ -737,6 +477,45 @@ export class BaseApi {
|
|||
);
|
||||
}
|
||||
|
||||
private connectionStatusFromRpc(hasApiKey: boolean): ConnectionState["status"] {
|
||||
switch (this.rpcState.status) {
|
||||
case "connected":
|
||||
return hasApiKey ? "authenticated" : "unauthenticated";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "closed":
|
||||
return "failed";
|
||||
case "connecting":
|
||||
return this.lastError === undefined ? "connecting" : "reconnecting";
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchInput<RequestType extends object>(
|
||||
service: string,
|
||||
request: RequestType,
|
||||
flow?: string,
|
||||
): DispatchInput {
|
||||
if (isNonEmptyString(flow)) {
|
||||
return {
|
||||
scope: "flow",
|
||||
service,
|
||||
flow,
|
||||
request: request as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
return {
|
||||
scope: "global",
|
||||
service,
|
||||
request: request as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
private socketUrlWithToken(): string {
|
||||
if (!isNonEmptyString(this.token)) return this.socketUrl;
|
||||
const separator = this.socketUrl.includes("?") ? "&" : "?";
|
||||
return `${this.socketUrl}${separator}token=${encodeURIComponent(this.token)}`;
|
||||
}
|
||||
|
||||
// Factory methods for creating specialized API instances
|
||||
librarian() {
|
||||
return new LibrarianApi(this);
|
||||
|
|
@ -787,7 +566,7 @@ export class LibrarianApi {
|
|||
},
|
||||
60000, // 60 second timeout for potentially large lists
|
||||
)
|
||||
.then((r) => r["document-metadatas"] ?? []);
|
||||
.then((r) => r["document-metadatas"] ?? r.documents ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -803,7 +582,7 @@ export class LibrarianApi {
|
|||
},
|
||||
60000,
|
||||
)
|
||||
.then((r) => r["processing-metadata"] ?? []);
|
||||
.then((r) => r["processing-metadatas"] ?? r.processing ?? r["processing-metadata"] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -818,6 +597,7 @@ export class LibrarianApi {
|
|||
{
|
||||
operation: "get-document-metadata",
|
||||
"document-id": documentId,
|
||||
documentId,
|
||||
user: this.api.user,
|
||||
},
|
||||
30000,
|
||||
|
|
@ -851,6 +631,8 @@ export class LibrarianApi {
|
|||
comments,
|
||||
user: this.api.user,
|
||||
tags,
|
||||
"document-type": "source",
|
||||
documentType: "source",
|
||||
};
|
||||
if (id !== undefined) {
|
||||
documentMetadata.id = id;
|
||||
|
|
@ -863,6 +645,7 @@ export class LibrarianApi {
|
|||
"librarian",
|
||||
{
|
||||
operation: "add-document",
|
||||
"document-metadata": documentMetadata,
|
||||
documentMetadata,
|
||||
content: document,
|
||||
},
|
||||
|
|
@ -879,6 +662,7 @@ export class LibrarianApi {
|
|||
{
|
||||
operation: "remove-document",
|
||||
"document-id": id,
|
||||
documentId: id,
|
||||
user: this.api.user,
|
||||
collection: withDefault(collection, "default"),
|
||||
},
|
||||
|
|
@ -908,6 +692,7 @@ export class LibrarianApi {
|
|||
"processing-metadata": {
|
||||
id: id,
|
||||
"document-id": doc_id,
|
||||
documentId: doc_id,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
flow: flow,
|
||||
user: this.api.user,
|
||||
|
|
@ -935,6 +720,7 @@ export class LibrarianApi {
|
|||
): Promise<BeginUploadResponse> {
|
||||
const request: BeginUploadRequest = {
|
||||
operation: "begin-upload",
|
||||
"document-metadata": metadata,
|
||||
documentMetadata: metadata,
|
||||
"total-size": totalSize,
|
||||
};
|
||||
|
|
@ -1200,32 +986,17 @@ export class FlowsApi {
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates configuration values. Items are grouped by `type` (the namespace);
|
||||
* one put request is issued per distinct type.
|
||||
* Updates configuration values using the Python-compatible values array.
|
||||
*/
|
||||
putConfig(items: { type: string; key: string; value: string }[]) {
|
||||
const byType = new Map<string, Record<string, unknown>>();
|
||||
for (const item of items) {
|
||||
let group = byType.get(item.type);
|
||||
if (group === undefined) {
|
||||
group = {};
|
||||
byType.set(item.type, group);
|
||||
}
|
||||
group[item.key] = item.value;
|
||||
}
|
||||
return Promise.all(
|
||||
[...byType.entries()].map(([type, values]) =>
|
||||
this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
keys: [type],
|
||||
values,
|
||||
},
|
||||
60000,
|
||||
),
|
||||
),
|
||||
).then((responses) => responses[responses.length - 1]);
|
||||
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
values: items,
|
||||
},
|
||||
60000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1233,13 +1004,13 @@ export class FlowsApi {
|
|||
*/
|
||||
deleteConfig(target: { type: string; key: string }) {
|
||||
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [target.type, target.key],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [target],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
// Prompt management - specialized config operations for AI prompts
|
||||
|
|
@ -2154,32 +1925,17 @@ export class ConfigApi {
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates configuration values. Items are grouped by `type` (the namespace);
|
||||
* one put request is issued per distinct type.
|
||||
* Updates configuration values using the Python-compatible values array.
|
||||
*/
|
||||
putConfig(items: { type: string; key: string; value: string }[]) {
|
||||
const byType = new Map<string, Record<string, unknown>>();
|
||||
for (const item of items) {
|
||||
let group = byType.get(item.type);
|
||||
if (group === undefined) {
|
||||
group = {};
|
||||
byType.set(item.type, group);
|
||||
}
|
||||
group[item.key] = item.value;
|
||||
}
|
||||
return Promise.all(
|
||||
[...byType.entries()].map(([type, values]) =>
|
||||
this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
keys: [type],
|
||||
values,
|
||||
},
|
||||
60000,
|
||||
),
|
||||
),
|
||||
).then((responses) => responses[responses.length - 1]);
|
||||
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
values: items,
|
||||
},
|
||||
60000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2187,13 +1943,13 @@ export class ConfigApi {
|
|||
*/
|
||||
deleteConfig(target: { type: string; key: string }) {
|
||||
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [target.type, target.key],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [target],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
// Specialized prompt management methods
|
||||
|
|
@ -2267,7 +2023,7 @@ export class ConfigApi {
|
|||
},
|
||||
60000,
|
||||
)
|
||||
.then((r) => (r as RowsQueryResponse).values);
|
||||
.then((r) => asConfigValues(r));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2285,12 +2041,10 @@ export class ConfigApi {
|
|||
60000,
|
||||
)
|
||||
.then((r) => {
|
||||
// Parse JSON values and restructure data
|
||||
const response = r as RowsQueryResponse;
|
||||
return (response.values ?? []).map((x: unknown) => {
|
||||
const item = x as Record<string, string>;
|
||||
return { key: item.key, value: JSON.parse(item.value) };
|
||||
});
|
||||
return asConfigValues(r).map((item) => ({
|
||||
key: item.key,
|
||||
value: parseConfigJson(item.value),
|
||||
}));
|
||||
})
|
||||
.then((r) =>
|
||||
// Transform to more usable format
|
||||
|
|
@ -2334,6 +2088,19 @@ export class KnowledgeApi {
|
|||
.then((r) => r.ids ?? []);
|
||||
}
|
||||
|
||||
getDocumentEmbeddingCores() {
|
||||
return this.api
|
||||
.makeRequest<FlowRequest, FlowResponse>(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "list-de-cores",
|
||||
user: this.api.user,
|
||||
},
|
||||
60000,
|
||||
)
|
||||
.then((r) => r.ids ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a knowledge graph core
|
||||
*/
|
||||
|
|
@ -2367,6 +2134,45 @@ export class KnowledgeApi {
|
|||
);
|
||||
}
|
||||
|
||||
unloadKgCore(id: string, flow: string) {
|
||||
return this.api.makeRequest<LibraryRequest, LibraryResponse>(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "unload-kg-core",
|
||||
id,
|
||||
flow,
|
||||
user: this.api.user,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
deleteDeCore(id: string) {
|
||||
return this.api.makeRequest<LibraryRequest, LibraryResponse>(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "delete-de-core",
|
||||
id,
|
||||
user: this.api.user,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
loadDeCore(id: string, flow: string, collection?: string) {
|
||||
return this.api.makeRequest<LibraryRequest, LibraryResponse>(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "load-de-core",
|
||||
id,
|
||||
flow,
|
||||
user: this.api.user,
|
||||
collection: withDefault(collection, "default"),
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a knowledge graph core with streaming data
|
||||
* Uses multi-request pattern for large datasets
|
||||
|
|
@ -2512,7 +2318,7 @@ export class CollectionManagementApi {
|
|||
* This is the main entry point for using the TrustGraph API
|
||||
* @param user - User identifier for API requests
|
||||
* @param token - Optional authentication token for secure connections
|
||||
* @param socketUrl - Optional WebSocket URL (defaults to /api/socket for browser, provide full URL for Node.js)
|
||||
* @param socketUrl - Optional WebSocket URL (defaults to /api/v1/rpc for browser, provide full URL for Node.js)
|
||||
*/
|
||||
export const createTrustGraphSocket = (
|
||||
user: string,
|
||||
|
|
|
|||
|
|
@ -97,16 +97,16 @@ export function getWebSocketConstructor(): IsomorphicWebSocketConstructor {
|
|||
/**
|
||||
* Returns the default WebSocket URL for the current environment.
|
||||
*
|
||||
* - Browser: returns the relative path `"/api/socket"` (resolved by the
|
||||
* - Browser: returns the relative path `"/api/v1/rpc"` (resolved by the
|
||||
* browser against the current page origin).
|
||||
* - Node.js: returns a full URL `"ws://localhost:8088/api/v1/socket"` since
|
||||
* - Node.js: returns a full URL `"ws://localhost:8088/api/v1/rpc"` since
|
||||
* relative URLs are not meaningful outside a browser.
|
||||
*/
|
||||
export function getDefaultSocketUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
return "/api/socket";
|
||||
return "/api/v1/rpc";
|
||||
}
|
||||
return "ws://localhost:8088/api/v1/socket";
|
||||
return "ws://localhost:8088/api/v1/rpc";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@effect/platform-bun": "4.0.0-beta.65",
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@qdrant/js-client-rest": "^1.13.0",
|
||||
"@trustgraph/base": "workspace:*",
|
||||
|
|
@ -20,13 +19,28 @@
|
|||
"fastify": "^5.2.0",
|
||||
"ollama": "^0.6.3",
|
||||
"@mistralai/mistralai": "^1.0.0",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/sql-pg": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-node": "4.0.0-beta.74",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"effect": "4.0.0-beta.74",
|
||||
"openai": "^4.85.0",
|
||||
"pdfjs-dist": "^5.6.205"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
131
ts/packages/flow/src/__tests__/retrieval-rag.test.ts
Normal file
131
ts/packages/flow/src/__tests__/retrieval-rag.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { Effect } from "effect";
|
||||
import type {
|
||||
DocumentEmbeddingsRequest,
|
||||
DocumentEmbeddingsResponse,
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
FlowRequestor,
|
||||
GraphEmbeddingsRequest,
|
||||
GraphEmbeddingsResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
TriplesQueryRequest,
|
||||
TriplesQueryResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeDocumentRagEngine, type DocumentRagClients } from "../retrieval/document-rag.js";
|
||||
import { makeGraphRagEngine, type GraphRagClients } from "../retrieval/graph-rag.js";
|
||||
|
||||
const requestor = <TReq, TRes>(
|
||||
handler: (request: TReq) => TRes | Promise<TRes>,
|
||||
): FlowRequestor<TReq, TRes> => ({
|
||||
request: async (request) => handler(request),
|
||||
stop: async () => undefined,
|
||||
});
|
||||
|
||||
describe("RAG engines", () => {
|
||||
it.effect(
|
||||
"runs Graph RAG without per-request service objects",
|
||||
Effect.fnUntraced(function* () {
|
||||
const prompts: Array<PromptRequest> = [];
|
||||
const triplesRequests: Array<TriplesQueryRequest> = [];
|
||||
let synthesisContext = "";
|
||||
|
||||
const clients: GraphRagClients = {
|
||||
prompt: requestor<PromptRequest, PromptResponse>((request) => {
|
||||
prompts.push(request);
|
||||
if (request.name === "extract-concepts") {
|
||||
return { system: "extract-system", prompt: "extract-prompt" };
|
||||
}
|
||||
synthesisContext = String(request.variables?.context ?? "");
|
||||
return { system: "synth-system", prompt: "synth-prompt" };
|
||||
}),
|
||||
llm: requestor<TextCompletionRequest, TextCompletionResponse>((request) => {
|
||||
if (request.prompt === "extract-prompt") {
|
||||
return { response: "alpha\nbeta", endOfStream: true };
|
||||
}
|
||||
return { response: `answer:${request.prompt}`, endOfStream: true };
|
||||
}),
|
||||
embeddings: requestor<EmbeddingsRequest, EmbeddingsResponse>((request) => {
|
||||
expect(request.text).toEqual(["alpha", "beta"]);
|
||||
return { vectors: [[1], [2]] };
|
||||
}),
|
||||
graphEmbeddings: requestor<GraphEmbeddingsRequest, GraphEmbeddingsResponse>((request) => {
|
||||
expect(request.collection).toBe("project");
|
||||
return {
|
||||
entities: [{ type: "IRI", iri: "https://example.test/entity/a" }],
|
||||
};
|
||||
}),
|
||||
triples: requestor<TriplesQueryRequest, TriplesQueryResponse>((request) => {
|
||||
triplesRequests.push(request);
|
||||
return {
|
||||
triples: [
|
||||
{
|
||||
s: { type: "IRI", iri: "https://example.test/entity/a" },
|
||||
p: { type: "IRI", iri: "https://example.test/relation" },
|
||||
o: { type: "LITERAL", value: "related value" },
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const engine = makeGraphRagEngine();
|
||||
const result = yield* engine.query(
|
||||
clients,
|
||||
"who is related?",
|
||||
{ collection: "project" },
|
||||
{ maxPathLength: 1 },
|
||||
);
|
||||
|
||||
expect(result.answer).toBe("answer:synth-prompt");
|
||||
expect(result.subgraph).toHaveLength(1);
|
||||
expect(prompts.map((prompt) => prompt.name)).toEqual([
|
||||
"extract-concepts",
|
||||
"graph-rag-synthesize",
|
||||
]);
|
||||
expect(triplesRequests).toHaveLength(1);
|
||||
expect(synthesisContext).toContain("https://example.test/entity/a");
|
||||
expect(synthesisContext).toContain("related value");
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"builds Document RAG synthesis context from returned chunks",
|
||||
Effect.fnUntraced(function* () {
|
||||
let synthesisContext = "";
|
||||
const clients: DocumentRagClients = {
|
||||
embeddings: requestor<EmbeddingsRequest, EmbeddingsResponse>((request) => {
|
||||
expect(request.text).toEqual(["explain docs"]);
|
||||
return { vectors: [[0.1, 0.2]] };
|
||||
}),
|
||||
docEmbeddings: requestor<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>((request) => {
|
||||
expect(request.collection).toBe("docs");
|
||||
return {
|
||||
chunks: [
|
||||
{ chunkId: "1", score: 0.9, content: "first chunk" },
|
||||
{ chunkId: "2", score: 0.8, content: "" },
|
||||
{ chunkId: "3", score: 0.7, content: "second chunk" },
|
||||
],
|
||||
};
|
||||
}),
|
||||
prompt: requestor<PromptRequest, PromptResponse>((request) => {
|
||||
synthesisContext = String(request.variables?.context ?? "");
|
||||
return { system: "doc-system", prompt: "doc-prompt" };
|
||||
}),
|
||||
llm: requestor<TextCompletionRequest, TextCompletionResponse>((request) => ({
|
||||
response: `doc-answer:${request.prompt}`,
|
||||
endOfStream: true,
|
||||
})),
|
||||
};
|
||||
|
||||
const engine = makeDocumentRagEngine();
|
||||
const response = yield* engine.query(clients, "explain docs", { collection: "docs" });
|
||||
|
||||
expect(response).toBe("doc-answer:doc-prompt");
|
||||
expect(synthesisContext).toBe("first chunk\n\n---\n\nsecond chunk");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -1 +1 @@
|
|||
export { McpToolService } from "./service.js";
|
||||
export { McpToolService, run } from "./service.js";
|
||||
|
|
|
|||
|
|
@ -17,152 +17,312 @@ import {
|
|||
FlowProcessor,
|
||||
ConsumerSpec,
|
||||
ProducerSpec,
|
||||
makeFlowProcessorProgram,
|
||||
errorMessage,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type ToolRequest,
|
||||
type ToolResponse,
|
||||
type EffectConfigHandler,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer, Ref } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
interface McpServiceConfig {
|
||||
url: string;
|
||||
"remote-name"?: string;
|
||||
"auth-token"?: string;
|
||||
const McpServiceConfig = S.Struct({
|
||||
url: S.String,
|
||||
"remote-name": S.optionalKey(S.String),
|
||||
"auth-token": S.optionalKey(S.String),
|
||||
});
|
||||
type McpServiceConfig = typeof McpServiceConfig.Type;
|
||||
|
||||
const decodeRawMcpConfig = S.decodeUnknownOption(S.Record(S.String, S.String));
|
||||
const decodeMcpServiceConfig = S.decodeUnknownOption(McpServiceConfig.pipe(S.fromJsonString));
|
||||
const decodeToolParameters = S.decodeUnknownOption(S.Record(S.String, S.Unknown).pipe(S.fromJsonString));
|
||||
const encodeJson = S.encodeUnknownOption(S.UnknownFromJsonString);
|
||||
|
||||
export class McpToolError extends S.TaggedErrorClass<McpToolError>()(
|
||||
"McpToolError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
tool: S.optionalKey(S.String),
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface McpToolRuntimeService {
|
||||
readonly configure: (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) => Effect.Effect<void>;
|
||||
readonly invokeTool: (
|
||||
name: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) => Effect.Effect<string | unknown, McpToolError>;
|
||||
}
|
||||
|
||||
export class McpToolService extends FlowProcessor {
|
||||
private mcpServices: Record<string, McpServiceConfig> = {};
|
||||
export class McpToolRuntime extends Context.Service<
|
||||
McpToolRuntime,
|
||||
McpToolRuntimeService
|
||||
>()("@trustgraph/flow/agent/mcp-tool/service/McpToolRuntime") {}
|
||||
|
||||
const mcpToolError = (
|
||||
operation: string,
|
||||
cause: unknown,
|
||||
tool?: string,
|
||||
): McpToolError =>
|
||||
new McpToolError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
...(tool === undefined ? {} : { tool }),
|
||||
});
|
||||
|
||||
const closeTransport = (
|
||||
transport: StreamableHTTPClientTransport,
|
||||
tool: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => transport.close(),
|
||||
catch: (cause) => mcpToolError("close-transport", cause, tool),
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[McpToolService] Failed to close MCP transport", {
|
||||
error: error.message,
|
||||
tool: error.tool ?? tool,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const loadMcpServices = Effect.fn("McpToolRuntime.loadMcpServices")(function* (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
yield* Effect.log(`[McpToolService] Got config version ${version}`);
|
||||
|
||||
if (!("mcp" in config) || typeof config.mcp !== "object" || config.mcp === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawConfig = decodeRawMcpConfig(config.mcp);
|
||||
if (O.isNone(rawConfig)) {
|
||||
yield* Effect.logError("[McpToolService] MCP config must be an object of JSON strings");
|
||||
return {};
|
||||
}
|
||||
|
||||
const services: Record<string, McpServiceConfig> = {};
|
||||
for (const [name, value] of Object.entries(rawConfig.value)) {
|
||||
const decoded = decodeMcpServiceConfig(value);
|
||||
if (O.isNone(decoded)) {
|
||||
yield* Effect.logError(`[McpToolService] Failed to parse MCP config for ${name}`);
|
||||
continue;
|
||||
}
|
||||
services[name] = decoded.value;
|
||||
yield* Effect.log(`[McpToolService] Registered MCP service: ${name}`);
|
||||
}
|
||||
|
||||
yield* Effect.log(
|
||||
`[McpToolService] ${Object.keys(services).length} MCP services configured`,
|
||||
);
|
||||
|
||||
return services;
|
||||
});
|
||||
|
||||
const invokeConfiguredTool = Effect.fn("McpToolRuntime.invokeTool")(function* (
|
||||
services: Record<string, McpServiceConfig>,
|
||||
name: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) {
|
||||
if (!(name in services)) {
|
||||
return yield* mcpToolError("lookup-service", `MCP service "${name}" not known`, name);
|
||||
}
|
||||
|
||||
const svcConfig = services[name];
|
||||
if (svcConfig.url.length === 0) {
|
||||
return yield* mcpToolError("validate-service", `MCP service "${name}" URL not defined`, name);
|
||||
}
|
||||
|
||||
const remoteName = svcConfig["remote-name"] ?? name;
|
||||
const headers: Record<string, string> = {};
|
||||
if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) {
|
||||
headers.Authorization = `Bearer ${svcConfig["auth-token"]}`;
|
||||
}
|
||||
|
||||
yield* Effect.log(`[McpToolService] Invoking ${remoteName} at ${svcConfig.url}`);
|
||||
|
||||
const url = yield* Effect.try({
|
||||
try: () => new URL(svcConfig.url),
|
||||
catch: (cause) => mcpToolError("validate-url", cause, name),
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
url,
|
||||
{ requestInit: { headers } },
|
||||
);
|
||||
const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" });
|
||||
|
||||
const result = yield* Effect.acquireUseRelease(
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
|
||||
return client;
|
||||
},
|
||||
catch: (cause) => mcpToolError("connect", cause, name),
|
||||
}),
|
||||
(connectedClient) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
connectedClient.callTool({
|
||||
name: remoteName,
|
||||
arguments: parameters,
|
||||
}),
|
||||
catch: (cause) => mcpToolError("call-tool", cause, name),
|
||||
}),
|
||||
() => closeTransport(transport, name),
|
||||
);
|
||||
|
||||
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
if (result.content !== undefined && Array.isArray(result.content)) {
|
||||
return result.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
return "No content";
|
||||
});
|
||||
|
||||
export const makeMcpToolRuntime = Effect.gen(function* () {
|
||||
const servicesRef = yield* Ref.make<Record<string, McpServiceConfig>>({});
|
||||
|
||||
return McpToolRuntime.of({
|
||||
configure: Effect.fn("McpToolRuntime.configure")(function* (config, version) {
|
||||
const services = yield* loadMcpServices(config, version);
|
||||
yield* Ref.set(servicesRef, services);
|
||||
}),
|
||||
invokeTool: Effect.fn("McpToolRuntime.invokeToolFromRef")(function* (name, parameters) {
|
||||
const services = yield* Ref.get(servicesRef);
|
||||
return yield* invokeConfiguredTool(services, name, parameters);
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
export const McpToolRuntimeLive = Layer.effect(McpToolRuntime, makeMcpToolRuntime);
|
||||
|
||||
const onMcpConfig = Effect.fn("McpToolService.onConfig")(function* (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
const runtime = yield* McpToolRuntime;
|
||||
yield* runtime.configure(config, version);
|
||||
});
|
||||
|
||||
type McpToolHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError;
|
||||
|
||||
const parametersFromJson = (
|
||||
name: string,
|
||||
parameters: string,
|
||||
): Effect.Effect<Record<string, unknown>, McpToolError> => {
|
||||
if (parameters.length === 0) return Effect.succeed({});
|
||||
|
||||
const decoded = decodeToolParameters(parameters);
|
||||
if (O.isNone(decoded)) {
|
||||
return Effect.fail(mcpToolError("decode-parameters", "Tool parameters must be a JSON object", name));
|
||||
}
|
||||
return Effect.succeed(decoded.value);
|
||||
};
|
||||
|
||||
const onMcpToolRequest = Effect.fn("McpToolService.onRequest")(function* (
|
||||
msg: ToolRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<McpToolRuntime>,
|
||||
): Effect.fn.Return<void, McpToolHandlerError, McpToolRuntime> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<ToolResponse>("mcp-tool-response");
|
||||
const runtime = yield* McpToolRuntime;
|
||||
|
||||
const result = yield* parametersFromJson(msg.name, msg.parameters).pipe(
|
||||
Effect.flatMap((parameters) => runtime.invokeTool(msg.name, parameters)),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError(`[McpToolService] Error invoking tool ${msg.name}`, {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
responseProducer.send(requestId, {
|
||||
error: { type: "tool-error", message: error.message },
|
||||
}),
|
||||
),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result === undefined) return;
|
||||
|
||||
if (typeof result === "string") {
|
||||
yield* responseProducer.send(requestId, { text: result });
|
||||
return;
|
||||
}
|
||||
|
||||
const encoded = encodeJson(result);
|
||||
yield* responseProducer.send(requestId, {
|
||||
object: O.isSome(encoded) ? encoded.value : String(result),
|
||||
});
|
||||
});
|
||||
|
||||
export const makeMcpToolSpecs = (): ReadonlyArray<Spec<McpToolRuntime>> => [
|
||||
new ConsumerSpec<ToolRequest, McpToolHandlerError, McpToolRuntime>(
|
||||
"mcp-tool-request",
|
||||
onMcpToolRequest,
|
||||
),
|
||||
new ProducerSpec<ToolResponse>("mcp-tool-response"),
|
||||
];
|
||||
|
||||
export const makeMcpToolConfigHandlers = (): ReadonlyArray<
|
||||
EffectConfigHandler<never, McpToolRuntime>
|
||||
> => [onMcpConfig];
|
||||
|
||||
export class McpToolService extends FlowProcessor<McpToolRuntime> {
|
||||
private readonly runtime = Effect.runSync(makeMcpToolRuntime);
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<ToolRequest>("mcp-tool-request", this.onRequest.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<ToolResponse>("mcp-tool-response"));
|
||||
|
||||
this.registerConfigHandler(this.onMcpConfig.bind(this));
|
||||
}
|
||||
|
||||
private async onMcpConfig(
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
): Promise<void> {
|
||||
console.log(`[McpToolService] Got config version ${version}`);
|
||||
|
||||
if (!("mcp" in config) || typeof config.mcp !== "object" || config.mcp === null) {
|
||||
this.mcpServices = {};
|
||||
return;
|
||||
for (const spec of makeMcpToolSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
const mcpConfig = config.mcp as Record<string, string>;
|
||||
this.mcpServices = {};
|
||||
|
||||
for (const [name, value] of Object.entries(mcpConfig)) {
|
||||
try {
|
||||
this.mcpServices[name] = JSON.parse(value) as McpServiceConfig;
|
||||
console.log(`[McpToolService] Registered MCP service: ${name}`);
|
||||
} catch (err) {
|
||||
console.error(`[McpToolService] Failed to parse MCP config for ${name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[McpToolService] ${Object.keys(this.mcpServices).length} MCP services configured`,
|
||||
this.registerConfigHandler((config, version) =>
|
||||
Effect.runPromise(onMcpConfig(config, version).pipe(
|
||||
Effect.provideService(McpToolRuntime, this.runtime),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: ToolRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<ToolResponse>("mcp-tool-response");
|
||||
|
||||
try {
|
||||
const result = await this.invokeTool(
|
||||
msg.name,
|
||||
msg.parameters !== undefined && msg.parameters.length > 0
|
||||
? JSON.parse(msg.parameters) as Record<string, unknown>
|
||||
: {},
|
||||
);
|
||||
|
||||
if (typeof result === "string") {
|
||||
await responseProducer.send(requestId, { text: result });
|
||||
} else {
|
||||
await responseProducer.send(requestId, { object: JSON.stringify(result) });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[McpToolService] Error invoking tool ${msg.name}:`, err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await responseProducer.send(requestId, {
|
||||
error: { type: "tool-error", message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async invokeTool(
|
||||
name: string,
|
||||
parameters: Record<string, unknown>,
|
||||
): Promise<string | unknown> {
|
||||
if (!(name in this.mcpServices)) {
|
||||
throw new Error(`MCP service "${name}" not known`);
|
||||
}
|
||||
|
||||
const svcConfig = this.mcpServices[name];
|
||||
if (svcConfig.url.length === 0) {
|
||||
throw new Error(`MCP service "${name}" URL not defined`);
|
||||
}
|
||||
|
||||
const remoteName = svcConfig["remote-name"] ?? name;
|
||||
|
||||
// Build headers with optional bearer token
|
||||
const headers: Record<string, string> = {};
|
||||
if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) {
|
||||
headers["Authorization"] = `Bearer ${svcConfig["auth-token"]}`;
|
||||
}
|
||||
|
||||
console.log(`[McpToolService] Invoking ${remoteName} at ${svcConfig.url}`);
|
||||
|
||||
// Connect to streamable HTTP MCP server
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(svcConfig.url),
|
||||
{ requestInit: { headers } },
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(McpToolRuntime, this.runtime),
|
||||
);
|
||||
|
||||
const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" });
|
||||
|
||||
try {
|
||||
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: remoteName,
|
||||
arguments: parameters,
|
||||
});
|
||||
|
||||
// Extract response — prefer structured content, fall back to text
|
||||
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
if (result.content !== undefined && Array.isArray(result.content)) {
|
||||
return result.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
return "No content";
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, McpToolRuntime>({
|
||||
id: "mcp-tool",
|
||||
make: (config) => new McpToolService(config),
|
||||
specs: () => makeMcpToolSpecs(),
|
||||
configHandlers: () => makeMcpToolConfigHandlers(),
|
||||
layer: () => McpToolRuntimeLive,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
ConsumerSpec,
|
||||
ProducerSpec,
|
||||
RequestResponseSpec,
|
||||
makeFlowProcessorProgram,
|
||||
errorMessage,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type AgentRequest,
|
||||
|
|
@ -35,8 +37,18 @@ import {
|
|||
type TriplesQueryResponse,
|
||||
type ToolRequest,
|
||||
type ToolResponse,
|
||||
type EffectConfigHandler,
|
||||
type EffectRequestOptions,
|
||||
type EffectRequestResponse,
|
||||
type FlowRequestOptions,
|
||||
type FlowRequestor,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer, Ref } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
import {
|
||||
createKnowledgeQueryTool,
|
||||
|
|
@ -51,398 +63,490 @@ import type { AgentTool, ToolArg } from "./types.js";
|
|||
|
||||
const MAX_ITERATIONS = 10;
|
||||
|
||||
export class AgentService extends FlowProcessor {
|
||||
/** Config-driven tools; null means "use hardcoded fallback". */
|
||||
private configuredTools: AgentTool[] | null = null;
|
||||
class AgentToolExecutionError extends S.TaggedErrorClass<AgentToolExecutionError>()(
|
||||
"AgentToolExecutionError",
|
||||
{
|
||||
message: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
const UnknownRecord = S.Record(S.String, S.Unknown);
|
||||
const ToolArgumentConfig = S.StructWithRest(
|
||||
S.Struct({
|
||||
name: S.optionalKey(S.String),
|
||||
type: S.optionalKey(S.String),
|
||||
description: S.optionalKey(S.String),
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
const ToolConfigEntry = S.StructWithRest(
|
||||
S.Struct({
|
||||
type: S.optionalKey(S.String),
|
||||
name: S.optionalKey(S.String),
|
||||
description: S.optionalKey(S.String),
|
||||
arguments: ToolArgumentConfig.pipe(S.Array, S.optionalKey),
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
type ToolConfigEntry = typeof ToolConfigEntry.Type;
|
||||
|
||||
const decodeRawToolConfig = S.decodeUnknownOption(S.Record(S.String, S.String));
|
||||
const decodeToolConfigEntry = S.decodeUnknownOption(ToolConfigEntry.pipe(S.fromJsonString));
|
||||
|
||||
export interface AgentRuntimeService {
|
||||
readonly configureTools: (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) => Effect.Effect<void>;
|
||||
readonly getConfiguredTools: Effect.Effect<ReadonlyArray<AgentTool> | null>;
|
||||
}
|
||||
|
||||
export class AgentRuntime extends Context.Service<AgentRuntime, AgentRuntimeService>()(
|
||||
"@trustgraph/flow/agent/react/service/AgentRuntime",
|
||||
) {}
|
||||
|
||||
const toEffectRequestOptions = <TRes>(
|
||||
options: FlowRequestOptions<TRes> | undefined,
|
||||
): EffectRequestOptions<TRes> | undefined => {
|
||||
if (options === undefined) return undefined;
|
||||
return {
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(options.recipient === undefined
|
||||
? {}
|
||||
: {
|
||||
recipient: (response: TRes) =>
|
||||
Effect.promise(() => options.recipient?.(response) ?? Promise.resolve(true)),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const toPromiseRequestor = <TReq, TRes>(
|
||||
requestor: EffectRequestResponse<TReq, TRes>,
|
||||
): FlowRequestor<TReq, TRes> => ({
|
||||
request: (request, options) =>
|
||||
Effect.runPromise(requestor.request(request, toEffectRequestOptions(options))),
|
||||
stop: () => Effect.runPromise(requestor.stop),
|
||||
});
|
||||
|
||||
const buildConfiguredTool = (
|
||||
toolId: string,
|
||||
data: ToolConfigEntry,
|
||||
): AgentTool | null => {
|
||||
const implType = data.type ?? "";
|
||||
const name = data.name ?? "";
|
||||
const description = data.description ?? "";
|
||||
const config = { ...data } as Record<string, unknown>;
|
||||
|
||||
if (name.length === 0) {
|
||||
console.warn(`[AgentService] Skipping tool with no name: ${toolId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (implType) {
|
||||
case "knowledge-query":
|
||||
return {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query the knowledge graph for information about entities and their relationships.",
|
||||
args: [{ name: "question", type: "string", description: "The question to ask" }],
|
||||
config,
|
||||
execute: async () => "",
|
||||
};
|
||||
|
||||
case "document-query":
|
||||
return {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Search documents for relevant information.",
|
||||
args: [{ name: "question", type: "string", description: "The question to search for" }],
|
||||
config,
|
||||
execute: async () => "",
|
||||
};
|
||||
|
||||
case "triples-query":
|
||||
return {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query for specific triples in the knowledge graph.",
|
||||
args: [
|
||||
{ name: "subject", type: "string", description: "Subject entity (optional)" },
|
||||
{ name: "predicate", type: "string", description: "Predicate/relationship (optional)" },
|
||||
{ name: "object", type: "string", description: "Object entity (optional)" },
|
||||
],
|
||||
config,
|
||||
execute: async () => "",
|
||||
};
|
||||
|
||||
case "mcp-tool": {
|
||||
const args: ToolArg[] = (data.arguments ?? []).map((arg) => ({
|
||||
name: arg.name ?? "",
|
||||
type: arg.type ?? "string",
|
||||
description: arg.description ?? "",
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
args,
|
||||
config,
|
||||
execute: async () => "",
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`[AgentService] Unknown tool type "${implType}" for ${name}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfiguredTools = Effect.fn("AgentRuntime.loadConfiguredTools")(function* (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
yield* Effect.log(`[AgentService] Loading tool configuration version ${version}`);
|
||||
|
||||
if (!("tool" in config) || typeof config.tool !== "object" || config.tool === null) {
|
||||
yield* Effect.log("[AgentService] No tool config found, using default tools");
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawConfig = decodeRawToolConfig(config.tool);
|
||||
if (O.isNone(rawConfig)) {
|
||||
yield* Effect.logError("[AgentService] Tool config must be an object of JSON strings");
|
||||
return null;
|
||||
}
|
||||
|
||||
const tools: AgentTool[] = [];
|
||||
for (const [toolId, toolValue] of Object.entries(rawConfig.value)) {
|
||||
const decoded = decodeToolConfigEntry(toolValue);
|
||||
if (O.isNone(decoded)) {
|
||||
yield* Effect.logError(`[AgentService] Failed to parse tool config ${toolId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tool = buildConfiguredTool(toolId, decoded.value);
|
||||
if (tool === null) continue;
|
||||
|
||||
tools.push(tool);
|
||||
yield* Effect.log(`[AgentService] Registered tool: ${tool.name} (${tool.config?.type ?? "unknown"})`);
|
||||
}
|
||||
|
||||
yield* Effect.log(`[AgentService] ${tools.length} tools loaded from config`);
|
||||
return tools.length > 0 ? tools : null;
|
||||
});
|
||||
|
||||
export const makeAgentRuntime = Effect.gen(function* () {
|
||||
const configuredToolsRef = yield* Ref.make<ReadonlyArray<AgentTool> | null>(null);
|
||||
|
||||
return AgentRuntime.of({
|
||||
configureTools: Effect.fn("AgentRuntime.configureTools")(function* (config, version) {
|
||||
const tools = yield* loadConfiguredTools(config, version);
|
||||
yield* Ref.set(configuredToolsRef, tools);
|
||||
}),
|
||||
getConfiguredTools: Ref.get(configuredToolsRef),
|
||||
});
|
||||
});
|
||||
|
||||
export const AgentRuntimeLive = Layer.effect(AgentRuntime, makeAgentRuntime);
|
||||
|
||||
const onToolsConfig = Effect.fn("AgentService.onToolsConfig")(function* (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
const runtime = yield* AgentRuntime;
|
||||
yield* runtime.configureTools(config, version);
|
||||
});
|
||||
|
||||
const wireTools = Effect.fn("AgentService.wireTools")(function* (
|
||||
tools: ReadonlyArray<AgentTool>,
|
||||
flowCtx: FlowContext<AgentRuntime>,
|
||||
collection: string | undefined,
|
||||
onExplain: (data: ExplainData) => void,
|
||||
) {
|
||||
const graphRag = yield* flowCtx.flow.requestorEffect<GraphRagRequest, GraphRagResponse>("graph-rag");
|
||||
const docRag = yield* flowCtx.flow.requestorEffect<DocumentRagRequest, DocumentRagResponse>("doc-rag");
|
||||
const triples = yield* flowCtx.flow.requestorEffect<TriplesQueryRequest, TriplesQueryResponse>("triples");
|
||||
const mcpTool = yield* flowCtx.flow.requestorEffect<ToolRequest, ToolResponse>("mcp-tool");
|
||||
|
||||
return tools.map((tool) => {
|
||||
const implType = tool.config?.type as string | undefined;
|
||||
|
||||
switch (implType) {
|
||||
case "knowledge-query": {
|
||||
const live = createKnowledgeQueryTool(
|
||||
toPromiseRequestor(graphRag),
|
||||
collection,
|
||||
onExplain,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "document-query": {
|
||||
const live = createDocumentQueryTool(
|
||||
toPromiseRequestor(docRag),
|
||||
collection,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "triples-query": {
|
||||
const live = createTriplesQueryTool(
|
||||
toPromiseRequestor(triples),
|
||||
collection,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "mcp-tool": {
|
||||
const live = createMcpTool(
|
||||
toPromiseRequestor(mcpTool),
|
||||
tool.name,
|
||||
tool.description,
|
||||
tool.args,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
default:
|
||||
return tool;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const defaultTools = Effect.fn("AgentService.defaultTools")(function* (
|
||||
flowCtx: FlowContext<AgentRuntime>,
|
||||
collection: string | undefined,
|
||||
onExplain: (data: ExplainData) => void,
|
||||
) {
|
||||
const graphRag = yield* flowCtx.flow.requestorEffect<GraphRagRequest, GraphRagResponse>("graph-rag");
|
||||
const docRag = yield* flowCtx.flow.requestorEffect<DocumentRagRequest, DocumentRagResponse>("doc-rag");
|
||||
const triples = yield* flowCtx.flow.requestorEffect<TriplesQueryRequest, TriplesQueryResponse>("triples");
|
||||
|
||||
return [
|
||||
createKnowledgeQueryTool(
|
||||
toPromiseRequestor(graphRag),
|
||||
collection,
|
||||
onExplain,
|
||||
),
|
||||
createDocumentQueryTool(
|
||||
toPromiseRequestor(docRag),
|
||||
collection,
|
||||
),
|
||||
createTriplesQueryTool(
|
||||
toPromiseRequestor(triples),
|
||||
collection,
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
const executeTool = (
|
||||
tool: AgentTool,
|
||||
input: string,
|
||||
): Effect.Effect<string> =>
|
||||
Effect.tryPromise({
|
||||
try: () => tool.execute(input),
|
||||
catch: (cause) => new AgentToolExecutionError({ message: errorMessage(cause) }),
|
||||
}).pipe(
|
||||
Effect.catch((error: AgentToolExecutionError) =>
|
||||
Effect.succeed(`Error executing tool: ${error.message}`),
|
||||
),
|
||||
);
|
||||
|
||||
type AgentHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError;
|
||||
|
||||
const onAgentRequest = Effect.fn("AgentService.onRequest")(function* (
|
||||
msg: AgentRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<AgentRuntime>,
|
||||
): Effect.fn.Return<void, AgentHandlerError, AgentRuntime> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<AgentResponse>("agent-response");
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const runtime = yield* AgentRuntime;
|
||||
const explainEvents: ExplainData[] = [];
|
||||
const onExplain = (data: ExplainData) => {
|
||||
explainEvents.push(data);
|
||||
};
|
||||
|
||||
const configuredTools = yield* runtime.getConfiguredTools;
|
||||
let tools = configuredTools !== null
|
||||
? yield* wireTools(configuredTools, flowCtx, msg.collection, onExplain)
|
||||
: yield* defaultTools(flowCtx, msg.collection, onExplain);
|
||||
|
||||
tools = filterToolsByGroupAndState(tools, msg.group, msg.state);
|
||||
|
||||
const { system, prompt: initialPrompt } = buildReActPrompt(
|
||||
tools,
|
||||
msg.question,
|
||||
);
|
||||
|
||||
const llmClient = yield* flowCtx.flow.requestorEffect<
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse
|
||||
>("llm");
|
||||
|
||||
let conversation = initialPrompt;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
||||
yield* Effect.log(
|
||||
`[AgentService] Iteration ${iteration + 1}/${MAX_ITERATIONS} for request ${requestId}`,
|
||||
);
|
||||
|
||||
const llmResponse = yield* llmClient.request({
|
||||
system,
|
||||
prompt: conversation,
|
||||
});
|
||||
|
||||
if (llmResponse.error !== undefined) {
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content: `LLM error: ${llmResponse.error.message}`,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = llmResponse.response;
|
||||
const parsed = parseReActResponse(text);
|
||||
|
||||
if (parsed.thought.length > 0) {
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "thought",
|
||||
content: parsed.thought,
|
||||
end_of_message: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.finalAnswer.length > 0) {
|
||||
for (const explain of explainEvents) {
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "explain",
|
||||
content: "",
|
||||
explain_id: explain.explainId,
|
||||
explain_triples: explain.triples,
|
||||
} as AgentResponse);
|
||||
}
|
||||
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "answer",
|
||||
content: parsed.finalAnswer,
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action.length > 0 && parsed.actionInput.length > 0) {
|
||||
const tool = tools.find((candidate) => candidate.name === parsed.action);
|
||||
const observation = tool === undefined
|
||||
? `Unknown tool: ${parsed.action}. Available tools: ${tools.map((candidate) => candidate.name).join(", ")}`
|
||||
: yield* executeTool(tool, parsed.actionInput);
|
||||
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "observation",
|
||||
content: observation,
|
||||
end_of_message: true,
|
||||
});
|
||||
|
||||
conversation += `\n${text}\nObservation: ${observation}\n`;
|
||||
} else if (parsed.finalAnswer.length === 0) {
|
||||
conversation += `\n${text}\nObservation: You must either use a tool (Action + Action Input) or provide a Final Answer.\n`;
|
||||
}
|
||||
}
|
||||
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content:
|
||||
"Maximum reasoning iterations reached without a final answer. " +
|
||||
"The agent was unable to complete the task within the allowed steps.",
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
}).pipe(
|
||||
Effect.catch((error: unknown) =>
|
||||
Effect.logError(`[AgentService] Error processing request ${requestId}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content: `Agent error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export const makeAgentSpecs = (): ReadonlyArray<Spec<AgentRuntime>> => [
|
||||
new ConsumerSpec<AgentRequest, AgentHandlerError, AgentRuntime>(
|
||||
"agent-request",
|
||||
onAgentRequest,
|
||||
),
|
||||
new ProducerSpec<AgentResponse>("agent-response"),
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
new RequestResponseSpec<GraphRagRequest, GraphRagResponse>(
|
||||
"graph-rag",
|
||||
"graph-rag-request",
|
||||
"graph-rag-response",
|
||||
),
|
||||
new RequestResponseSpec<DocumentRagRequest, DocumentRagResponse>(
|
||||
"doc-rag",
|
||||
"document-rag-request",
|
||||
"document-rag-response",
|
||||
),
|
||||
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
|
||||
"triples",
|
||||
"triples-request",
|
||||
"triples-response",
|
||||
),
|
||||
new RequestResponseSpec<ToolRequest, ToolResponse>(
|
||||
"mcp-tool",
|
||||
"mcp-tool-request",
|
||||
"mcp-tool-response",
|
||||
),
|
||||
];
|
||||
|
||||
export const makeAgentConfigHandlers = (): ReadonlyArray<
|
||||
EffectConfigHandler<never, AgentRuntime>
|
||||
> => [onToolsConfig];
|
||||
|
||||
export class AgentService extends FlowProcessor<AgentRuntime> {
|
||||
private readonly runtime = Effect.runSync(makeAgentRuntime);
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
// Consumer: agent requests
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<AgentRequest>("agent-request", this.onRequest.bind(this)),
|
||||
);
|
||||
for (const spec of makeAgentSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
// Producer: agent responses (streaming chunks)
|
||||
this.registerSpecification(new ProducerSpec<AgentResponse>("agent-response"));
|
||||
|
||||
// Request-response clients for tool execution
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
this.registerConfigHandler((config, version) =>
|
||||
Effect.runPromise(onToolsConfig(config, version).pipe(
|
||||
Effect.provideService(AgentRuntime, this.runtime),
|
||||
)),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<GraphRagRequest, GraphRagResponse>(
|
||||
"graph-rag",
|
||||
"graph-rag-request",
|
||||
"graph-rag-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<DocumentRagRequest, DocumentRagResponse>(
|
||||
"doc-rag",
|
||||
"document-rag-request",
|
||||
"document-rag-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
|
||||
"triples",
|
||||
"triples-request",
|
||||
"triples-response",
|
||||
),
|
||||
);
|
||||
|
||||
// MCP tool invocation client
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<ToolRequest, ToolResponse>(
|
||||
"mcp-tool",
|
||||
"mcp-tool-request",
|
||||
"mcp-tool-response",
|
||||
),
|
||||
);
|
||||
|
||||
// Register for config-push to build tools dynamically
|
||||
this.registerConfigHandler(this.onToolsConfig.bind(this));
|
||||
|
||||
console.log("[AgentService] Service initialized");
|
||||
}
|
||||
|
||||
// ---------- Config-driven tool registration ----------
|
||||
|
||||
private async onToolsConfig(
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
): Promise<void> {
|
||||
console.log(`[AgentService] Loading tool configuration version ${version}`);
|
||||
|
||||
try {
|
||||
if (!("tool" in config) || typeof config.tool !== "object" || config.tool === null) {
|
||||
// No tool config — keep using hardcoded fallback
|
||||
this.configuredTools = null;
|
||||
console.log("[AgentService] No tool config found, using default tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const toolConfig = config.tool as Record<string, string>;
|
||||
const tools: AgentTool[] = [];
|
||||
|
||||
for (const [_toolId, toolValue] of Object.entries(toolConfig)) {
|
||||
try {
|
||||
const data = JSON.parse(toolValue) as Record<string, unknown>;
|
||||
const implType = typeof data["type"] === "string" ? data["type"] : "";
|
||||
const name = typeof data["name"] === "string" ? data["name"] : "";
|
||||
const description =
|
||||
typeof data["description"] === "string" ? data["description"] : "";
|
||||
|
||||
if (name.length === 0) {
|
||||
console.warn(`[AgentService] Skipping tool with no name: ${_toolId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let tool: AgentTool | null = null;
|
||||
|
||||
switch (implType) {
|
||||
case "knowledge-query":
|
||||
// Will be wired to requestor at request time
|
||||
tool = {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query the knowledge graph for information about entities and their relationships.",
|
||||
args: [{ name: "question", type: "string", description: "The question to ask" }],
|
||||
config: data,
|
||||
execute: async () => "", // placeholder — wired at request time
|
||||
};
|
||||
break;
|
||||
|
||||
case "document-query":
|
||||
tool = {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Search documents for relevant information.",
|
||||
args: [{ name: "question", type: "string", description: "The question to search for" }],
|
||||
config: data,
|
||||
execute: async () => "",
|
||||
};
|
||||
break;
|
||||
|
||||
case "triples-query":
|
||||
tool = {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query for specific triples in the knowledge graph.",
|
||||
args: [
|
||||
{ name: "subject", type: "string", description: "Subject entity (optional)" },
|
||||
{ name: "predicate", type: "string", description: "Predicate/relationship (optional)" },
|
||||
{ name: "object", type: "string", description: "Object entity (optional)" },
|
||||
],
|
||||
config: data,
|
||||
execute: async () => "",
|
||||
};
|
||||
break;
|
||||
|
||||
case "mcp-tool": {
|
||||
const configArgs = (data["arguments"] as Array<Record<string, string>>) ?? [];
|
||||
const args: ToolArg[] = configArgs.map((a) => ({
|
||||
name: a.name ?? "",
|
||||
type: a.type ?? "string",
|
||||
description: a.description ?? "",
|
||||
}));
|
||||
|
||||
// Create a placeholder — will be wired to the MCP requestor at request time
|
||||
tool = {
|
||||
name,
|
||||
description,
|
||||
args,
|
||||
config: data,
|
||||
execute: async () => "", // placeholder
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`[AgentService] Unknown tool type "${implType}" for ${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tool !== null) {
|
||||
tools.push(tool);
|
||||
console.log(`[AgentService] Registered tool: ${name} (${implType})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[AgentService] Failed to parse tool config ${_toolId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
this.configuredTools = tools.length > 0 ? tools : null;
|
||||
console.log(`[AgentService] ${tools.length} tools loaded from config`);
|
||||
} catch (err) {
|
||||
console.error("[AgentService] Config reload failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up tool execute functions with live requestors from the flow context.
|
||||
* Config-driven tools store placeholders; this replaces them with real impls.
|
||||
*/
|
||||
private wireTools(
|
||||
tools: AgentTool[],
|
||||
flowCtx: FlowContext,
|
||||
collection?: string,
|
||||
onExplain?: (data: ExplainData) => void,
|
||||
): AgentTool[] {
|
||||
return tools.map((tool) => {
|
||||
const implType = tool.config?.["type"] as string | undefined;
|
||||
|
||||
switch (implType) {
|
||||
case "knowledge-query": {
|
||||
const live = createKnowledgeQueryTool(
|
||||
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
|
||||
collection,
|
||||
onExplain,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "document-query": {
|
||||
const live = createDocumentQueryTool(
|
||||
flowCtx.flow.requestor<DocumentRagRequest, DocumentRagResponse>("doc-rag"),
|
||||
collection,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "triples-query": {
|
||||
const live = createTriplesQueryTool(
|
||||
flowCtx.flow.requestor<TriplesQueryRequest, TriplesQueryResponse>("triples"),
|
||||
collection,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "mcp-tool": {
|
||||
const live = createMcpTool(
|
||||
flowCtx.flow.requestor<ToolRequest, ToolResponse>("mcp-tool"),
|
||||
tool.name,
|
||||
tool.description,
|
||||
tool.args,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
default:
|
||||
return tool;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: AgentRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
|
||||
|
||||
try {
|
||||
// Accumulate explain data from tool calls for emission after completion
|
||||
const explainEvents: ExplainData[] = [];
|
||||
const onExplain = (data: ExplainData) => {
|
||||
explainEvents.push(data);
|
||||
};
|
||||
|
||||
// Build tools — config-driven or hardcoded fallback
|
||||
let tools: AgentTool[];
|
||||
|
||||
if (this.configuredTools !== null) {
|
||||
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain);
|
||||
} else {
|
||||
// Hardcoded fallback (backward compat)
|
||||
tools = [
|
||||
createKnowledgeQueryTool(
|
||||
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
|
||||
msg.collection,
|
||||
onExplain,
|
||||
),
|
||||
createDocumentQueryTool(
|
||||
flowCtx.flow.requestor<DocumentRagRequest, DocumentRagResponse>("doc-rag"),
|
||||
msg.collection,
|
||||
),
|
||||
createTriplesQueryTool(
|
||||
flowCtx.flow.requestor<TriplesQueryRequest, TriplesQueryResponse>("triples"),
|
||||
msg.collection,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Apply tool filtering by group and state
|
||||
tools = filterToolsByGroupAndState(tools, msg.group, msg.state);
|
||||
|
||||
// Build the ReAct prompt
|
||||
const { system, prompt: initialPrompt } = buildReActPrompt(
|
||||
tools,
|
||||
msg.question,
|
||||
);
|
||||
|
||||
const llmClient = flowCtx.flow.requestor<
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse
|
||||
>("llm");
|
||||
|
||||
// Conversation accumulates the full exchange for multi-turn reasoning
|
||||
let conversation = initialPrompt;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
||||
console.log(
|
||||
`[AgentService] Iteration ${iteration + 1}/${MAX_ITERATIONS} for request ${requestId}`,
|
||||
);
|
||||
|
||||
// Call LLM (non-streaming for MVP)
|
||||
const llmResponse = await llmClient.request({
|
||||
system,
|
||||
prompt: conversation,
|
||||
});
|
||||
|
||||
if (llmResponse.error !== undefined) {
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content: `LLM error: ${llmResponse.error.message}`,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = llmResponse.response;
|
||||
|
||||
// Parse the LLM response with simple line-based parsing
|
||||
const parsed = parseReActResponse(text);
|
||||
|
||||
// Send thought chunk
|
||||
if (parsed.thought.length > 0) {
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "thought",
|
||||
content: parsed.thought,
|
||||
end_of_message: true,
|
||||
});
|
||||
}
|
||||
|
||||
// If we got a final answer, emit explain events then send the answer
|
||||
if (parsed.finalAnswer.length > 0) {
|
||||
// Emit explain events collected from tool calls
|
||||
for (const explain of explainEvents) {
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "explain",
|
||||
content: "",
|
||||
explain_id: explain.explainId,
|
||||
explain_triples: explain.triples,
|
||||
} as AgentResponse);
|
||||
}
|
||||
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "answer",
|
||||
content: parsed.finalAnswer,
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute tool if action was specified
|
||||
if (parsed.action.length > 0 && parsed.actionInput.length > 0) {
|
||||
const tool = tools.find((t) => t.name === parsed.action);
|
||||
let observation: string;
|
||||
|
||||
if (tool !== undefined) {
|
||||
try {
|
||||
observation = await tool.execute(parsed.actionInput);
|
||||
} catch (err) {
|
||||
observation = `Error executing tool: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
} else {
|
||||
observation = `Unknown tool: ${parsed.action}. Available tools: ${tools.map((t) => t.name).join(", ")}`;
|
||||
}
|
||||
|
||||
// Send observation chunk
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "observation",
|
||||
content: observation,
|
||||
end_of_message: true,
|
||||
});
|
||||
|
||||
// Append the full exchange to conversation for the next iteration
|
||||
conversation += `\n${text}\nObservation: ${observation}\n`;
|
||||
} else if (parsed.finalAnswer.length === 0) {
|
||||
// LLM didn't produce a valid action or final answer -- nudge it
|
||||
conversation += `\n${text}\nObservation: You must either use a tool (Action + Action Input) or provide a Final Answer.\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Max iterations reached without a final answer
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content:
|
||||
"Maximum reasoning iterations reached without a final answer. " +
|
||||
"The agent was unable to complete the task within the allowed steps.",
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[AgentService] Error processing request ${requestId}:`, err);
|
||||
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content: `Agent error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(AgentRuntime, this.runtime),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -524,11 +628,13 @@ function parseReActResponse(text: string): {
|
|||
};
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, AgentRuntime>({
|
||||
id: "agent",
|
||||
make: (config) => new AgentService(config),
|
||||
specs: () => makeAgentSpecs(),
|
||||
configHandlers: () => makeAgentConfigHandlers(),
|
||||
layer: () => AgentRuntimeLive,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await AgentService.launch("agent");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,81 +21,86 @@ import {
|
|||
type TextDocument,
|
||||
type Chunk,
|
||||
type Triples,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import { recursiveSplit } from "./recursive-splitter.js";
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 2000;
|
||||
const DEFAULT_CHUNK_OVERLAP = 100;
|
||||
|
||||
const onChunkMessage = Effect.fn("ChunkingService.onMessage")(function* (
|
||||
msg: TextDocument,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const chunkSize = yield* flowCtx.flow.parameterEffect<number>("chunk-size").pipe(
|
||||
Effect.orElseSucceed(() => DEFAULT_CHUNK_SIZE),
|
||||
);
|
||||
const chunkOverlap = yield* flowCtx.flow.parameterEffect<number>("chunk-overlap").pipe(
|
||||
Effect.orElseSucceed(() => DEFAULT_CHUNK_OVERLAP),
|
||||
);
|
||||
|
||||
const text = msg.text;
|
||||
if (text.trim().length === 0) {
|
||||
yield* Effect.logWarning(`[ChunkingService] Empty text received for document ${msg.documentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = recursiveSplit(text, chunkSize, chunkOverlap);
|
||||
|
||||
yield* Effect.log(
|
||||
`[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`,
|
||||
);
|
||||
|
||||
const outputProducer = yield* flowCtx.flow.producerEffect<Chunk>("chunk-output");
|
||||
|
||||
yield* Effect.forEach(
|
||||
chunks,
|
||||
(chunkText) =>
|
||||
outputProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
chunk: chunkText,
|
||||
documentId: msg.documentId,
|
||||
}),
|
||||
{ discard: true },
|
||||
);
|
||||
});
|
||||
|
||||
export const makeChunkingSpecs = (): ReadonlyArray<
|
||||
Spec<never>
|
||||
> => [
|
||||
new ConsumerSpec<TextDocument, FlowResourceNotFoundError | MessagingDeliveryError>(
|
||||
"chunk-input",
|
||||
onChunkMessage,
|
||||
),
|
||||
new ProducerSpec<Chunk>("chunk-output"),
|
||||
new ProducerSpec<Triples>("chunk-triples"),
|
||||
new ParameterSpec("chunk-size"),
|
||||
new ParameterSpec("chunk-overlap"),
|
||||
];
|
||||
|
||||
export class ChunkingService extends FlowProcessor {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<TextDocument, FlowResourceNotFoundError | MessagingDeliveryError>(
|
||||
"chunk-input",
|
||||
this.onMessageEffect.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<Chunk>("chunk-output"));
|
||||
this.registerSpecification(new ProducerSpec<Triples>("chunk-triples"));
|
||||
this.registerSpecification(new ParameterSpec("chunk-size"));
|
||||
this.registerSpecification(new ParameterSpec("chunk-overlap"));
|
||||
for (const spec of makeChunkingSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[ChunkingService] Service initialized");
|
||||
}
|
||||
|
||||
private onMessageEffect(
|
||||
msg: TextDocument,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const chunkSize = yield* flowCtx.flow.parameterEffect<number>("chunk-size").pipe(
|
||||
Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_SIZE)),
|
||||
);
|
||||
const chunkOverlap = yield* flowCtx.flow.parameterEffect<number>("chunk-overlap").pipe(
|
||||
Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_OVERLAP)),
|
||||
);
|
||||
|
||||
const text = msg.text;
|
||||
if (text.trim().length === 0) {
|
||||
yield* Effect.logWarning(`[ChunkingService] Empty text received for document ${msg.documentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = recursiveSplit(text, chunkSize, chunkOverlap);
|
||||
|
||||
yield* Effect.log(
|
||||
`[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`,
|
||||
);
|
||||
|
||||
const outputProducer = yield* flowCtx.flow.producerEffect<Chunk>("chunk-output");
|
||||
|
||||
yield* Effect.forEach(
|
||||
chunks,
|
||||
(chunkText) =>
|
||||
outputProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
chunk: chunkText,
|
||||
documentId: msg.documentId,
|
||||
}),
|
||||
{ discard: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "chunking",
|
||||
make: (config) => new ChunkingService(config),
|
||||
specs: () => makeChunkingSpecs(),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await ChunkingService.launch("chunking");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,8 +44,33 @@ const ConfigPushSchema = S.Struct({
|
|||
config: S.Record(S.String, S.Unknown),
|
||||
});
|
||||
|
||||
const DEFAULT_WORKSPACE = "default";
|
||||
|
||||
interface ConfigKeyLike {
|
||||
type: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface ConfigValueLike {
|
||||
workspace?: string;
|
||||
type: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
type NamespaceStore = Map<string, unknown>;
|
||||
type WorkspaceStore = Map<string, NamespaceStore>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export class ConfigService extends AsyncProcessor {
|
||||
private store = new Map<string, Map<string, unknown>>();
|
||||
private store = new Map<string, WorkspaceStore>();
|
||||
private version = 0;
|
||||
private readonly persistPath: string | null;
|
||||
private consumer: BackendConsumer<ConfigRequest> | null = null;
|
||||
|
|
@ -137,36 +162,146 @@ export class ConfigService extends AsyncProcessor {
|
|||
|
||||
switch (op) {
|
||||
case "get":
|
||||
return this.handleGet(request.keys ?? []);
|
||||
return this.handleGet(request);
|
||||
|
||||
case "put":
|
||||
return await this.handlePut(request.keys ?? [], request.values ?? {});
|
||||
return await this.handlePut(request);
|
||||
|
||||
case "delete":
|
||||
return await this.handleDelete(request.keys ?? []);
|
||||
return await this.handleDelete(request);
|
||||
|
||||
case "list":
|
||||
return this.handleList(request.keys ?? []);
|
||||
return this.handleList(request);
|
||||
|
||||
case "config":
|
||||
return this.handleConfigDump();
|
||||
return this.handleConfigDump(request);
|
||||
|
||||
case "getvalues":
|
||||
return this.handleGetValues(request);
|
||||
|
||||
case "getvalues-all-ws":
|
||||
return this.handleGetValuesAllWorkspaces(request);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown config operation: ${op as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleGet(keys: string[]): ConfigResponse {
|
||||
private requestRecord(request: ConfigRequest): Record<string, unknown> {
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private workspaceFor(request: ConfigRequest): string {
|
||||
return optionalString(this.requestRecord(request).workspace) ?? DEFAULT_WORKSPACE;
|
||||
}
|
||||
|
||||
private workspaceStore(workspace: string, create: boolean): WorkspaceStore | undefined {
|
||||
let store = this.store.get(workspace);
|
||||
if (store === undefined && create) {
|
||||
store = new Map<string, NamespaceStore>();
|
||||
this.store.set(workspace, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
private namespaceStore(
|
||||
workspace: string,
|
||||
namespace: string,
|
||||
create: boolean,
|
||||
): NamespaceStore | undefined {
|
||||
const ws = this.workspaceStore(workspace, create);
|
||||
if (ws === undefined) return undefined;
|
||||
|
||||
let ns = ws.get(namespace);
|
||||
if (ns === undefined && create) {
|
||||
ns = new Map<string, unknown>();
|
||||
ws.set(namespace, ns);
|
||||
}
|
||||
return ns;
|
||||
}
|
||||
|
||||
private rawKeys(request: ConfigRequest): unknown[] {
|
||||
const keys = this.requestRecord(request).keys;
|
||||
return Array.isArray(keys) ? keys : [];
|
||||
}
|
||||
|
||||
private stringKeys(request: ConfigRequest): string[] {
|
||||
return this.rawKeys(request).filter((key): key is string => typeof key === "string");
|
||||
}
|
||||
|
||||
private objectKeys(request: ConfigRequest): ConfigKeyLike[] {
|
||||
return this.rawKeys(request).flatMap((key) => {
|
||||
if (!isRecord(key)) return [];
|
||||
const type = optionalString(key.type);
|
||||
if (type === undefined) return [];
|
||||
const item: ConfigKeyLike = { type };
|
||||
const keyValue = optionalString(key.key);
|
||||
if (keyValue !== undefined) item.key = keyValue;
|
||||
return [item];
|
||||
});
|
||||
}
|
||||
|
||||
private requestType(request: ConfigRequest): string | undefined {
|
||||
return optionalString(this.requestRecord(request).type) ?? this.stringKeys(request)[0];
|
||||
}
|
||||
|
||||
private configValues(request: ConfigRequest): ConfigValueLike[] {
|
||||
const req = this.requestRecord(request);
|
||||
const rawValues = req.values;
|
||||
const workspace = this.workspaceFor(request);
|
||||
|
||||
if (Array.isArray(rawValues)) {
|
||||
return rawValues.flatMap((value) => {
|
||||
if (!isRecord(value)) return [];
|
||||
const type = optionalString(value.type);
|
||||
const key = optionalString(value.key);
|
||||
if (type === undefined || key === undefined) return [];
|
||||
return [{
|
||||
workspace: optionalString(value.workspace) ?? workspace,
|
||||
type,
|
||||
key,
|
||||
value: value.value,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(rawValues)) {
|
||||
const namespace = this.requestType(request);
|
||||
if (namespace === undefined) return [];
|
||||
return Object.entries(rawValues).map(([key, value]) => ({
|
||||
workspace,
|
||||
type: namespace,
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private handleGet(request: ConfigRequest): ConfigResponse {
|
||||
const workspace = this.workspaceFor(request);
|
||||
const objectKeys = this.objectKeys(request);
|
||||
|
||||
if (objectKeys.length > 0) {
|
||||
const values = objectKeys.map((key) => ({
|
||||
type: key.type,
|
||||
key: key.key ?? "",
|
||||
value: key.key !== undefined
|
||||
? this.namespaceStore(workspace, key.type, false)?.get(key.key)
|
||||
: undefined,
|
||||
}));
|
||||
return { version: this.version, values };
|
||||
}
|
||||
|
||||
const keys = this.stringKeys(request);
|
||||
if (keys.length === 0) {
|
||||
return { version: this.version, values: {} };
|
||||
}
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
const namespace = keys[0];
|
||||
const subMap = this.store.get(namespace);
|
||||
const subMap = this.namespaceStore(workspace, namespace, false);
|
||||
|
||||
if (subMap !== undefined) {
|
||||
if (keys.length === 1) {
|
||||
|
|
@ -188,23 +323,12 @@ export class ConfigService extends AsyncProcessor {
|
|||
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)");
|
||||
}
|
||||
private async handlePut(request: ConfigRequest): Promise<ConfigResponse> {
|
||||
const values = this.configValues(request);
|
||||
if (values.length === 0) throw new Error("Put requires config values");
|
||||
|
||||
const namespace = keys[0];
|
||||
let subMap = this.store.get(namespace);
|
||||
if (subMap === undefined) {
|
||||
subMap = new Map<string, unknown>();
|
||||
this.store.set(namespace, subMap);
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
subMap.set(k, v);
|
||||
for (const item of values) {
|
||||
this.namespaceStore(item.workspace ?? DEFAULT_WORKSPACE, item.type, true)?.set(item.key, item.value);
|
||||
}
|
||||
|
||||
this.version++;
|
||||
|
|
@ -214,25 +338,49 @@ export class ConfigService extends AsyncProcessor {
|
|||
return { version: this.version };
|
||||
}
|
||||
|
||||
private async handleDelete(keys: string[]): Promise<ConfigResponse> {
|
||||
private async handleDelete(request: ConfigRequest): Promise<ConfigResponse> {
|
||||
const workspace = this.workspaceFor(request);
|
||||
const objectKeys = this.objectKeys(request);
|
||||
if (objectKeys.length > 0) {
|
||||
for (const key of objectKeys) {
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
if (ws === undefined) continue;
|
||||
if (key.key === undefined) {
|
||||
ws.delete(key.type);
|
||||
} else {
|
||||
const ns = ws.get(key.type);
|
||||
ns?.delete(key.key);
|
||||
if (ns !== undefined && ns.size === 0) ws.delete(key.type);
|
||||
}
|
||||
}
|
||||
|
||||
this.version++;
|
||||
await this.persist();
|
||||
await this.pushConfig();
|
||||
return { version: this.version };
|
||||
}
|
||||
|
||||
const keys = this.stringKeys(request);
|
||||
if (keys.length === 0) {
|
||||
throw new Error("Delete requires at least one key");
|
||||
}
|
||||
|
||||
const namespace = keys[0];
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
if (ws === undefined) return { version: this.version };
|
||||
|
||||
if (keys.length === 1) {
|
||||
// Delete entire namespace
|
||||
this.store.delete(namespace);
|
||||
ws.delete(namespace);
|
||||
} else {
|
||||
// Delete specific keys within namespace
|
||||
const subMap = this.store.get(namespace);
|
||||
const subMap = ws.get(namespace);
|
||||
if (subMap !== undefined) {
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
subMap.delete(keys[i]);
|
||||
}
|
||||
if (subMap.size === 0) {
|
||||
this.store.delete(namespace);
|
||||
ws.delete(namespace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -244,17 +392,20 @@ export class ConfigService extends AsyncProcessor {
|
|||
return { version: this.version };
|
||||
}
|
||||
|
||||
private handleList(keys: string[]): ConfigResponse {
|
||||
if (keys.length === 0) {
|
||||
private handleList(request: ConfigRequest): ConfigResponse {
|
||||
const workspace = this.workspaceFor(request);
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
const namespace = this.requestType(request);
|
||||
|
||||
if (namespace === undefined) {
|
||||
// List all namespaces
|
||||
return {
|
||||
version: this.version,
|
||||
directory: [...this.store.keys()],
|
||||
directory: ws !== undefined ? [...ws.keys()] : [],
|
||||
};
|
||||
}
|
||||
|
||||
const namespace = keys[0];
|
||||
const subMap = this.store.get(namespace);
|
||||
const subMap = ws?.get(namespace);
|
||||
|
||||
return {
|
||||
version: this.version,
|
||||
|
|
@ -263,30 +414,48 @@ export class ConfigService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
private handleGetValues(request: ConfigRequest): ConfigResponse {
|
||||
const type = request.type ?? "";
|
||||
const workspace = this.workspaceFor(request);
|
||||
const type = this.requestType(request) ?? "";
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
|
||||
const values: { key: string; value: unknown }[] = [];
|
||||
const values: { type: string; key: string; value: unknown }[] = [];
|
||||
|
||||
for (const [namespace, subMap] of this.store) {
|
||||
for (const [namespace, subMap] of ws ?? new Map<string, NamespaceStore>()) {
|
||||
if (
|
||||
type.length === 0 ||
|
||||
namespace === type ||
|
||||
namespace.startsWith(`${type}.`) ||
|
||||
namespace.startsWith(`${type}/`)
|
||||
namespace === type
|
||||
) {
|
||||
for (const [k, v] of subMap) {
|
||||
values.push({ key: `${namespace}.${k}`, value: v });
|
||||
values.push({ type: namespace, key: k, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { version: this.version, values: values as unknown as Record<string, unknown> };
|
||||
return { version: this.version, values };
|
||||
}
|
||||
|
||||
private handleConfigDump(): ConfigResponse {
|
||||
private handleGetValuesAllWorkspaces(request: ConfigRequest): ConfigResponse {
|
||||
const type = this.requestType(request) ?? "";
|
||||
const values: { workspace: string; type: string; key: string; value: unknown }[] = [];
|
||||
|
||||
for (const [workspace, ws] of this.store) {
|
||||
for (const [namespace, subMap] of ws) {
|
||||
if (type.length > 0 && namespace !== type) continue;
|
||||
for (const [key, value] of subMap) {
|
||||
values.push({ workspace, type: namespace, key, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { version: this.version, values };
|
||||
}
|
||||
|
||||
private handleConfigDump(request: ConfigRequest): ConfigResponse {
|
||||
const workspace = this.workspaceFor(request);
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
for (const [namespace, subMap] of this.store) {
|
||||
for (const [namespace, subMap] of ws ?? new Map<string, NamespaceStore>()) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of subMap) {
|
||||
obj[k] = v;
|
||||
|
|
@ -305,7 +474,8 @@ export class ConfigService extends AsyncProcessor {
|
|||
if (pushProducer === null) return;
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const [namespace, subMap] of this.store) {
|
||||
const ws = this.workspaceStore(DEFAULT_WORKSPACE, false);
|
||||
for (const [namespace, subMap] of ws ?? new Map<string, NamespaceStore>()) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of subMap) {
|
||||
obj[k] = v;
|
||||
|
|
@ -326,18 +496,22 @@ export class ConfigService extends AsyncProcessor {
|
|||
if (persistPath === null) return;
|
||||
|
||||
try {
|
||||
const data: Record<string, Record<string, unknown>> = {};
|
||||
const workspaces: Record<string, 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;
|
||||
for (const [workspace, ws] of this.store) {
|
||||
const workspaceData: Record<string, Record<string, unknown>> = {};
|
||||
for (const [namespace, subMap] of ws) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of subMap) {
|
||||
obj[k] = v;
|
||||
}
|
||||
workspaceData[namespace] = obj;
|
||||
}
|
||||
data[namespace] = obj;
|
||||
workspaces[workspace] = workspaceData;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(
|
||||
{ version: this.version, data },
|
||||
{ version: this.version, workspaces },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
|
@ -356,22 +530,39 @@ export class ConfigService extends AsyncProcessor {
|
|||
const raw = await readTextFile(persistPath);
|
||||
const parsed = JSON.parse(raw) as {
|
||||
version: number;
|
||||
data: Record<string, Record<string, unknown>>;
|
||||
data?: Record<string, Record<string, unknown>>;
|
||||
workspaces?: Record<string, 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);
|
||||
if (parsed.workspaces !== undefined) {
|
||||
for (const [workspace, namespaces] of Object.entries(parsed.workspaces)) {
|
||||
const ws = new Map<string, NamespaceStore>();
|
||||
for (const [namespace, obj] of Object.entries(namespaces)) {
|
||||
const subMap = new Map<string, unknown>();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
subMap.set(k, v);
|
||||
}
|
||||
ws.set(namespace, subMap);
|
||||
}
|
||||
this.store.set(workspace, ws);
|
||||
}
|
||||
this.store.set(namespace, subMap);
|
||||
} else {
|
||||
const ws = new Map<string, NamespaceStore>();
|
||||
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);
|
||||
}
|
||||
ws.set(namespace, subMap);
|
||||
}
|
||||
this.store.set(DEFAULT_WORKSPACE, ws);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ConfigService] Loaded persisted config (version=${this.version}, namespaces=${this.store.size})`,
|
||||
`[ConfigService] Loaded persisted config (version=${this.version}, workspaces=${this.store.size})`,
|
||||
);
|
||||
} catch {
|
||||
// File doesn't exist yet or is invalid — start fresh
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ import {
|
|||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
|
||||
import { joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js";
|
||||
import { Effect } from "effect";
|
||||
import { ensureDirectory, joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js";
|
||||
|
||||
export interface KnowledgeCoreServiceConfig extends ProcessorConfig {
|
||||
dataDir?: string;
|
||||
|
|
@ -32,9 +33,17 @@ interface KnowledgeCore {
|
|||
graphEmbeddings: { entity: Term; vectors: number[][] }[];
|
||||
}
|
||||
|
||||
interface DocumentEmbeddingsCore {
|
||||
metadata?: Record<string, unknown>;
|
||||
chunks?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class KnowledgeCoreService extends AsyncProcessor {
|
||||
/** Keyed by `${user}:${id}` */
|
||||
private cores = new Map<string, KnowledgeCore>();
|
||||
private deCores = new Map<string, DocumentEmbeddingsCore[]>();
|
||||
private readonly dataDir: string;
|
||||
private readonly persistPath: string;
|
||||
|
||||
private consumer: BackendConsumer<KnowledgeRequest> | null = null;
|
||||
|
|
@ -43,6 +52,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
constructor(config: KnowledgeCoreServiceConfig) {
|
||||
super(config);
|
||||
const dataDir = config.dataDir ?? process.env.KNOWLEDGE_DATA_DIR ?? "./data/knowledge";
|
||||
this.dataDir = dataDir;
|
||||
this.persistPath = joinPath(dataDir, "knowledge-state.json");
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +61,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
protected override async run(): Promise<void> {
|
||||
await ensureDirectory(this.dataDir);
|
||||
// Load persisted state
|
||||
await this.loadFromDisk();
|
||||
|
||||
|
|
@ -116,11 +127,40 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
return this.putKgCore(request, requestId);
|
||||
case "load-kg-core":
|
||||
return this.loadKgCore(request, requestId);
|
||||
case "unload-kg-core":
|
||||
return this.unloadKgCore(request, requestId);
|
||||
case "list-de-cores":
|
||||
return this.listDeCores(request, requestId);
|
||||
case "get-de-core":
|
||||
return this.getDeCore(request, requestId);
|
||||
case "delete-de-core":
|
||||
return this.deleteDeCore(request, requestId);
|
||||
case "put-de-core":
|
||||
return this.putDeCore(request, requestId);
|
||||
case "load-de-core":
|
||||
return this.loadDeCore(request, requestId);
|
||||
default:
|
||||
throw new Error(`Unknown knowledge operation: ${request.operation as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
private requestRecord(request: KnowledgeRequest): Record<string, unknown> {
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private graphEmbeddings(request: KnowledgeRequest): { entity: Term; vectors: number[][] }[] {
|
||||
const req = this.requestRecord(request);
|
||||
const value = request.graphEmbeddings ?? req["graph-embeddings"];
|
||||
return Array.isArray(value) ? value as { entity: Term; vectors: number[][] }[] : [];
|
||||
}
|
||||
|
||||
private documentEmbeddings(request: KnowledgeRequest): DocumentEmbeddingsCore | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
const value = request.documentEmbeddings ?? req["document-embeddings"];
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
||||
return value as DocumentEmbeddingsCore;
|
||||
}
|
||||
|
||||
private async listKgCores(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const prefix = user.length > 0 ? `${user}:` : "";
|
||||
|
|
@ -167,7 +207,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
const isLast = i + BATCH_SIZE >= core.graphEmbeddings.length;
|
||||
|
||||
await this.responseProducer!.send(
|
||||
{ graphEmbeddings: batch, eos: isLast },
|
||||
{ graphEmbeddings: batch, "graph-embeddings": batch, eos: isLast } as KnowledgeResponse,
|
||||
{ id: requestId },
|
||||
);
|
||||
}
|
||||
|
|
@ -207,8 +247,9 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
// Append graph embeddings if provided
|
||||
if (request.graphEmbeddings !== undefined && request.graphEmbeddings.length > 0) {
|
||||
core.graphEmbeddings.push(...request.graphEmbeddings);
|
||||
const graphEmbeddings = this.graphEmbeddings(request);
|
||||
if (graphEmbeddings.length > 0) {
|
||||
core.graphEmbeddings.push(...graphEmbeddings);
|
||||
}
|
||||
|
||||
await this.persist();
|
||||
|
|
@ -229,22 +270,108 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
throw new Error(`Knowledge core not found: ${key}`);
|
||||
}
|
||||
|
||||
// MVP: just acknowledge. Full implementation would publish triples
|
||||
// to flow storage topics via the flow config.
|
||||
if (core.triples.length > 0) {
|
||||
const producer = await this.pubsub.createProducer<unknown>({ topic: "tg.flow.triples" });
|
||||
try {
|
||||
await producer.send({
|
||||
metadata: {
|
||||
id: coreId,
|
||||
root: coreId,
|
||||
user,
|
||||
collection: request.collection ?? "default",
|
||||
},
|
||||
triples: core.triples,
|
||||
});
|
||||
} finally {
|
||||
await producer.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[KnowledgeCoreService] Load requested for core ${key} (triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length}) — returning success`,
|
||||
`[KnowledgeCoreService] Loaded core ${key} (triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length})`,
|
||||
);
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
private async unloadKgCore(_request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
private async listDeCores(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const prefix = user.length > 0 ? `${user}:` : "";
|
||||
const ids = [...this.deCores.keys()]
|
||||
.filter((key) => prefix.length === 0 || key.startsWith(prefix))
|
||||
.map((key) => key.slice(prefix.length));
|
||||
await this.responseProducer!.send({ ids }, { id: requestId });
|
||||
}
|
||||
|
||||
private async getDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const coreId = request.id ?? "";
|
||||
const key = this.coreKey(user, coreId);
|
||||
const core = this.deCores.get(key);
|
||||
if (core === undefined) throw new Error(`Document embeddings core not found: ${key}`);
|
||||
|
||||
for (let i = 0; i < core.length; i++) {
|
||||
const isLast = i === core.length - 1;
|
||||
await this.responseProducer!.send(
|
||||
{
|
||||
documentEmbeddings: core[i],
|
||||
"document-embeddings": core[i],
|
||||
eos: isLast,
|
||||
} as KnowledgeResponse,
|
||||
{ id: requestId },
|
||||
);
|
||||
}
|
||||
if (core.length === 0) {
|
||||
await this.responseProducer!.send({ eos: true }, { id: requestId });
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const coreId = request.id ?? "";
|
||||
this.deCores.delete(this.coreKey(user, coreId));
|
||||
await this.persist();
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
private async putDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const coreId = request.id ?? "";
|
||||
const key = this.coreKey(user, coreId);
|
||||
const item = this.documentEmbeddings(request);
|
||||
if (item === undefined) throw new Error("put-de-core requires document-embeddings");
|
||||
const core = this.deCores.get(key) ?? [];
|
||||
core.push(item);
|
||||
this.deCores.set(key, core);
|
||||
await this.persist();
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
private async loadDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const coreId = request.id ?? "";
|
||||
const key = this.coreKey(user, coreId);
|
||||
if (!this.deCores.has(key)) throw new Error(`Document embeddings core not found: ${key}`);
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
// ---------- Persistence ----------
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
try {
|
||||
// Serialize Map to object
|
||||
const data: Record<string, KnowledgeCore> = {};
|
||||
const data: {
|
||||
kg: Record<string, KnowledgeCore>;
|
||||
de: Record<string, DocumentEmbeddingsCore[]>;
|
||||
} = { kg: {}, de: {} };
|
||||
for (const [key, core] of this.cores) {
|
||||
data[key] = core;
|
||||
data.kg[key] = core;
|
||||
}
|
||||
for (const [key, core] of this.deCores) {
|
||||
data.de[key] = core;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
|
|
@ -257,14 +384,24 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
private async loadFromDisk(): Promise<void> {
|
||||
try {
|
||||
const raw = await readTextFile(this.persistPath);
|
||||
const parsed = JSON.parse(raw) as Record<string, KnowledgeCore>;
|
||||
const parsed = JSON.parse(raw) as Record<string, KnowledgeCore> | {
|
||||
kg?: Record<string, KnowledgeCore>;
|
||||
de?: Record<string, DocumentEmbeddingsCore[]>;
|
||||
};
|
||||
|
||||
this.cores.clear();
|
||||
for (const [key, core] of Object.entries(parsed)) {
|
||||
this.deCores.clear();
|
||||
const kg = "kg" in parsed && parsed.kg !== undefined ? parsed.kg : parsed as Record<string, KnowledgeCore>;
|
||||
for (const [key, core] of Object.entries(kg)) {
|
||||
this.cores.set(key, core);
|
||||
}
|
||||
if ("de" in parsed && parsed.de !== undefined) {
|
||||
for (const [key, core] of Object.entries(parsed.de)) {
|
||||
this.deCores.set(key, core);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[KnowledgeCoreService] Loaded persisted state (cores=${this.cores.size})`);
|
||||
console.log(`[KnowledgeCoreService] Loaded persisted state (kg=${this.cores.size}, de=${this.deCores.size})`);
|
||||
} catch {
|
||||
console.log("[KnowledgeCoreService] No persisted state found, starting fresh");
|
||||
}
|
||||
|
|
@ -293,5 +430,5 @@ export const program = makeProcessorProgram({
|
|||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await KnowledgeCoreService.launch("knowledge-svc");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
RequestResponseSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type Document,
|
||||
type TextDocument,
|
||||
type Triples,
|
||||
|
|
@ -29,170 +30,205 @@ import {
|
|||
type Term,
|
||||
type LibrarianRequest,
|
||||
type LibrarianResponse,
|
||||
type MessagingDeliveryError,
|
||||
type MessagingTimeoutError,
|
||||
type Spec,
|
||||
errorMessage,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export class PdfDecoderError extends S.TaggedErrorClass<PdfDecoderError>()(
|
||||
"PdfDecoderError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
documentId: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
type PdfDecoderHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError
|
||||
| MessagingTimeoutError
|
||||
| PdfDecoderError;
|
||||
|
||||
type PdfDocument = Awaited<ReturnType<typeof getDocument>["promise"]>;
|
||||
|
||||
const pdfDecoderError = (
|
||||
operation: string,
|
||||
documentId: string,
|
||||
cause: unknown,
|
||||
) =>
|
||||
new PdfDecoderError({
|
||||
operation,
|
||||
documentId,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
const loadPdf = (documentId: string, pdfBuffer: Buffer) =>
|
||||
Effect.tryPromise({
|
||||
try: () => getDocument({ data: new Uint8Array(pdfBuffer) }).promise,
|
||||
catch: (cause) => pdfDecoderError("load-pdf", documentId, cause),
|
||||
});
|
||||
|
||||
const loadPageText = (documentId: string, pageNumber: number, pdf: PdfDocument) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const textContent = await page.getTextContent();
|
||||
return textContent.items
|
||||
.filter((item): item is TextItem => "str" in item)
|
||||
.map((item) => item.str)
|
||||
.join(" ");
|
||||
},
|
||||
catch: (cause) => pdfDecoderError("load-page-text", documentId, cause),
|
||||
});
|
||||
|
||||
const onPdfDecodeMessage = Effect.fn("PdfDecoderService.onMessage")(function* (
|
||||
msg: Document,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Effect.fn.Return<void, PdfDecoderHandlerError> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const { documentId } = msg;
|
||||
const user = msg.metadata.user;
|
||||
|
||||
const librarian = yield* flowCtx.flow.requestorEffect<LibrarianRequest, LibrarianResponse>(
|
||||
"librarian-client",
|
||||
);
|
||||
|
||||
const metadataResp = yield* librarian.request({
|
||||
operation: "get-document-metadata",
|
||||
documentId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (metadataResp.error !== undefined) {
|
||||
yield* Effect.logError(`[PdfDecoder] Failed to get metadata for ${documentId}`, {
|
||||
error: metadataResp.error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = metadataResp.documentMetadata?.kind;
|
||||
if (kind !== "application/pdf") {
|
||||
yield* Effect.log(`[PdfDecoder] Skipping document ${documentId}: kind=${kind} (not PDF)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentResp = yield* librarian.request({
|
||||
operation: "get-document-content",
|
||||
documentId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (
|
||||
contentResp.error !== undefined ||
|
||||
contentResp.content === undefined ||
|
||||
contentResp.content.length === 0
|
||||
) {
|
||||
yield* Effect.logError(`[PdfDecoder] Failed to get content for ${documentId}`, {
|
||||
error: contentResp.error?.message ?? "no content",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfBuffer = Buffer.from(contentResp.content, "base64");
|
||||
const pdf = yield* loadPdf(documentId, pdfBuffer);
|
||||
|
||||
yield* Effect.log(`[PdfDecoder] Document ${documentId}: ${pdf.numPages} pages`);
|
||||
|
||||
const outputProducer = yield* flowCtx.flow.producerEffect<TextDocument>("decode-output");
|
||||
const triplesProducer = yield* flowCtx.flow.producerEffect<Triples>("decode-triples");
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const pageText = yield* loadPageText(documentId, i, pdf);
|
||||
|
||||
if (pageText.trim().length === 0) {
|
||||
yield* Effect.log(`[PdfDecoder] Skipping empty page ${i} of document ${documentId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const childResp = yield* librarian.request({
|
||||
operation: "add-child-document",
|
||||
documentMetadata: {
|
||||
id: "",
|
||||
user,
|
||||
kind: "text/plain",
|
||||
title: `Page ${i}`,
|
||||
parentId: documentId,
|
||||
documentType: "page",
|
||||
time: Date.now(),
|
||||
comments: "",
|
||||
tags: [],
|
||||
},
|
||||
content: Buffer.from(pageText).toString("base64"),
|
||||
});
|
||||
|
||||
if (childResp.error !== undefined) {
|
||||
yield* Effect.logError(`[PdfDecoder] Failed to save page ${i} of ${documentId}`, {
|
||||
error: childResp.error.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const childDocId = childResp.documentMetadata?.id ?? "";
|
||||
|
||||
yield* outputProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
text: pageText,
|
||||
documentId: childDocId,
|
||||
});
|
||||
|
||||
const triples: Triple[] = [
|
||||
{
|
||||
s: iriTerm(`urn:tg:page:${childDocId}`),
|
||||
p: iriTerm("http://www.w3.org/ns/prov#wasDerivedFrom"),
|
||||
o: iriTerm(`urn:tg:doc:${documentId}`),
|
||||
},
|
||||
{
|
||||
s: iriTerm(`urn:tg:page:${childDocId}`),
|
||||
p: iriTerm("http://www.w3.org/2000/01/rdf-schema#label"),
|
||||
o: literalTerm(`Page ${i}`),
|
||||
},
|
||||
];
|
||||
|
||||
yield* triplesProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
triples,
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.log(`[PdfDecoder] Finished processing document ${documentId}`);
|
||||
});
|
||||
|
||||
export const makePdfDecoderSpecs = (): ReadonlyArray<Spec<never>> => [
|
||||
new ConsumerSpec<Document, PdfDecoderHandlerError>("decode-input", onPdfDecodeMessage),
|
||||
new ProducerSpec<TextDocument>("decode-output"),
|
||||
new ProducerSpec<Triples>("decode-triples"),
|
||||
new RequestResponseSpec<LibrarianRequest, LibrarianResponse>(
|
||||
"librarian-client",
|
||||
"librarian-request",
|
||||
"librarian-response",
|
||||
),
|
||||
];
|
||||
|
||||
export class PdfDecoderService extends FlowProcessor {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<Document>("decode-input", this.onMessage.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<TextDocument>("decode-output"));
|
||||
this.registerSpecification(new ProducerSpec<Triples>("decode-triples"));
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<LibrarianRequest, LibrarianResponse>(
|
||||
"librarian-client",
|
||||
"librarian-request",
|
||||
"librarian-response",
|
||||
),
|
||||
);
|
||||
for (const spec of makePdfDecoderSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[PdfDecoder] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: Document,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const { documentId } = msg;
|
||||
const user = msg.metadata.user;
|
||||
|
||||
const librarian = flowCtx.flow.requestor<LibrarianRequest, LibrarianResponse>(
|
||||
"librarian-client",
|
||||
);
|
||||
|
||||
// 1. Fetch document metadata to check MIME type
|
||||
const metadataResp = await librarian.request({
|
||||
operation: "get-document-metadata",
|
||||
documentId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (metadataResp.error !== undefined) {
|
||||
console.error(
|
||||
`[PdfDecoder] Failed to get metadata for ${documentId}:`,
|
||||
metadataResp.error.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = metadataResp.documentMetadata?.kind;
|
||||
if (kind !== "application/pdf") {
|
||||
console.log(
|
||||
`[PdfDecoder] Skipping document ${documentId}: kind=${kind} (not PDF)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fetch document content
|
||||
const contentResp = await librarian.request({
|
||||
operation: "get-document-content",
|
||||
documentId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (
|
||||
contentResp.error !== undefined ||
|
||||
contentResp.content === undefined ||
|
||||
contentResp.content.length === 0
|
||||
) {
|
||||
console.error(
|
||||
`[PdfDecoder] Failed to get content for ${documentId}:`,
|
||||
contentResp.error?.message ?? "no content",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Decode base64 content and extract text per page
|
||||
const pdfBuffer = Buffer.from(contentResp.content, "base64");
|
||||
const pdf = await getDocument({ data: new Uint8Array(pdfBuffer) }).promise;
|
||||
|
||||
console.log(
|
||||
`[PdfDecoder] Document ${documentId}: ${pdf.numPages} pages`,
|
||||
);
|
||||
|
||||
const outputProducer = flowCtx.flow.producer<TextDocument>("decode-output");
|
||||
const triplesProducer = flowCtx.flow.producer<Triples>("decode-triples");
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = textContent.items
|
||||
.filter((item): item is TextItem => "str" in item)
|
||||
.map((item) => item.str)
|
||||
.join(" ");
|
||||
|
||||
if (pageText.trim().length === 0) {
|
||||
console.log(
|
||||
`[PdfDecoder] Skipping empty page ${i} of document ${documentId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Save as child document in librarian
|
||||
const childResp = await librarian.request({
|
||||
operation: "add-child-document",
|
||||
documentMetadata: {
|
||||
id: "",
|
||||
user,
|
||||
kind: "text/plain",
|
||||
title: `Page ${i}`,
|
||||
parentId: documentId,
|
||||
documentType: "page",
|
||||
time: Date.now(),
|
||||
comments: "",
|
||||
tags: [],
|
||||
},
|
||||
content: Buffer.from(pageText).toString("base64"),
|
||||
});
|
||||
|
||||
if (childResp.error !== undefined) {
|
||||
console.error(
|
||||
`[PdfDecoder] Failed to save page ${i} of ${documentId}:`,
|
||||
childResp.error.message,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const childDocId = childResp.documentMetadata?.id ?? "";
|
||||
|
||||
// 5. Emit TextDocument for the chunking pipeline
|
||||
await outputProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
text: pageText,
|
||||
documentId: childDocId,
|
||||
});
|
||||
|
||||
// 6. Emit provenance triples
|
||||
const triples: Triple[] = [
|
||||
{
|
||||
s: iriTerm(`urn:tg:page:${childDocId}`),
|
||||
p: iriTerm("http://www.w3.org/ns/prov#wasDerivedFrom"),
|
||||
o: iriTerm(`urn:tg:doc:${documentId}`),
|
||||
},
|
||||
{
|
||||
s: iriTerm(`urn:tg:page:${childDocId}`),
|
||||
p: iriTerm("http://www.w3.org/2000/01/rdf-schema#label"),
|
||||
o: literalTerm(`Page ${i}`),
|
||||
},
|
||||
];
|
||||
|
||||
await triplesProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
triples,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[PdfDecoder] Finished processing document ${documentId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function iriTerm(iri: string): Term {
|
||||
|
|
@ -203,11 +239,11 @@ function literalTerm(value: string): Term {
|
|||
return { type: "LITERAL", value };
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "pdf-decoder",
|
||||
make: (config) => new PdfDecoderService(config),
|
||||
specs: () => makePdfDecoderSpecs(),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await PdfDecoderService.launch("pdf-decoder");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ import {
|
|||
Embeddings,
|
||||
EmbeddingsService,
|
||||
embeddingsError,
|
||||
makeEmbeddingsSpecs,
|
||||
type EmbeddingsServiceShape,
|
||||
type ProcessorConfig,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export interface OllamaEmbeddingsConfig extends ProcessorConfig {
|
||||
model?: string;
|
||||
|
|
@ -102,11 +103,12 @@ export class OllamaEmbeddingsProcessor extends EmbeddingsService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<OllamaEmbeddingsConfig, never, Embeddings>({
|
||||
id: "embeddings",
|
||||
make: (config) => new OllamaEmbeddingsProcessor(config),
|
||||
specs: () => makeEmbeddingsSpecs(),
|
||||
layer: (config) => OllamaEmbeddingsLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OllamaEmbeddingsProcessor.launch("embeddings");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ConsumerSpec,
|
||||
ProducerSpec,
|
||||
RequestResponseSpec,
|
||||
makeFlowProcessorProgram,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type Chunk,
|
||||
|
|
@ -27,229 +28,270 @@ import {
|
|||
type TextCompletionResponse,
|
||||
type Triple,
|
||||
type Term,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type EffectRequestResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
// Well-known RDF/SKOS IRIs
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const SKOS_DEFINITION = "http://www.w3.org/2004/02/skos/core#definition";
|
||||
|
||||
interface ExtractedRelationship {
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
}
|
||||
const ExtractedRelationship = S.Struct({
|
||||
subject: S.String,
|
||||
predicate: S.String,
|
||||
object: S.String,
|
||||
});
|
||||
type ExtractedRelationship = typeof ExtractedRelationship.Type;
|
||||
|
||||
interface ExtractedDefinition {
|
||||
entity: string;
|
||||
definition: string;
|
||||
}
|
||||
const ExtractedRelationshipsFromJson = S.Array(ExtractedRelationship).pipe(S.fromJsonString);
|
||||
const decodeExtractedRelationships = S.decodeUnknownOption(ExtractedRelationshipsFromJson);
|
||||
|
||||
const ExtractedDefinition = S.Struct({
|
||||
entity: S.String,
|
||||
definition: S.String,
|
||||
});
|
||||
type ExtractedDefinition = typeof ExtractedDefinition.Type;
|
||||
|
||||
const ExtractedDefinitionsFromJson = S.Array(ExtractedDefinition).pipe(S.fromJsonString);
|
||||
const decodeExtractedDefinitions = S.decodeUnknownOption(ExtractedDefinitionsFromJson);
|
||||
|
||||
type KnowledgeExtractHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError;
|
||||
|
||||
type PromptClient = EffectRequestResponse<PromptRequest, PromptResponse>;
|
||||
type LlmClient = EffectRequestResponse<TextCompletionRequest, TextCompletionResponse>;
|
||||
|
||||
const requestPrompt = Effect.fn("KnowledgeExtract.requestPrompt")(function* (
|
||||
promptClient: PromptClient,
|
||||
name: string,
|
||||
text: string,
|
||||
) {
|
||||
return yield* promptClient.request(
|
||||
{ name, variables: { text } },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
const requestCompletion = Effect.fn("KnowledgeExtract.requestCompletion")(function* (
|
||||
llmClient: LlmClient,
|
||||
prompt: PromptResponse,
|
||||
) {
|
||||
return yield* llmClient.request(
|
||||
{ system: prompt.system, prompt: prompt.prompt },
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
});
|
||||
|
||||
const extractRelationships = Effect.fn("KnowledgeExtract.extractRelationships")(function* (
|
||||
promptClient: PromptClient,
|
||||
llmClient: LlmClient,
|
||||
text: string,
|
||||
) {
|
||||
const relPrompt = yield* requestPrompt(promptClient, "extract-relationships", text);
|
||||
if (relPrompt.error !== undefined) return null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const relCompletion = yield* requestCompletion(llmClient, relPrompt);
|
||||
|
||||
if (relCompletion.error !== undefined || relCompletion.response.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const relationships = parseRelationshipsResponse(relCompletion.response);
|
||||
if (relationships !== null) return relationships;
|
||||
|
||||
yield* Effect.logWarning(
|
||||
`[KnowledgeExtract] Relationship parse failed, attempt ${attempt + 1}/3`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const extractDefinitions = Effect.fn("KnowledgeExtract.extractDefinitions")(function* (
|
||||
promptClient: PromptClient,
|
||||
llmClient: LlmClient,
|
||||
text: string,
|
||||
) {
|
||||
const defPrompt = yield* requestPrompt(promptClient, "extract-definitions", text);
|
||||
if (defPrompt.error !== undefined) return null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const defCompletion = yield* requestCompletion(llmClient, defPrompt);
|
||||
|
||||
if (defCompletion.error !== undefined || defCompletion.response.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const definitions = parseDefinitionsResponse(defCompletion.response);
|
||||
if (definitions !== null) return definitions;
|
||||
|
||||
yield* Effect.logWarning(
|
||||
`[KnowledgeExtract] Definition parse failed, attempt ${attempt + 1}/3`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const onKnowledgeExtractMessage = Effect.fn("KnowledgeExtractService.onMessage")(function* (
|
||||
msg: Chunk,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Effect.fn.Return<void, KnowledgeExtractHandlerError> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const text = msg.chunk;
|
||||
if (text.trim().length === 0) return;
|
||||
|
||||
const promptClient = yield* flowCtx.flow.requestorEffect<PromptRequest, PromptResponse>("prompt-client");
|
||||
const llmClient = yield* flowCtx.flow.requestorEffect<TextCompletionRequest, TextCompletionResponse>("llm-client");
|
||||
const triplesProducer = yield* flowCtx.flow.producerEffect<Triples>("extract-triples");
|
||||
const entityContextsProducer = yield* flowCtx.flow.producerEffect<EntityContexts>("extract-entity-contexts");
|
||||
|
||||
const allTriples: Triple[] = [];
|
||||
const allEntityContexts: EntityContext[] = [];
|
||||
|
||||
const relationships = yield* extractRelationships(promptClient, llmClient, text).pipe(
|
||||
Effect.catch((error: unknown) =>
|
||||
Effect.logError("[KnowledgeExtract] Relationship extraction failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(Effect.as(null)),
|
||||
),
|
||||
);
|
||||
|
||||
if (relationships !== null) {
|
||||
for (const rel of relationships) {
|
||||
if (
|
||||
rel.subject.length === 0 ||
|
||||
rel.predicate.length === 0 ||
|
||||
rel.object.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subjectIri = toEntityIri(rel.subject);
|
||||
const predicateIri = toEntityIri(rel.predicate);
|
||||
const objectIri = toEntityIri(rel.object);
|
||||
|
||||
allTriples.push({ s: subjectIri, p: predicateIri, o: objectIri });
|
||||
allTriples.push({
|
||||
s: subjectIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.subject),
|
||||
});
|
||||
allTriples.push({
|
||||
s: predicateIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.predicate),
|
||||
});
|
||||
allTriples.push({
|
||||
s: objectIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.object),
|
||||
});
|
||||
|
||||
allEntityContexts.push({
|
||||
entity: subjectIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
allEntityContexts.push({
|
||||
entity: objectIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.log(`[KnowledgeExtract] Extracted ${relationships.length} relationships`);
|
||||
}
|
||||
|
||||
const definitions = yield* extractDefinitions(promptClient, llmClient, text).pipe(
|
||||
Effect.catch((error: unknown) =>
|
||||
Effect.logError("[KnowledgeExtract] Definition extraction failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(Effect.as(null)),
|
||||
),
|
||||
);
|
||||
|
||||
if (definitions !== null) {
|
||||
for (const def of definitions) {
|
||||
if (def.entity.length === 0 || def.definition.length === 0) continue;
|
||||
|
||||
const entityIri = toEntityIri(def.entity);
|
||||
|
||||
allTriples.push({
|
||||
s: entityIri,
|
||||
p: iriTerm(SKOS_DEFINITION),
|
||||
o: literalTerm(def.definition),
|
||||
});
|
||||
allTriples.push({
|
||||
s: entityIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(def.entity),
|
||||
});
|
||||
|
||||
allEntityContexts.push({
|
||||
entity: entityIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.log(`[KnowledgeExtract] Extracted ${definitions.length} definitions`);
|
||||
}
|
||||
|
||||
if (allTriples.length > 0) {
|
||||
yield* triplesProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
triples: allTriples,
|
||||
});
|
||||
}
|
||||
|
||||
if (allEntityContexts.length > 0) {
|
||||
yield* entityContextsProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
entities: allEntityContexts,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const makeKnowledgeExtractSpecs = (): ReadonlyArray<Spec<never>> => [
|
||||
new ConsumerSpec<Chunk, KnowledgeExtractHandlerError>(
|
||||
"extract-input",
|
||||
onKnowledgeExtractMessage,
|
||||
),
|
||||
new ProducerSpec<Triples>("extract-triples"),
|
||||
new ProducerSpec<EntityContexts>("extract-entity-contexts"),
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt-client",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm-client",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
];
|
||||
|
||||
export class KnowledgeExtractService extends FlowProcessor {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<Chunk>("extract-input", this.onMessage.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<Triples>("extract-triples"));
|
||||
this.registerSpecification(new ProducerSpec<EntityContexts>("extract-entity-contexts"));
|
||||
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt-client",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm-client",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
);
|
||||
for (const spec of makeKnowledgeExtractSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[KnowledgeExtract] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: Chunk,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const text = msg.chunk;
|
||||
if (text.trim().length === 0) return;
|
||||
|
||||
const promptClient = flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt-client");
|
||||
const llmClient = flowCtx.flow.requestor<TextCompletionRequest, TextCompletionResponse>("llm-client");
|
||||
const triplesProducer = flowCtx.flow.producer<Triples>("extract-triples");
|
||||
const entityContextsProducer = flowCtx.flow.producer<EntityContexts>("extract-entity-contexts");
|
||||
|
||||
const allTriples: Triple[] = [];
|
||||
const allEntityContexts: EntityContext[] = [];
|
||||
|
||||
// --- Extract relationships ---
|
||||
try {
|
||||
const relPrompt = await promptClient.request(
|
||||
{ name: "extract-relationships", variables: { text } },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
if (relPrompt.error === undefined) {
|
||||
let relationships: ExtractedRelationship[] | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const relCompletion = await llmClient.request(
|
||||
{ system: relPrompt.system, prompt: relPrompt.prompt },
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
|
||||
if (
|
||||
relCompletion.error === undefined &&
|
||||
relCompletion.response.length > 0
|
||||
) {
|
||||
relationships = parseJsonResponse<ExtractedRelationship[]>(relCompletion.response);
|
||||
if (relationships !== null) break;
|
||||
console.warn(`[KnowledgeExtract] Relationship parse failed, attempt ${attempt + 1}/3`);
|
||||
} else {
|
||||
break; // LLM error, don't retry
|
||||
}
|
||||
}
|
||||
|
||||
if (relationships !== null) {
|
||||
for (const rel of relationships) {
|
||||
if (
|
||||
rel.subject.length === 0 ||
|
||||
rel.predicate.length === 0 ||
|
||||
rel.object.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subjectIri = toEntityIri(rel.subject);
|
||||
const predicateIri = toEntityIri(rel.predicate);
|
||||
const objectIri = toEntityIri(rel.object);
|
||||
|
||||
// Main relationship triple
|
||||
allTriples.push({ s: subjectIri, p: predicateIri, o: objectIri });
|
||||
|
||||
// rdfs:label triples for each entity
|
||||
allTriples.push({
|
||||
s: subjectIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.subject),
|
||||
});
|
||||
allTriples.push({
|
||||
s: predicateIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.predicate),
|
||||
});
|
||||
allTriples.push({
|
||||
s: objectIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.object),
|
||||
});
|
||||
|
||||
// Entity contexts for subject and object
|
||||
allEntityContexts.push({
|
||||
entity: subjectIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
allEntityContexts.push({
|
||||
entity: objectIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[KnowledgeExtract] Extracted ${relationships.length} relationships`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[KnowledgeExtract] Relationship extraction failed:", err);
|
||||
}
|
||||
|
||||
// --- Extract definitions ---
|
||||
try {
|
||||
const defPrompt = await promptClient.request(
|
||||
{ name: "extract-definitions", variables: { text } },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
if (defPrompt.error === undefined) {
|
||||
let definitions: ExtractedDefinition[] | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const defCompletion = await llmClient.request(
|
||||
{ system: defPrompt.system, prompt: defPrompt.prompt },
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
|
||||
if (
|
||||
defCompletion.error === undefined &&
|
||||
defCompletion.response.length > 0
|
||||
) {
|
||||
definitions = parseJsonResponse<ExtractedDefinition[]>(defCompletion.response);
|
||||
if (definitions !== null) break;
|
||||
console.warn(`[KnowledgeExtract] Definition parse failed, attempt ${attempt + 1}/3`);
|
||||
} else {
|
||||
break; // LLM error, don't retry
|
||||
}
|
||||
}
|
||||
|
||||
if (definitions !== null) {
|
||||
for (const def of definitions) {
|
||||
if (def.entity.length === 0 || def.definition.length === 0) continue;
|
||||
|
||||
const entityIri = toEntityIri(def.entity);
|
||||
|
||||
// Definition triple
|
||||
allTriples.push({
|
||||
s: entityIri,
|
||||
p: iriTerm(SKOS_DEFINITION),
|
||||
o: literalTerm(def.definition),
|
||||
});
|
||||
|
||||
// Label triple
|
||||
allTriples.push({
|
||||
s: entityIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(def.entity),
|
||||
});
|
||||
|
||||
// Entity context
|
||||
allEntityContexts.push({
|
||||
entity: entityIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[KnowledgeExtract] Extracted ${definitions.length} definitions`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[KnowledgeExtract] Definition extraction failed:", err);
|
||||
}
|
||||
|
||||
// --- Emit results ---
|
||||
if (allTriples.length > 0) {
|
||||
await triplesProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
triples: allTriples,
|
||||
});
|
||||
}
|
||||
|
||||
if (allEntityContexts.length > 0) {
|
||||
await entityContextsProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
entities: allEntityContexts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
|
@ -275,53 +317,68 @@ function literalTerm(value: string): Term {
|
|||
* Uses progressive fallback: direct parse, array extraction, truncated array repair, single object wrap.
|
||||
*/
|
||||
export function parseJsonResponse<T>(raw: string): T | null {
|
||||
// Attempt 1: direct parse after stripping fences
|
||||
let cleaned = raw.trim();
|
||||
const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
||||
if (fenceMatch !== null) {
|
||||
cleaned = (fenceMatch[1] ?? "").trim();
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(cleaned) as T;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Attempt 2: extract first JSON array from the text
|
||||
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
||||
if (arrayMatch !== null) {
|
||||
try {
|
||||
return JSON.parse(arrayMatch[0]) as T;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Attempt 3: try to fix truncated array by closing it after the last complete object
|
||||
const partial = arrayMatch[0];
|
||||
const lastBrace = partial.lastIndexOf('}');
|
||||
if (lastBrace > 0) {
|
||||
const truncated = partial.slice(0, lastBrace + 1) + ']';
|
||||
try {
|
||||
return JSON.parse(truncated) as T;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt 4: extract first JSON object, wrap in array
|
||||
const objMatch = cleaned.match(/\{[\s\S]*?\}/);
|
||||
if (objMatch !== null) {
|
||||
try {
|
||||
const obj = JSON.parse(objMatch[0]);
|
||||
return [obj] as unknown as T;
|
||||
} catch { /* fall through */ }
|
||||
const decodeJson = S.decodeUnknownOption(S.UnknownFromJsonString);
|
||||
for (const candidate of jsonCandidates(raw)) {
|
||||
const decoded = decodeJson(candidate);
|
||||
if (O.isSome(decoded)) return decoded.value as T;
|
||||
}
|
||||
|
||||
console.warn("[KnowledgeExtract] Failed to parse JSON from LLM response:", raw.slice(0, 300));
|
||||
return null;
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
function parseRelationshipsResponse(raw: string): ReadonlyArray<ExtractedRelationship> | null {
|
||||
for (const candidate of jsonCandidates(raw)) {
|
||||
const decoded = decodeExtractedRelationships(candidate);
|
||||
if (O.isSome(decoded)) return decoded.value;
|
||||
}
|
||||
console.warn("[KnowledgeExtract] Failed to parse relationships from LLM response:", raw.slice(0, 300));
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDefinitionsResponse(raw: string): ReadonlyArray<ExtractedDefinition> | null {
|
||||
for (const candidate of jsonCandidates(raw)) {
|
||||
const decoded = decodeExtractedDefinitions(candidate);
|
||||
if (O.isSome(decoded)) return decoded.value;
|
||||
}
|
||||
console.warn("[KnowledgeExtract] Failed to parse definitions from LLM response:", raw.slice(0, 300));
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsonCandidates(raw: string): ReadonlyArray<string> {
|
||||
const candidates: string[] = [];
|
||||
let cleaned = raw.trim();
|
||||
const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
||||
if (fenceMatch !== null) {
|
||||
cleaned = (fenceMatch[1] ?? "").trim();
|
||||
}
|
||||
|
||||
candidates.push(cleaned);
|
||||
|
||||
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
||||
if (arrayMatch !== null) {
|
||||
candidates.push(arrayMatch[0]);
|
||||
|
||||
const partial = arrayMatch[0];
|
||||
const lastBrace = partial.lastIndexOf("}");
|
||||
if (lastBrace > 0) {
|
||||
candidates.push(partial.slice(0, lastBrace + 1) + "]");
|
||||
}
|
||||
}
|
||||
|
||||
const objMatch = cleaned.match(/\{[\s\S]*?\}/);
|
||||
if (objMatch !== null) {
|
||||
candidates.push(`[${objMatch[0]}]`);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "knowledge-extract",
|
||||
make: (config) => new KnowledgeExtractService(config),
|
||||
specs: () => makeKnowledgeExtractSpecs(),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await KnowledgeExtractService.launch("knowledge-extract");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
BackendConsumer,
|
||||
Message,
|
||||
} from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
|
||||
// ---------- Internal state types ----------
|
||||
|
||||
|
|
@ -35,13 +36,48 @@ interface FlowInstance {
|
|||
id: string;
|
||||
blueprintName: string;
|
||||
description: string;
|
||||
parameters: Record<string, string>;
|
||||
parameters: Record<string, unknown>;
|
||||
status: "running" | "stopped";
|
||||
}
|
||||
|
||||
interface Blueprint {
|
||||
description: string;
|
||||
topics: Record<string, string>;
|
||||
parameters?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ConfigValueEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function configValues(response: ConfigResponse): ConfigValueEntry[] {
|
||||
const values = response.values;
|
||||
if (!Array.isArray(values)) return [];
|
||||
return values.flatMap((value) => {
|
||||
if (!isRecord(value)) return [];
|
||||
const key = optionalString(value.key);
|
||||
if (key === undefined) return [];
|
||||
return [{ key, value: value.value }];
|
||||
});
|
||||
}
|
||||
|
||||
function parseConfigRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const parsed = typeof value === "string" ? JSON.parse(value) as unknown : value;
|
||||
return isRecord(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Default blueprint ----------
|
||||
|
|
@ -122,6 +158,8 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
subscription: `${this.config.id}-config-client`,
|
||||
});
|
||||
await this.configClient.start();
|
||||
await this.ensureDefaultBlueprint();
|
||||
await this.refreshBlueprintsFromConfig();
|
||||
|
||||
// Create producer for flow-response topic
|
||||
this.responseProducer = await this.pubsub.createProducer<Record<string, unknown>>({
|
||||
|
|
@ -178,15 +216,101 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
private async configRequest(request: ConfigRequest): Promise<ConfigResponse> {
|
||||
if (this.configClient === null) throw new Error("Config client not started");
|
||||
return this.configClient.request(request);
|
||||
}
|
||||
|
||||
private async ensureDefaultBlueprint(): Promise<void> {
|
||||
const response = await this.configRequest({
|
||||
operation: "getvalues",
|
||||
type: "flow-blueprint",
|
||||
});
|
||||
if (configValues(response).some((value) => value.key === "default")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.configRequest({
|
||||
operation: "put",
|
||||
keys: ["flow-blueprint"],
|
||||
values: {
|
||||
default: JSON.stringify(DEFAULT_BLUEPRINT),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshBlueprintsFromConfig(): Promise<void> {
|
||||
const response = await this.configRequest({
|
||||
operation: "getvalues",
|
||||
type: "flow-blueprint",
|
||||
});
|
||||
const next = new Map<string, Blueprint>();
|
||||
|
||||
for (const item of configValues(response)) {
|
||||
const parsed = parseConfigRecord(item.value);
|
||||
if (parsed === undefined) continue;
|
||||
next.set(item.key, parsed as Blueprint);
|
||||
}
|
||||
|
||||
if (!next.has("default")) {
|
||||
next.set("default", DEFAULT_BLUEPRINT);
|
||||
}
|
||||
this.blueprints = next;
|
||||
}
|
||||
|
||||
private async refreshFlowsFromConfig(): Promise<void> {
|
||||
const response = await this.configRequest({
|
||||
operation: "getvalues",
|
||||
type: "flow",
|
||||
});
|
||||
const next = new Map<string, FlowInstance>();
|
||||
|
||||
for (const item of configValues(response)) {
|
||||
const parsed = parseConfigRecord(item.value);
|
||||
if (parsed === undefined) continue;
|
||||
const parameters = isRecord(parsed.parameters) ? parsed.parameters : {};
|
||||
next.set(item.key, {
|
||||
id: item.key,
|
||||
blueprintName: optionalString(parsed["blueprint-name"]) ?? optionalString(parsed.blueprintName) ?? "default",
|
||||
description: optionalString(parsed.description) ?? "",
|
||||
parameters,
|
||||
status: "running",
|
||||
});
|
||||
}
|
||||
|
||||
if (next.size === 0) {
|
||||
const flowsResponse = await this.configRequest({
|
||||
operation: "getvalues",
|
||||
type: "flows",
|
||||
});
|
||||
for (const item of configValues(flowsResponse)) {
|
||||
next.set(item.key, {
|
||||
id: item.key,
|
||||
blueprintName: "default",
|
||||
description: "",
|
||||
parameters: {},
|
||||
status: "running",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.flows = next;
|
||||
}
|
||||
|
||||
private async handleOperation(
|
||||
request: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const op = request.operation as string;
|
||||
await this.refreshBlueprintsFromConfig();
|
||||
await this.refreshFlowsFromConfig();
|
||||
|
||||
switch (op) {
|
||||
case "list-blueprints":
|
||||
return this.handleListBlueprints();
|
||||
|
||||
case "put-blueprint":
|
||||
return await this.handlePutBlueprint(request);
|
||||
|
||||
case "get-blueprint":
|
||||
return this.handleGetBlueprint(request);
|
||||
|
||||
|
|
@ -236,9 +360,33 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
};
|
||||
}
|
||||
|
||||
private handleDeleteBlueprint(
|
||||
private async handlePutBlueprint(
|
||||
request: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
): Promise<Record<string, unknown>> {
|
||||
const name = request["blueprint-name"] as string | undefined;
|
||||
if (name === undefined || name.length === 0) {
|
||||
throw new Error("Missing blueprint-name");
|
||||
}
|
||||
const rawDefinition = request["blueprint-definition"];
|
||||
if (rawDefinition === undefined) {
|
||||
throw new Error("Missing blueprint-definition");
|
||||
}
|
||||
const definition = typeof rawDefinition === "string"
|
||||
? rawDefinition
|
||||
: JSON.stringify(rawDefinition);
|
||||
|
||||
await this.configRequest({
|
||||
operation: "put",
|
||||
keys: ["flow-blueprint"],
|
||||
values: { [name]: definition },
|
||||
});
|
||||
await this.refreshBlueprintsFromConfig();
|
||||
return {};
|
||||
}
|
||||
|
||||
private async handleDeleteBlueprint(
|
||||
request: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const name = request["blueprint-name"] as string | undefined;
|
||||
if (name === undefined || name.length === 0) {
|
||||
throw new Error("Missing blueprint-name");
|
||||
|
|
@ -248,10 +396,11 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
throw new Error("Cannot delete the default blueprint");
|
||||
}
|
||||
|
||||
const existed = this.blueprints.delete(name);
|
||||
if (!existed) {
|
||||
throw new Error(`Blueprint not found: ${name}`);
|
||||
}
|
||||
await this.configRequest({
|
||||
operation: "delete",
|
||||
keys: ["flow-blueprint", name],
|
||||
});
|
||||
this.blueprints.delete(name);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
|
@ -292,7 +441,7 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
const id = request["flow-id"] as string | undefined;
|
||||
const blueprintName = (request["blueprint-name"] as string) ?? "default";
|
||||
const description = (request["description"] as string) ?? "";
|
||||
const parameters = (request["parameters"] as Record<string, string>) ?? {};
|
||||
const parameters = (request["parameters"] as Record<string, unknown>) ?? {};
|
||||
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("Missing flow-id");
|
||||
|
|
@ -342,13 +491,15 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
|
||||
this.flows.delete(id);
|
||||
|
||||
console.log(`[FlowManager] Stopped flow "${id}"`);
|
||||
console.log(`[FlowManager] Stopped flow "${id}"`);
|
||||
|
||||
// Push updated flows config (without the removed flow)
|
||||
await this.pushFlowsConfig();
|
||||
await this.deleteFlowConfig(id);
|
||||
|
||||
return {};
|
||||
}
|
||||
// Push updated flows config (without the removed flow)
|
||||
await this.pushFlowsConfig();
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// ---------- Config push ----------
|
||||
|
||||
|
|
@ -360,10 +511,16 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
if (this.configClient === null) return;
|
||||
|
||||
const flowsConfig: Record<string, { topics: Record<string, string> }> = {};
|
||||
const flowRecords: Record<string, string> = {};
|
||||
for (const [id, inst] of this.flows) {
|
||||
const blueprint = this.blueprints.get(inst.blueprintName);
|
||||
if (blueprint !== undefined) {
|
||||
flowsConfig[id] = { topics: blueprint.topics };
|
||||
flowRecords[id] = JSON.stringify({
|
||||
"blueprint-name": inst.blueprintName,
|
||||
description: inst.description,
|
||||
parameters: inst.parameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +530,11 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
keys: ["flows"],
|
||||
values: flowsConfig,
|
||||
});
|
||||
await this.configClient.request({
|
||||
operation: "put",
|
||||
keys: ["flow"],
|
||||
values: flowRecords,
|
||||
});
|
||||
console.log(
|
||||
`[FlowManager] Pushed flows config (${this.flows.size} active flows)`,
|
||||
);
|
||||
|
|
@ -381,6 +543,18 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
private async deleteFlowConfig(id: string): Promise<void> {
|
||||
if (this.configClient === null) return;
|
||||
await this.configClient.request({
|
||||
operation: "delete",
|
||||
keys: ["flows", id],
|
||||
});
|
||||
await this.configClient.request({
|
||||
operation: "delete",
|
||||
keys: ["flow", id],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Lifecycle ----------
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
|
|
@ -410,5 +584,5 @@ export const program = makeProcessorProgram({
|
|||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await FlowManagerService.launch("flow-manager");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* WebSocket multiplexer — handles concurrent requests over a single connection.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/mux.py
|
||||
*/
|
||||
|
||||
import { AsyncQueue } from "@trustgraph/base";
|
||||
|
||||
const MAX_OUTSTANDING = 15;
|
||||
const MAX_QUEUE_SIZE = 10;
|
||||
|
||||
export interface MuxRequest {
|
||||
id: string;
|
||||
service: string;
|
||||
flow?: string;
|
||||
request: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type MuxHandler = (
|
||||
request: MuxRequest,
|
||||
respond: (response: unknown, complete: boolean) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
|
||||
export class Mux {
|
||||
private queue = new AsyncQueue<MuxRequest>();
|
||||
private outstanding = 0;
|
||||
private running = true;
|
||||
private readonly handler: MuxHandler;
|
||||
|
||||
constructor(handler: MuxHandler) {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
receive(request: MuxRequest): void {
|
||||
if (this.queue.length >= MAX_QUEUE_SIZE) {
|
||||
console.warn("[Mux] Queue full, dropping request:", request.id);
|
||||
return;
|
||||
}
|
||||
this.queue.push(request);
|
||||
}
|
||||
|
||||
async run(send: (data: string) => void): Promise<void> {
|
||||
while (this.running) {
|
||||
if (this.outstanding >= MAX_OUTSTANDING) {
|
||||
await sleep(50);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const request = await this.queue.pop(1000);
|
||||
this.outstanding++;
|
||||
|
||||
// Fire and forget — error handling inside
|
||||
this.processRequest(request, send).finally(() => {
|
||||
this.outstanding--;
|
||||
});
|
||||
} catch {
|
||||
// Timeout on queue pop — just loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
private async processRequest(
|
||||
request: MuxRequest,
|
||||
send: (data: string) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.handler(request, async (response, complete) => {
|
||||
send(JSON.stringify({ id: request.id, response, complete }));
|
||||
});
|
||||
} catch (err) {
|
||||
send(
|
||||
JSON.stringify({
|
||||
id: request.id,
|
||||
error: { type: "internal", message: String(err) },
|
||||
complete: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
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,
|
||||
|
|
|
|||
35
ts/packages/flow/src/gateway/rpc-contract.ts
Normal file
35
ts/packages/flow/src/gateway/rpc-contract.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Schema as S } from "effect";
|
||||
import * as Rpc from "effect/unstable/rpc/Rpc";
|
||||
import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
|
||||
|
||||
export class DispatchPayload extends S.Class<DispatchPayload>("DispatchPayload")({
|
||||
scope: S.Literals(["global", "flow"]),
|
||||
service: S.String,
|
||||
flow: S.optionalKey(S.String),
|
||||
request: S.Record(S.String, S.Unknown),
|
||||
}) {}
|
||||
|
||||
export class DispatchStreamChunk extends S.Class<DispatchStreamChunk>("DispatchStreamChunk")({
|
||||
response: S.Unknown,
|
||||
complete: S.Boolean,
|
||||
}) {}
|
||||
|
||||
export class DispatchError extends S.ErrorClass<DispatchError>("DispatchError")({
|
||||
_tag: S.tag("DispatchError"),
|
||||
message: S.String,
|
||||
}) {}
|
||||
|
||||
export class Dispatch extends Rpc.make("Dispatch", {
|
||||
payload: DispatchPayload,
|
||||
success: S.Unknown,
|
||||
error: DispatchError,
|
||||
}) {}
|
||||
|
||||
export class DispatchStream extends Rpc.make("DispatchStream", {
|
||||
payload: DispatchPayload,
|
||||
success: DispatchStreamChunk,
|
||||
error: DispatchError,
|
||||
stream: true,
|
||||
}) {}
|
||||
|
||||
export const TrustGraphRpcs = RpcGroup.make(Dispatch, DispatchStream);
|
||||
92
ts/packages/flow/src/gateway/rpc-protocol.ts
Normal file
92
ts/packages/flow/src/gateway/rpc-protocol.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Effect, Queue, Scope } from "effect";
|
||||
import * as RpcMessage from "effect/unstable/rpc/RpcMessage";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
||||
import * as Socket from "effect/unstable/socket/Socket";
|
||||
|
||||
export const makeSocketRpcProtocol = Effect.gen(function* () {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization;
|
||||
const disconnects = yield* Queue.make<number>();
|
||||
|
||||
let nextClientId = 0;
|
||||
const clients = new Map<number, {
|
||||
readonly write: (response: RpcMessage.FromServerEncoded) => Effect.Effect<void>;
|
||||
}>();
|
||||
const clientIds = new Set<number>();
|
||||
|
||||
let writeRequest!: (
|
||||
clientId: number,
|
||||
message: RpcMessage.FromClientEncoded,
|
||||
) => Effect.Effect<void>;
|
||||
|
||||
const onSocket = function* (
|
||||
socket: Socket.Socket,
|
||||
headers?: ReadonlyArray<[string, string]>,
|
||||
) {
|
||||
const scope = yield* Effect.scope;
|
||||
const parser = serialization.makeUnsafe();
|
||||
const clientId = nextClientId++;
|
||||
|
||||
yield* Scope.addFinalizerExit(scope, () => {
|
||||
clients.delete(clientId);
|
||||
clientIds.delete(clientId);
|
||||
return Queue.offer(disconnects, clientId);
|
||||
});
|
||||
|
||||
const writeRaw = yield* socket.writer;
|
||||
const write = (response: RpcMessage.FromServerEncoded) => {
|
||||
try {
|
||||
const encoded = parser.encode(response);
|
||||
if (encoded === undefined) return Effect.void;
|
||||
return Effect.orDie(writeRaw(encoded));
|
||||
} catch (cause) {
|
||||
return Effect.orDie(
|
||||
writeRaw(parser.encode(RpcMessage.ResponseDefectEncoded(cause))!),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
clients.set(clientId, { write });
|
||||
clientIds.add(clientId);
|
||||
|
||||
yield* socket.runRaw((data) => {
|
||||
try {
|
||||
const decoded = parser.decode(data) as ReadonlyArray<RpcMessage.FromClientEncoded>;
|
||||
return Effect.forEach(decoded, (message) => {
|
||||
if (message._tag === "Request" && headers !== undefined) {
|
||||
return writeRequest(clientId, {
|
||||
...message,
|
||||
headers: headers.concat(message.headers),
|
||||
});
|
||||
}
|
||||
return writeRequest(clientId, message);
|
||||
}, { discard: true });
|
||||
} catch (cause) {
|
||||
return writeRaw(parser.encode(RpcMessage.ResponseDefectEncoded(cause))!);
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.orDie,
|
||||
);
|
||||
};
|
||||
|
||||
const protocol = yield* RpcServer.Protocol.make((writeRequest_) => {
|
||||
writeRequest = writeRequest_;
|
||||
return Effect.succeed({
|
||||
disconnects,
|
||||
send: (clientId, response) => {
|
||||
const client = clients.get(clientId);
|
||||
if (client === undefined) return Effect.void;
|
||||
return Effect.orDie(client.write(response));
|
||||
},
|
||||
end: () => Effect.void,
|
||||
clientIds: Effect.sync(() => clientIds),
|
||||
initialMessage: Effect.succeedNone,
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
supportsSpanPropagation: true,
|
||||
});
|
||||
});
|
||||
|
||||
return { onSocket, protocol } as const;
|
||||
});
|
||||
109
ts/packages/flow/src/gateway/rpc-server.ts
Normal file
109
ts/packages/flow/src/gateway/rpc-server.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { Cause, Effect, Layer, Queue, Scope } from "effect";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
||||
import type * as Socket from "effect/unstable/socket/Socket";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import type { DispatcherManager } from "./dispatch/manager.js";
|
||||
import { DispatchError, DispatchPayload, DispatchStreamChunk, TrustGraphRpcs } from "./rpc-contract.js";
|
||||
import { makeSocketRpcProtocol } from "./rpc-protocol.js";
|
||||
|
||||
export interface GatewayRpcServer {
|
||||
readonly onSocket: (
|
||||
socket: Socket.Socket,
|
||||
headers?: ReadonlyArray<[string, string]>,
|
||||
) => Effect.Effect<void, never, Scope.Scope>;
|
||||
}
|
||||
|
||||
export const makeGatewayRpcServer = Effect.fn("makeGatewayRpcServer")(function* (
|
||||
dispatcher: DispatcherManager,
|
||||
) {
|
||||
const { onSocket, protocol } = yield* makeSocketRpcProtocol;
|
||||
|
||||
const serverLayer = RpcServer.layer(TrustGraphRpcs, {
|
||||
disableFatalDefects: true,
|
||||
}).pipe(
|
||||
Layer.provide(Layer.succeed(RpcServer.Protocol, protocol)),
|
||||
Layer.provide(makeGatewayRpcHandlers(dispatcher)),
|
||||
Layer.provide(RpcSerialization.layerNdjson),
|
||||
);
|
||||
|
||||
yield* Layer.launch(serverLayer).pipe(Effect.forkScoped);
|
||||
|
||||
return {
|
||||
onSocket: Effect.fn("GatewayRpc.onSocket")(function* (socket, headers) {
|
||||
yield* onSocket(socket, headers);
|
||||
}),
|
||||
} satisfies GatewayRpcServer;
|
||||
});
|
||||
|
||||
const makeGatewayRpcHandlers = (dispatcher: DispatcherManager) =>
|
||||
TrustGraphRpcs.toLayer(Effect.succeed(
|
||||
TrustGraphRpcs.of({
|
||||
Dispatch: (payload) =>
|
||||
Effect.tryPromise({
|
||||
try: () => dispatchOne(dispatcher, payload),
|
||||
catch: (cause) => new DispatchError({ message: errorMessage(cause) }),
|
||||
}),
|
||||
DispatchStream: Effect.fn("GatewayRpc.DispatchStream")(function* (payload) {
|
||||
const context = yield* Effect.context<never>();
|
||||
const runPromise = Effect.runPromiseWith(context);
|
||||
const queue = yield* Queue.bounded<DispatchStreamChunk, DispatchError | Cause.Done>(16);
|
||||
yield* Effect.addFinalizer(() => Queue.shutdown(queue));
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
dispatchStream(dispatcher, payload, async (response, complete) => {
|
||||
await runPromise(Queue.offer(queue, new DispatchStreamChunk({ response, complete })));
|
||||
return complete;
|
||||
}),
|
||||
catch: (cause) => new DispatchError({ message: errorMessage(cause) }),
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Queue.end(queue)),
|
||||
Effect.catch((error) => Queue.fail(queue, error)),
|
||||
Effect.forkScoped,
|
||||
);
|
||||
|
||||
return queue;
|
||||
}),
|
||||
}),
|
||||
));
|
||||
|
||||
function dispatchOne(
|
||||
dispatcher: DispatcherManager,
|
||||
payload: DispatchPayload,
|
||||
): Promise<unknown> {
|
||||
if (payload.scope === "flow") {
|
||||
return dispatcher.dispatchFlowService(
|
||||
payload.flow ?? "default",
|
||||
payload.service,
|
||||
payload.request,
|
||||
);
|
||||
}
|
||||
return dispatcher.dispatchGlobalService(payload.service, payload.request);
|
||||
}
|
||||
|
||||
async function dispatchStream(
|
||||
dispatcher: DispatcherManager,
|
||||
payload: DispatchPayload,
|
||||
responder: (response: unknown, complete: boolean) => Promise<boolean>,
|
||||
): Promise<void> {
|
||||
const send = async (response: unknown, complete: boolean) => {
|
||||
await responder(response, complete);
|
||||
};
|
||||
|
||||
if (payload.scope === "flow") {
|
||||
await dispatcher.dispatchFlowServiceStreaming(
|
||||
payload.flow ?? "default",
|
||||
payload.service,
|
||||
payload.request,
|
||||
send,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatcher.dispatchGlobalServiceStreaming(
|
||||
payload.service,
|
||||
payload.request,
|
||||
send,
|
||||
);
|
||||
}
|
||||
|
|
@ -2,19 +2,20 @@
|
|||
* 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).
|
||||
* Uses Effect RPC over WebSocket for streaming client requests.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/gateway/service.py
|
||||
*/
|
||||
|
||||
import Fastify from "fastify";
|
||||
import websocketPlugin from "@fastify/websocket";
|
||||
import { Config, Effect } from "effect";
|
||||
import { Config, Effect, Exit, Scope } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import { errorMessage, optionalStringConfig, registry, toTgError } from "@trustgraph/base";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as EffectSocket from "effect/unstable/socket/Socket";
|
||||
import { optionalStringConfig, registry, toTgError } from "@trustgraph/base";
|
||||
import { DispatcherManager } from "./dispatch/manager.js";
|
||||
import { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
|
||||
import { makeGatewayRpcServer } from "./rpc-server.js";
|
||||
|
||||
export interface GatewayConfig {
|
||||
port: number;
|
||||
|
|
@ -29,11 +30,18 @@ export async function createGateway(config: GatewayConfig) {
|
|||
|
||||
const dispatcher = new DispatcherManager(config);
|
||||
await dispatcher.start();
|
||||
const rpcScope = await Effect.runPromise(Scope.make());
|
||||
const rpcServer = await Effect.runPromise(
|
||||
makeGatewayRpcServer(dispatcher).pipe(
|
||||
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.ndjson),
|
||||
Scope.provide(rpcScope),
|
||||
),
|
||||
);
|
||||
|
||||
// Authentication middleware
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
if (request.url === "/api/v1/metrics") return;
|
||||
if (request.url.startsWith("/api/v1/socket")) return; // Socket auth via query param
|
||||
if (request.url.startsWith("/api/v1/rpc")) return; // RPC socket auth via query param
|
||||
|
||||
if (config.secret !== undefined && config.secret.length > 0) {
|
||||
const auth = request.headers.authorization;
|
||||
|
|
@ -43,6 +51,38 @@ export async function createGateway(config: GatewayConfig) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
scope?: string;
|
||||
service?: string;
|
||||
flow?: string;
|
||||
request?: Record<string, unknown>;
|
||||
};
|
||||
}>("/api/v1/workbench/dispatch", async (request, reply) => {
|
||||
const body = request.body;
|
||||
const service = body.service;
|
||||
const payload = body.request;
|
||||
if (service === undefined || service.length === 0 || payload === undefined) {
|
||||
return reply.code(400).send({
|
||||
error: { type: "bad-request", message: "service and request are required" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = body.scope === "flow"
|
||||
? await dispatcher.dispatchFlowService(body.flow ?? "default", service, payload)
|
||||
: await dispatcher.dispatchGlobalService(service, payload);
|
||||
const err = (result as Record<string, unknown>)?.error as { type?: string; message?: string } | undefined;
|
||||
if (err !== undefined) {
|
||||
const statusCode = err.type === "not-found" ? 404 : 400;
|
||||
return reply.code(statusCode).send(result);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
reply.code(500).send({ error: toTgError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// REST endpoint: POST /api/v1/:kind (global services)
|
||||
app.post<{ Params: { kind: string } }>("/api/v1/:kind", async (request, reply) => {
|
||||
const { kind } = request.params;
|
||||
|
|
@ -124,10 +164,8 @@ 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
|
||||
// Effect RPC WebSocket endpoint: /api/v1/rpc
|
||||
app.get("/api/v1/rpc", { websocket: true }, (socket, request) => {
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get("token");
|
||||
if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) {
|
||||
|
|
@ -135,91 +173,18 @@ export async function createGateway(config: GatewayConfig) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Build the MuxHandler that dispatches to the DispatcherManager
|
||||
const handler: MuxHandler = async (muxReq, respond) => {
|
||||
if (muxReq.flow !== undefined && muxReq.flow.length > 0) {
|
||||
await dispatcher.dispatchFlowServiceStreaming(
|
||||
muxReq.flow,
|
||||
muxReq.service,
|
||||
muxReq.request,
|
||||
respond,
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const effectSocket = yield* EffectSocket.fromWebSocket(
|
||||
Effect.succeed(socket as unknown as globalThis.WebSocket),
|
||||
{ closeCodeIsError: (code) => code !== 1000 },
|
||||
);
|
||||
} else {
|
||||
await dispatcher.dispatchGlobalServiceStreaming(
|
||||
muxReq.service,
|
||||
muxReq.request,
|
||||
respond,
|
||||
);
|
||||
}
|
||||
};
|
||||
yield* rpcServer.onSocket(effectSocket, headersFrom(request.headers));
|
||||
}),
|
||||
);
|
||||
|
||||
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 (
|
||||
msg.id === undefined ||
|
||||
msg.id.length === 0 ||
|
||||
msg.service === undefined ||
|
||||
msg.service.length === 0 ||
|
||||
msg.request === undefined
|
||||
) {
|
||||
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,
|
||||
request: msg.request,
|
||||
...(msg.flow !== undefined ? { flow: msg.flow } : {}),
|
||||
};
|
||||
|
||||
mux.receive(muxReq);
|
||||
} catch (err) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
error: { type: "parse-error", message: errorMessage(err) },
|
||||
complete: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
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();
|
||||
Effect.runPromise(program.pipe(Scope.provide(rpcScope))).catch((err) => {
|
||||
console.error("[Gateway] RPC WebSocket error:", err);
|
||||
if (socket.readyState === 1) {
|
||||
socket.close(1011, "Internal server error");
|
||||
}
|
||||
|
|
@ -236,11 +201,21 @@ export async function createGateway(config: GatewayConfig) {
|
|||
start: () => app.listen({ port: config.port, host: "0.0.0.0" }),
|
||||
stop: async () => {
|
||||
await app.close();
|
||||
await Effect.runPromise(Scope.close(rpcScope, Exit.void));
|
||||
await dispatcher.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function headersFrom(headers: Record<string, string | string[] | number | undefined>): ReadonlyArray<[string, string]> {
|
||||
return Object.entries(headers).flatMap(([key, value]) => {
|
||||
if (typeof value === "string") return [[key, value] satisfies [string, string]];
|
||||
if (typeof value === "number") return [[key, String(value)] satisfies [string, string]];
|
||||
if (Array.isArray(value)) return value.map((item) => [key, item] satisfies [string, string]);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,28 @@
|
|||
export { createGateway, type GatewayConfig } from "./gateway/index.js";
|
||||
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 {
|
||||
GraphRag,
|
||||
GraphRagEngine,
|
||||
GraphRagLive,
|
||||
makeGraphRagEngine,
|
||||
normalizeGraphRagConfig,
|
||||
stringToTerm,
|
||||
termToString,
|
||||
type GraphRagConfig,
|
||||
type GraphRagClients,
|
||||
type GraphRagEngineShape,
|
||||
type GraphRagQueryOptions,
|
||||
} from "./retrieval/graph-rag.js";
|
||||
export {
|
||||
DocumentRag,
|
||||
DocumentRagEngine,
|
||||
DocumentRagLive,
|
||||
makeDocumentRagEngine,
|
||||
type DocumentRagClients,
|
||||
type DocumentRagEngineShape,
|
||||
type DocumentRagQueryOptions,
|
||||
} from "./retrieval/document-rag.js";
|
||||
export { FalkorDBTriplesStore, type FalkorDBConfig } from "./storage/triples/falkordb.js";
|
||||
export { FalkorDBTriplesQuery, type FalkorDBQueryConfig } from "./query/triples/falkordb.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import { CollectionManager } from "./collection-manager.js";
|
||||
import {
|
||||
ensureDirectory,
|
||||
|
|
@ -38,9 +39,29 @@ export interface LibrarianServiceConfig extends ProcessorConfig {
|
|||
dataDir?: string;
|
||||
}
|
||||
|
||||
interface UploadSession {
|
||||
id: string;
|
||||
documentMetadata: DocumentMetadata;
|
||||
totalSize: number;
|
||||
chunkSize: number;
|
||||
totalChunks: number;
|
||||
createdAt: string;
|
||||
chunks: Map<number, string>;
|
||||
user: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export class LibrarianService extends AsyncProcessor {
|
||||
private documents = new Map<string, DocumentMetadata>();
|
||||
private processing = new Map<string, ProcessingMetadata>();
|
||||
private uploads = new Map<string, UploadSession>();
|
||||
private collectionManager = new CollectionManager();
|
||||
private readonly dataDir: string;
|
||||
private readonly persistPath: string;
|
||||
|
|
@ -112,6 +133,107 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
// ---------- Librarian message handling ----------
|
||||
|
||||
private requestRecord(request: LibrarianRequest): Record<string, unknown> {
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private documentId(request: LibrarianRequest): string | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
return optionalString(req.documentId) ?? optionalString(req["document-id"]);
|
||||
}
|
||||
|
||||
private processingId(request: LibrarianRequest): string | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
return optionalString(req.processingId) ?? optionalString(req["processing-id"]);
|
||||
}
|
||||
|
||||
private documentMetadata(request: LibrarianRequest): DocumentMetadata | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
const value = req.documentMetadata ?? req["document-metadata"];
|
||||
return isRecord(value) ? this.normaliseDocumentMetadata(value) : undefined;
|
||||
}
|
||||
|
||||
private processingMetadata(request: LibrarianRequest): ProcessingMetadata | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
const value = req.processingMetadata ?? req["processing-metadata"];
|
||||
if (!isRecord(value)) return undefined;
|
||||
const documentId = optionalString(value.documentId) ?? optionalString(value["document-id"]) ?? "";
|
||||
return {
|
||||
id: optionalString(value.id) ?? crypto.randomUUID(),
|
||||
documentId,
|
||||
"document-id": documentId,
|
||||
time: typeof value.time === "number" ? value.time : Math.floor(Date.now() / 1000),
|
||||
flow: optionalString(value.flow) ?? "default",
|
||||
user: optionalString(value.user) ?? optionalString(this.requestRecord(request).user) ?? "default",
|
||||
collection: optionalString(value.collection) ?? optionalString(this.requestRecord(request).collection) ?? "default",
|
||||
tags: Array.isArray(value.tags) ? value.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||
};
|
||||
}
|
||||
|
||||
private normaliseDocumentMetadata(value: Record<string, unknown>): DocumentMetadata {
|
||||
const id = optionalString(value.id) ?? crypto.randomUUID();
|
||||
const parentId = optionalString(value.parentId) ?? optionalString(value["parent-id"]);
|
||||
const documentType = optionalString(value.documentType) ?? optionalString(value["document-type"]) ?? "source";
|
||||
return {
|
||||
id,
|
||||
time: typeof value.time === "number" ? value.time : Math.floor(Date.now() / 1000),
|
||||
kind: optionalString(value.kind) ?? "application/octet-stream",
|
||||
title: optionalString(value.title) ?? "",
|
||||
comments: optionalString(value.comments) ?? "",
|
||||
user: optionalString(value.user) ?? "default",
|
||||
tags: Array.isArray(value.tags) ? value.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||
...(parentId !== undefined ? { parentId, "parent-id": parentId } : {}),
|
||||
documentType,
|
||||
"document-type": documentType,
|
||||
...(Array.isArray(value.metadata) ? { metadata: value.metadata as NonNullable<DocumentMetadata["metadata"]> } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private publicDocument(doc: DocumentMetadata): DocumentMetadata {
|
||||
const parentId = doc.parentId ?? doc["parent-id"];
|
||||
const documentType = doc.documentType ?? doc["document-type"] ?? "source";
|
||||
return {
|
||||
...doc,
|
||||
...(parentId !== undefined ? { parentId, "parent-id": parentId } : {}),
|
||||
documentType,
|
||||
"document-type": documentType,
|
||||
};
|
||||
}
|
||||
|
||||
private publicProcessing(proc: ProcessingMetadata): ProcessingMetadata {
|
||||
const documentId = proc.documentId ?? proc["document-id"] ?? "";
|
||||
return {
|
||||
...proc,
|
||||
documentId,
|
||||
"document-id": documentId,
|
||||
};
|
||||
}
|
||||
|
||||
private documentResponse(doc: DocumentMetadata): LibrarianResponse {
|
||||
const publicDoc = this.publicDocument(doc);
|
||||
return {
|
||||
documentMetadata: publicDoc,
|
||||
"document-metadata": publicDoc,
|
||||
};
|
||||
}
|
||||
|
||||
private documentsResponse(docs: DocumentMetadata[]): LibrarianResponse {
|
||||
const publicDocs = docs.map((doc) => this.publicDocument(doc));
|
||||
return {
|
||||
documents: publicDocs,
|
||||
"document-metadatas": publicDocs,
|
||||
};
|
||||
}
|
||||
|
||||
private processingResponse(records: ProcessingMetadata[]): LibrarianResponse {
|
||||
const publicRecords = records.map((proc) => this.publicProcessing(proc));
|
||||
return {
|
||||
processing: publicRecords,
|
||||
"processing-metadata": publicRecords,
|
||||
"processing-metadatas": publicRecords,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleLibrarianMessage(msg: Message<LibrarianRequest>): Promise<void> {
|
||||
const request = msg.value();
|
||||
const props = msg.properties();
|
||||
|
|
@ -123,6 +245,12 @@ export class LibrarianService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
try {
|
||||
if (request.operation === "stream-document") {
|
||||
for (const response of await this.streamDocument(request)) {
|
||||
await this.libProducer!.send(response, { id: requestId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const response = await this.handleLibrarianOperation(request);
|
||||
await this.libProducer!.send(response, { id: requestId });
|
||||
} catch (err) {
|
||||
|
|
@ -140,6 +268,8 @@ export class LibrarianService extends AsyncProcessor {
|
|||
return this.addDocument(request);
|
||||
case "remove-document":
|
||||
return this.removeDocument(request);
|
||||
case "update-document":
|
||||
return this.updateDocument(request);
|
||||
case "list-documents":
|
||||
return this.listDocuments(request);
|
||||
case "get-document-metadata":
|
||||
|
|
@ -156,17 +286,31 @@ export class LibrarianService extends AsyncProcessor {
|
|||
return this.removeProcessing(request);
|
||||
case "list-processing":
|
||||
return this.listProcessing(request);
|
||||
case "begin-upload":
|
||||
return this.beginUpload(request);
|
||||
case "upload-chunk":
|
||||
return this.uploadChunk(request);
|
||||
case "complete-upload":
|
||||
return this.completeUpload(request);
|
||||
case "get-upload-status":
|
||||
return this.getUploadStatus(request);
|
||||
case "abort-upload":
|
||||
return this.abortUpload(request);
|
||||
case "list-uploads":
|
||||
return this.listUploads(request);
|
||||
case "stream-document":
|
||||
throw new Error("stream-document must be handled as a streaming operation");
|
||||
default:
|
||||
throw new Error(`Unknown librarian operation: ${request.operation as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async addDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const meta = request.documentMetadata;
|
||||
const meta = this.documentMetadata(request);
|
||||
if (meta === undefined) throw new Error("add-document requires documentMetadata");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const id = meta.id;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const doc: DocumentMetadata = {
|
||||
...meta,
|
||||
|
|
@ -186,11 +330,11 @@ export class LibrarianService extends AsyncProcessor {
|
|||
await this.persist();
|
||||
console.log(`[LibrarianService] Added document ${id}: ${doc.title}`);
|
||||
|
||||
return { documentMetadata: doc };
|
||||
return this.documentResponse(doc);
|
||||
}
|
||||
|
||||
private async removeDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = request.documentId;
|
||||
const id = this.documentId(request);
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("remove-document requires documentId");
|
||||
}
|
||||
|
|
@ -234,23 +378,45 @@ export class LibrarianService extends AsyncProcessor {
|
|||
return {};
|
||||
}
|
||||
|
||||
private async updateDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = this.documentId(request) ?? this.documentMetadata(request)?.id;
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("update-document requires documentId");
|
||||
}
|
||||
const existing = this.documents.get(id);
|
||||
if (existing === undefined) throw new Error(`Document not found: ${id}`);
|
||||
const meta = this.documentMetadata(request);
|
||||
if (meta === undefined) throw new Error("update-document requires documentMetadata");
|
||||
|
||||
const doc: DocumentMetadata = this.publicDocument({
|
||||
...existing,
|
||||
...meta,
|
||||
id,
|
||||
time: meta.time ?? existing.time,
|
||||
});
|
||||
this.documents.set(id, doc);
|
||||
await this.persist();
|
||||
return this.documentResponse(doc);
|
||||
}
|
||||
|
||||
private listDocuments(request: LibrarianRequest): LibrarianResponse {
|
||||
const user = request.user ?? "";
|
||||
const includeChildren = this.requestRecord(request)["include-children"] === true;
|
||||
const docs: DocumentMetadata[] = [];
|
||||
|
||||
for (const doc of this.documents.values()) {
|
||||
// Filter by user
|
||||
if (user.length > 0 && doc.user !== user) continue;
|
||||
// Exclude children (only top-level documents) unless explicitly requested
|
||||
if (doc.parentId !== undefined && doc.parentId.length > 0) continue;
|
||||
if (!includeChildren && doc.parentId !== undefined && doc.parentId.length > 0) continue;
|
||||
docs.push(doc);
|
||||
}
|
||||
|
||||
return { documents: docs };
|
||||
return this.documentsResponse(docs);
|
||||
}
|
||||
|
||||
private getDocumentMetadata(request: LibrarianRequest): LibrarianResponse {
|
||||
const id = request.documentId;
|
||||
const id = this.documentId(request);
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("get-document-metadata requires documentId");
|
||||
}
|
||||
|
|
@ -258,11 +424,11 @@ export class LibrarianService extends AsyncProcessor {
|
|||
const doc = this.documents.get(id);
|
||||
if (doc === undefined) throw new Error(`Document not found: ${id}`);
|
||||
|
||||
return { documentMetadata: doc };
|
||||
return this.documentResponse(doc);
|
||||
}
|
||||
|
||||
private async getDocumentContent(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = request.documentId;
|
||||
const id = this.documentId(request);
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("get-document-content requires documentId");
|
||||
}
|
||||
|
|
@ -274,14 +440,14 @@ export class LibrarianService extends AsyncProcessor {
|
|||
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
|
||||
const buf = await readBinaryFile(filePath);
|
||||
const content = Buffer.from(buf).toString("base64");
|
||||
return { documentMetadata: doc, content };
|
||||
return { ...this.documentResponse(doc), content };
|
||||
} catch {
|
||||
throw new Error(`Document content not found on disk: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async addChildDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const meta = request.documentMetadata;
|
||||
const meta = this.documentMetadata(request);
|
||||
if (meta === undefined) {
|
||||
throw new Error("add-child-document requires documentMetadata");
|
||||
}
|
||||
|
|
@ -294,8 +460,8 @@ export class LibrarianService extends AsyncProcessor {
|
|||
throw new Error(`Parent document not found: ${meta.parentId}`);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const id = meta.id;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const doc: DocumentMetadata = {
|
||||
...meta,
|
||||
|
|
@ -315,11 +481,11 @@ export class LibrarianService extends AsyncProcessor {
|
|||
await this.persist();
|
||||
console.log(`[LibrarianService] Added child document ${id} (parent: ${meta.parentId})`);
|
||||
|
||||
return { documentMetadata: doc };
|
||||
return this.documentResponse(doc);
|
||||
}
|
||||
|
||||
private listChildren(request: LibrarianRequest): LibrarianResponse {
|
||||
const parentId = request.documentId;
|
||||
const parentId = this.documentId(request);
|
||||
if (parentId === undefined || parentId.length === 0) {
|
||||
throw new Error("list-children requires documentId");
|
||||
}
|
||||
|
|
@ -331,15 +497,15 @@ export class LibrarianService extends AsyncProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
return { documents: children };
|
||||
return this.documentsResponse(children);
|
||||
}
|
||||
|
||||
private async addProcessing(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const proc = request.processingMetadata;
|
||||
const proc = this.processingMetadata(request);
|
||||
if (proc === undefined) throw new Error("add-processing requires processingMetadata");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const id = proc.id;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const record: ProcessingMetadata = {
|
||||
...proc,
|
||||
|
|
@ -351,11 +517,11 @@ export class LibrarianService extends AsyncProcessor {
|
|||
await this.persist();
|
||||
|
||||
console.log(`[LibrarianService] Added processing ${id} for document ${proc.documentId}`);
|
||||
return { processing: [record] };
|
||||
return this.processingResponse([record]);
|
||||
}
|
||||
|
||||
private async removeProcessing(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = request.processingId;
|
||||
const id = this.processingId(request);
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("remove-processing requires processingId");
|
||||
}
|
||||
|
|
@ -367,17 +533,167 @@ export class LibrarianService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
private listProcessing(request: LibrarianRequest): LibrarianResponse {
|
||||
const documentId = request.documentId;
|
||||
const documentId = this.documentId(request);
|
||||
const records: ProcessingMetadata[] = [];
|
||||
|
||||
for (const proc of this.processing.values()) {
|
||||
if (documentId !== undefined && documentId.length > 0 && proc.documentId !== documentId) {
|
||||
const procDocumentId = proc.documentId ?? proc["document-id"];
|
||||
if (documentId !== undefined && documentId.length > 0 && procDocumentId !== documentId) {
|
||||
continue;
|
||||
}
|
||||
records.push(proc);
|
||||
}
|
||||
|
||||
return { processing: records };
|
||||
return this.processingResponse(records);
|
||||
}
|
||||
|
||||
private beginUpload(request: LibrarianRequest): LibrarianResponse {
|
||||
const meta = this.documentMetadata(request);
|
||||
if (meta === undefined) throw new Error("begin-upload requires documentMetadata");
|
||||
const req = this.requestRecord(request);
|
||||
const totalSize = typeof req["total-size"] === "number" ? req["total-size"] : 0;
|
||||
if (totalSize <= 0) throw new Error("begin-upload requires total-size");
|
||||
const chunkSize = typeof req["chunk-size"] === "number" && req["chunk-size"] > 0
|
||||
? req["chunk-size"]
|
||||
: 3 * 1024 * 1024;
|
||||
const totalChunks = Math.max(1, Math.ceil(totalSize / chunkSize));
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
this.uploads.set(uploadId, {
|
||||
id: uploadId,
|
||||
documentMetadata: meta,
|
||||
totalSize,
|
||||
chunkSize,
|
||||
totalChunks,
|
||||
createdAt: new Date().toISOString(),
|
||||
chunks: new Map<number, string>(),
|
||||
user: meta.user ?? optionalString(req.user) ?? "default",
|
||||
});
|
||||
|
||||
return {
|
||||
"upload-id": uploadId,
|
||||
"chunk-size": chunkSize,
|
||||
"total-chunks": totalChunks,
|
||||
} as LibrarianResponse;
|
||||
}
|
||||
|
||||
private uploadChunk(request: LibrarianRequest): LibrarianResponse {
|
||||
const req = this.requestRecord(request);
|
||||
const uploadId = optionalString(req["upload-id"]);
|
||||
if (uploadId === undefined) throw new Error("upload-chunk requires upload-id");
|
||||
const session = this.uploads.get(uploadId);
|
||||
if (session === undefined) throw new Error(`Upload not found: ${uploadId}`);
|
||||
const chunkIndex = typeof req["chunk-index"] === "number" ? req["chunk-index"] : -1;
|
||||
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= session.totalChunks) {
|
||||
throw new Error("upload-chunk requires a valid chunk-index");
|
||||
}
|
||||
const content = optionalString(req.content);
|
||||
if (content === undefined) throw new Error("upload-chunk requires content");
|
||||
session.chunks.set(chunkIndex, content);
|
||||
|
||||
const bytesReceived = [...session.chunks.values()].reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
return {
|
||||
"upload-id": uploadId,
|
||||
"chunk-index": chunkIndex,
|
||||
"chunks-received": session.chunks.size,
|
||||
"total-chunks": session.totalChunks,
|
||||
"bytes-received": bytesReceived,
|
||||
"total-bytes": session.totalSize,
|
||||
} as LibrarianResponse;
|
||||
}
|
||||
|
||||
private async completeUpload(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const uploadId = optionalString(this.requestRecord(request)["upload-id"]);
|
||||
if (uploadId === undefined) throw new Error("complete-upload requires upload-id");
|
||||
const session = this.uploads.get(uploadId);
|
||||
if (session === undefined) throw new Error(`Upload not found: ${uploadId}`);
|
||||
if (session.chunks.size !== session.totalChunks) {
|
||||
throw new Error(`Upload incomplete: ${session.chunks.size}/${session.totalChunks} chunks received`);
|
||||
}
|
||||
|
||||
const content = Array.from({ length: session.totalChunks }, (_, i) => session.chunks.get(i) ?? "").join("");
|
||||
const response = await this.addDocument({
|
||||
operation: "add-document",
|
||||
documentMetadata: session.documentMetadata,
|
||||
"document-metadata": session.documentMetadata,
|
||||
content,
|
||||
user: session.user,
|
||||
} as LibrarianRequest);
|
||||
this.uploads.delete(uploadId);
|
||||
const documentId = response.documentMetadata?.id ?? response["document-metadata"]?.id ?? session.documentMetadata.id;
|
||||
return {
|
||||
...response,
|
||||
"document-id": documentId,
|
||||
"object-id": documentId,
|
||||
} as LibrarianResponse;
|
||||
}
|
||||
|
||||
private getUploadStatus(request: LibrarianRequest): LibrarianResponse {
|
||||
const uploadId = optionalString(this.requestRecord(request)["upload-id"]);
|
||||
if (uploadId === undefined) throw new Error("get-upload-status requires upload-id");
|
||||
const session = this.uploads.get(uploadId);
|
||||
if (session === undefined) throw new Error(`Upload not found: ${uploadId}`);
|
||||
const receivedChunks = [...session.chunks.keys()].sort((a, b) => a - b);
|
||||
const receivedSet = new Set(receivedChunks);
|
||||
const missingChunks = Array.from({ length: session.totalChunks }, (_, i) => i).filter((i) => !receivedSet.has(i));
|
||||
const bytesReceived = [...session.chunks.values()].reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
return {
|
||||
"upload-id": uploadId,
|
||||
"upload-state": "in-progress",
|
||||
"chunks-received": session.chunks.size,
|
||||
"total-chunks": session.totalChunks,
|
||||
"received-chunks": receivedChunks,
|
||||
"missing-chunks": missingChunks,
|
||||
"bytes-received": bytesReceived,
|
||||
"total-bytes": session.totalSize,
|
||||
} as LibrarianResponse;
|
||||
}
|
||||
|
||||
private abortUpload(request: LibrarianRequest): LibrarianResponse {
|
||||
const uploadId = optionalString(this.requestRecord(request)["upload-id"]);
|
||||
if (uploadId === undefined) throw new Error("abort-upload requires upload-id");
|
||||
this.uploads.delete(uploadId);
|
||||
return {};
|
||||
}
|
||||
|
||||
private listUploads(request: LibrarianRequest): LibrarianResponse {
|
||||
const user = optionalString(this.requestRecord(request).user);
|
||||
const sessions = [...this.uploads.values()]
|
||||
.filter((session) => user === undefined || session.user === user)
|
||||
.map((session) => ({
|
||||
"upload-id": session.id,
|
||||
"document-id": session.documentMetadata.id,
|
||||
"document-metadata-json": JSON.stringify(this.publicDocument(session.documentMetadata)),
|
||||
"total-size": session.totalSize,
|
||||
"chunk-size": session.chunkSize,
|
||||
"total-chunks": session.totalChunks,
|
||||
"chunks-received": session.chunks.size,
|
||||
"created-at": session.createdAt,
|
||||
}));
|
||||
return { "upload-sessions": sessions } as LibrarianResponse;
|
||||
}
|
||||
|
||||
private async streamDocument(request: LibrarianRequest): Promise<LibrarianResponse[]> {
|
||||
const id = this.documentId(request);
|
||||
if (id === undefined) throw new Error("stream-document requires documentId");
|
||||
const req = this.requestRecord(request);
|
||||
const chunkSize = typeof req["chunk-size"] === "number" && req["chunk-size"] > 0
|
||||
? req["chunk-size"]
|
||||
: 1024 * 1024;
|
||||
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
|
||||
const buf = await readBinaryFile(filePath);
|
||||
const base64 = Buffer.from(buf).toString("base64");
|
||||
const totalChunks = Math.max(1, Math.ceil(base64.length / chunkSize));
|
||||
return Array.from({ length: totalChunks }, (_, index) => {
|
||||
const start = index * chunkSize;
|
||||
const content = base64.slice(start, start + chunkSize);
|
||||
return {
|
||||
content,
|
||||
"chunk-index": index,
|
||||
"total-chunks": totalChunks,
|
||||
eos: index === totalChunks - 1,
|
||||
} as LibrarianResponse;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Collection management ----------
|
||||
|
|
@ -471,14 +787,14 @@ export class LibrarianService extends AsyncProcessor {
|
|||
this.documents.clear();
|
||||
if (parsed.documents !== undefined) {
|
||||
for (const [id, doc] of Object.entries(parsed.documents)) {
|
||||
this.documents.set(id, doc);
|
||||
this.documents.set(id, this.publicDocument(doc));
|
||||
}
|
||||
}
|
||||
|
||||
this.processing.clear();
|
||||
if (parsed.processing !== undefined) {
|
||||
for (const [id, proc] of Object.entries(parsed.processing)) {
|
||||
this.processing.set(id, proc);
|
||||
this.processing.set(id, this.publicProcessing(proc));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -525,5 +841,5 @@ export const program = makeProcessorProgram({
|
|||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await LibrarianService.launch("librarian-svc");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,17 @@
|
|||
|
||||
import { AzureOpenAI } from "openai";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class AzureOpenAIProcessor extends LlmService {
|
||||
private client: AzureOpenAI;
|
||||
|
|
@ -157,11 +161,16 @@ export class AzureOpenAIProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new AzureOpenAIProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new AzureOpenAIProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await AzureOpenAIProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,18 @@
|
|||
*/
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class ClaudeProcessor extends LlmService {
|
||||
private client: Anthropic;
|
||||
|
|
@ -127,11 +137,16 @@ export class ClaudeProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new ClaudeProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new ClaudeProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await ClaudeProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@
|
|||
|
||||
import { Mistral } from "@mistralai/mistralai";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class MistralProcessor extends LlmService {
|
||||
private client: Mistral;
|
||||
|
|
@ -143,11 +147,16 @@ export class MistralProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new MistralProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new MistralProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await MistralProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,17 @@
|
|||
*/
|
||||
|
||||
import { Ollama } from "ollama";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk } from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
} from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class OllamaProcessor extends LlmService {
|
||||
private client: Ollama;
|
||||
|
|
@ -113,11 +122,16 @@ export class OllamaProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new OllamaProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new OllamaProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OllamaProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@
|
|||
|
||||
import OpenAI from "openai";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class OpenAICompatibleProcessor extends LlmService {
|
||||
private client: OpenAI;
|
||||
|
|
@ -137,11 +141,16 @@ export class OpenAICompatibleProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new OpenAICompatibleProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new OpenAICompatibleProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OpenAICompatibleProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,18 @@
|
|||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class OpenAIProcessor extends LlmService {
|
||||
private client: OpenAI;
|
||||
|
|
@ -137,11 +147,16 @@ export class OpenAIProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new OpenAIProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new OpenAIProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OpenAIProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,17 @@ import {
|
|||
ConsumerSpec,
|
||||
ProducerSpec,
|
||||
type ProcessorConfig,
|
||||
type EffectConfigHandler,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface PromptTemplate {
|
||||
system: string;
|
||||
|
|
@ -44,94 +50,129 @@ export interface PromptTemplateConfig extends ProcessorConfig {
|
|||
configKey?: string;
|
||||
}
|
||||
|
||||
const PromptTemplateEntry = S.Struct({
|
||||
system: S.optionalKey(S.String),
|
||||
prompt: S.optionalKey(S.String),
|
||||
});
|
||||
|
||||
const PromptTemplateEntries = S.Record(S.String, PromptTemplateEntry);
|
||||
|
||||
interface PromptTemplateRuntime {
|
||||
readonly specs: ReadonlyArray<Spec<never>>;
|
||||
readonly configHandlers: ReadonlyArray<EffectConfigHandler>;
|
||||
}
|
||||
|
||||
const programRuntimes = new WeakMap<PromptTemplateConfig, PromptTemplateRuntime>();
|
||||
|
||||
const makePromptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplateRuntime => {
|
||||
const templates = new Map<string, PromptTemplate>();
|
||||
const configKey = config.configKey ?? "prompt";
|
||||
|
||||
const onPromptConfig = Effect.fn("PromptTemplateService.onConfig")(function* (
|
||||
pushedConfig: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
yield* Effect.log(`[PromptTemplate] Loading prompt configuration version ${version}`);
|
||||
|
||||
const promptConfig = pushedConfig[configKey];
|
||||
if (promptConfig === undefined) {
|
||||
yield* Effect.logWarning(`[PromptTemplate] No key "${configKey}" in config`);
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = yield* S.decodeUnknownEffect(PromptTemplateEntries)(promptConfig).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[PromptTemplate] Failed to decode prompt configuration", {
|
||||
error: error.message,
|
||||
configKey,
|
||||
}).pipe(Effect.as(null)),
|
||||
),
|
||||
);
|
||||
if (decoded === null) return;
|
||||
|
||||
templates.clear();
|
||||
|
||||
for (const [name, template] of Object.entries(decoded)) {
|
||||
templates.set(name, {
|
||||
system: template.system ?? "",
|
||||
prompt: template.prompt ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.log(
|
||||
`[PromptTemplate] Loaded ${templates.size} template(s): ${[...templates.keys()].join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
const onRequest = Effect.fn("PromptTemplateService.onRequest")(function* (
|
||||
msg: PromptRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<PromptResponse>("prompt-response");
|
||||
const template = templates.get(msg.name);
|
||||
if (template === undefined) {
|
||||
yield* responseProducer.send(requestId, {
|
||||
system: "",
|
||||
prompt: "",
|
||||
error: {
|
||||
type: "prompt-error",
|
||||
message: `Unknown prompt template: "${msg.name}"`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = msg.variables ?? {};
|
||||
|
||||
yield* responseProducer.send(requestId, {
|
||||
system: renderTemplate(template.system, variables),
|
||||
prompt: renderTemplate(template.prompt, variables),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
specs: [
|
||||
new ConsumerSpec<PromptRequest, FlowResourceNotFoundError | MessagingDeliveryError>(
|
||||
"prompt-request",
|
||||
onRequest,
|
||||
),
|
||||
new ProducerSpec<PromptResponse>("prompt-response"),
|
||||
],
|
||||
configHandlers: [onPromptConfig],
|
||||
};
|
||||
};
|
||||
|
||||
const promptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplateRuntime => {
|
||||
const existing = programRuntimes.get(config);
|
||||
if (existing !== undefined) return existing;
|
||||
const runtime = makePromptTemplateRuntime(config);
|
||||
programRuntimes.set(config, runtime);
|
||||
return runtime;
|
||||
};
|
||||
|
||||
export class PromptTemplateService extends FlowProcessor {
|
||||
private templates = new Map<string, PromptTemplate>();
|
||||
private readonly configKey: string;
|
||||
private readonly runtime: PromptTemplateRuntime;
|
||||
|
||||
constructor(config: PromptTemplateConfig) {
|
||||
super(config);
|
||||
|
||||
this.configKey = config.configKey ?? "prompt";
|
||||
this.runtime = makePromptTemplateRuntime(config);
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<PromptRequest>(
|
||||
"prompt-request",
|
||||
this.onRequest.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<PromptResponse>("prompt-response"));
|
||||
|
||||
this.registerConfigHandler(this.onPromptConfig.bind(this));
|
||||
for (const spec of this.runtime.specs) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
for (const handler of this.runtime.configHandlers) {
|
||||
this.registerConfigHandler((pushedConfig, version) =>
|
||||
Effect.runPromise(handler(pushedConfig, version)),
|
||||
);
|
||||
}
|
||||
|
||||
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 === undefined) {
|
||||
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 === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<PromptResponse>("prompt-response");
|
||||
|
||||
try {
|
||||
const template = this.templates.get(msg.name);
|
||||
if (template === undefined) {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -150,11 +191,12 @@ function renderTemplate(
|
|||
});
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "prompt",
|
||||
make: (config) => new PromptTemplateService(config),
|
||||
specs: (config: PromptTemplateConfig) => promptTemplateRuntime(config).specs,
|
||||
configHandlers: (config: PromptTemplateConfig) => promptTemplateRuntime(config).configHandlers,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await PromptTemplateService.launch("prompt");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,79 +13,108 @@ import {
|
|||
ProducerSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type DocumentEmbeddingsRequest,
|
||||
type DocumentEmbeddingsResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { QdrantDocEmbeddingsQuery } from "./qdrant-doc.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
QdrantDocEmbeddingsQueryLive,
|
||||
QdrantDocEmbeddingsQueryService,
|
||||
makeQdrantDocEmbeddingsQueryService,
|
||||
type QdrantDocQueryConfig,
|
||||
} from "./qdrant-doc.js";
|
||||
|
||||
export class DocEmbeddingsQueryService extends FlowProcessor {
|
||||
private query: QdrantDocEmbeddingsQuery;
|
||||
const onDocEmbeddingsQueryMessage = Effect.fn("DocEmbeddingsQueryService.onMessage")(function* (
|
||||
msg: DocumentEmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<QdrantDocEmbeddingsQueryService>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = yield* flowCtx.flow.producerEffect<DocumentEmbeddingsResponse>("document-embeddings-response");
|
||||
const query = yield* QdrantDocEmbeddingsQueryService;
|
||||
const collection = msg.collection ?? "default";
|
||||
const allChunks: DocumentEmbeddingsResponse["chunks"] = [];
|
||||
|
||||
for (const vector of msg.vectors ?? []) {
|
||||
const matches = yield* query.query({
|
||||
vector,
|
||||
user: msg.user ?? "default",
|
||||
collection,
|
||||
limit: msg.limit ?? 10,
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[DocEmbeddingsQuery] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
chunks: [],
|
||||
error: { type: "query-error", message: error.message },
|
||||
})
|
||||
),
|
||||
Effect.as(null),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (matches === null) return;
|
||||
|
||||
for (const match of matches) {
|
||||
allChunks.push({
|
||||
chunkId: match.chunkId,
|
||||
score: match.score,
|
||||
...(match.content !== undefined ? { content: match.content } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield* producer.send(requestId, { chunks: allChunks });
|
||||
});
|
||||
|
||||
export const makeDocEmbeddingsQuerySpecs = (): ReadonlyArray<Spec<QdrantDocEmbeddingsQueryService>> => [
|
||||
new ConsumerSpec<
|
||||
DocumentEmbeddingsRequest,
|
||||
FlowResourceNotFoundError | MessagingDeliveryError,
|
||||
QdrantDocEmbeddingsQueryService
|
||||
>("document-embeddings-request", onDocEmbeddingsQueryMessage),
|
||||
new ProducerSpec<DocumentEmbeddingsResponse>("document-embeddings-response"),
|
||||
];
|
||||
|
||||
export class DocEmbeddingsQueryService extends FlowProcessor<QdrantDocEmbeddingsQueryService> {
|
||||
private readonly query = makeQdrantDocEmbeddingsQueryService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.query = new QdrantDocEmbeddingsQuery();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<DocumentEmbeddingsRequest>(
|
||||
"document-embeddings-request",
|
||||
this.onMessage.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new ProducerSpec<DocumentEmbeddingsResponse>("document-embeddings-response"),
|
||||
);
|
||||
for (const spec of makeDocEmbeddingsQuerySpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[DocEmbeddingsQuery] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: DocumentEmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<DocumentEmbeddingsResponse>("document-embeddings-response");
|
||||
const collection = msg.collection ?? "default";
|
||||
|
||||
try {
|
||||
const allChunks: DocumentEmbeddingsResponse["chunks"] = [];
|
||||
|
||||
for (const vector of msg.vectors ?? []) {
|
||||
const matches = await this.query.query({
|
||||
vector,
|
||||
user: msg.user ?? "default",
|
||||
collection,
|
||||
limit: msg.limit ?? 10,
|
||||
});
|
||||
|
||||
for (const match of matches) {
|
||||
allChunks.push({
|
||||
chunkId: match.chunkId,
|
||||
score: match.score,
|
||||
...(match.content !== undefined ? { content: match.content } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await producer.send(requestId, { chunks: allChunks });
|
||||
} catch (err) {
|
||||
console.error("[DocEmbeddingsQuery] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
chunks: [],
|
||||
error: { type: "query-error", message: String(err) },
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
QdrantDocEmbeddingsQueryService,
|
||||
QdrantDocEmbeddingsQueryService.of(this.query),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig & QdrantDocQueryConfig, never, QdrantDocEmbeddingsQueryService>({
|
||||
id: "doc-embeddings-query",
|
||||
make: (config) => new DocEmbeddingsQueryService(config),
|
||||
specs: () => makeDocEmbeddingsQuerySpecs(),
|
||||
layer: (config) => QdrantDocEmbeddingsQueryLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await DocEmbeddingsQueryService.launch("doc-embeddings-query");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface QdrantDocQueryConfig {
|
||||
url?: string;
|
||||
|
|
@ -83,3 +86,54 @@ export class QdrantDocEmbeddingsQuery {
|
|||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
export class QdrantDocEmbeddingsQueryError extends S.TaggedErrorClass<QdrantDocEmbeddingsQueryError>()(
|
||||
"QdrantDocEmbeddingsQueryError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface QdrantDocEmbeddingsQueryServiceShape {
|
||||
readonly query: (
|
||||
request: DocEmbeddingsQueryRequest,
|
||||
) => Effect.Effect<ReadonlyArray<ChunkMatch>, QdrantDocEmbeddingsQueryError>;
|
||||
}
|
||||
|
||||
export class QdrantDocEmbeddingsQueryService extends Context.Service<
|
||||
QdrantDocEmbeddingsQueryService,
|
||||
QdrantDocEmbeddingsQueryServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/query/embeddings/qdrant-doc/QdrantDocEmbeddingsQueryService",
|
||||
) {}
|
||||
|
||||
const qdrantDocEmbeddingsQueryError = (operation: string, cause: unknown) =>
|
||||
new QdrantDocEmbeddingsQueryError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeQdrantDocEmbeddingsQueryService = (
|
||||
config: QdrantDocQueryConfig = {},
|
||||
): QdrantDocEmbeddingsQueryServiceShape => {
|
||||
const query = new QdrantDocEmbeddingsQuery(config);
|
||||
return {
|
||||
query: Effect.fn("QdrantDocEmbeddingsQuery.query")(function* (request) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => query.query(request),
|
||||
catch: (cause) => qdrantDocEmbeddingsQueryError("query", cause),
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const QdrantDocEmbeddingsQueryLive = (
|
||||
config: QdrantDocQueryConfig = {},
|
||||
): Layer.Layer<QdrantDocEmbeddingsQueryService> =>
|
||||
Layer.succeed(
|
||||
QdrantDocEmbeddingsQueryService,
|
||||
QdrantDocEmbeddingsQueryService.of(makeQdrantDocEmbeddingsQueryService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,78 +13,109 @@ import {
|
|||
ProducerSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type GraphEmbeddingsRequest,
|
||||
type GraphEmbeddingsResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { QdrantGraphEmbeddingsQuery } from "./qdrant-graph.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
QdrantGraphEmbeddingsQueryLive,
|
||||
QdrantGraphEmbeddingsQueryService,
|
||||
makeQdrantGraphEmbeddingsQueryService,
|
||||
type QdrantGraphQueryConfig,
|
||||
} from "./qdrant-graph.js";
|
||||
|
||||
export class GraphEmbeddingsQueryService extends FlowProcessor {
|
||||
private query: QdrantGraphEmbeddingsQuery;
|
||||
const onGraphEmbeddingsQueryMessage = Effect.fn("GraphEmbeddingsQueryService.onMessage")(function* (
|
||||
msg: GraphEmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<QdrantGraphEmbeddingsQueryService>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = yield* flowCtx.flow.producerEffect<GraphEmbeddingsResponse>("graph-embeddings-response");
|
||||
const query = yield* QdrantGraphEmbeddingsQueryService;
|
||||
const user = msg.user ?? "default";
|
||||
const collection = msg.collection ?? "default";
|
||||
yield* Effect.log(
|
||||
`[GraphEmbeddingsQuery] Request: user=${user}, collection=${collection}, vectors=${msg.vectors?.length ?? 0}, limit=${msg.limit}`,
|
||||
);
|
||||
|
||||
const allEntities: GraphEmbeddingsResponse["entities"] = [];
|
||||
|
||||
for (const vector of msg.vectors ?? []) {
|
||||
const matches = yield* query.query({
|
||||
vector,
|
||||
user,
|
||||
collection,
|
||||
limit: msg.limit ?? 50,
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[GraphEmbeddingsQuery] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
entities: [],
|
||||
error: { type: "query-error", message: error.message },
|
||||
})
|
||||
),
|
||||
Effect.as(null),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (matches === null) return;
|
||||
|
||||
for (const match of matches) {
|
||||
allEntities.push(match.entity);
|
||||
}
|
||||
}
|
||||
|
||||
yield* producer.send(requestId, { entities: allEntities });
|
||||
});
|
||||
|
||||
export const makeGraphEmbeddingsQuerySpecs = (): ReadonlyArray<Spec<QdrantGraphEmbeddingsQueryService>> => [
|
||||
new ConsumerSpec<
|
||||
GraphEmbeddingsRequest,
|
||||
FlowResourceNotFoundError | MessagingDeliveryError,
|
||||
QdrantGraphEmbeddingsQueryService
|
||||
>("graph-embeddings-request", onGraphEmbeddingsQueryMessage),
|
||||
new ProducerSpec<GraphEmbeddingsResponse>("graph-embeddings-response"),
|
||||
];
|
||||
|
||||
export class GraphEmbeddingsQueryService extends FlowProcessor<QdrantGraphEmbeddingsQueryService> {
|
||||
private readonly query = makeQdrantGraphEmbeddingsQueryService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.query = new QdrantGraphEmbeddingsQuery();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<GraphEmbeddingsRequest>(
|
||||
"graph-embeddings-request",
|
||||
this.onMessage.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new ProducerSpec<GraphEmbeddingsResponse>("graph-embeddings-response"),
|
||||
);
|
||||
for (const spec of makeGraphEmbeddingsQuerySpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[GraphEmbeddingsQuery] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: GraphEmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<GraphEmbeddingsResponse>("graph-embeddings-response");
|
||||
const user = msg.user ?? "default";
|
||||
const collection = msg.collection ?? "default";
|
||||
console.log(`[GraphEmbeddingsQuery] Request: user=${user}, collection=${collection}, vectors=${msg.vectors?.length ?? 0}, limit=${msg.limit}`);
|
||||
|
||||
try {
|
||||
// Query for each vector and aggregate results
|
||||
const allEntities: GraphEmbeddingsResponse["entities"] = [];
|
||||
|
||||
for (const vector of msg.vectors ?? []) {
|
||||
const matches = await this.query.query({
|
||||
vector,
|
||||
user,
|
||||
collection,
|
||||
limit: msg.limit ?? 50,
|
||||
});
|
||||
|
||||
for (const match of matches) {
|
||||
allEntities.push(match.entity);
|
||||
}
|
||||
}
|
||||
|
||||
await producer.send(requestId, { entities: allEntities });
|
||||
} catch (err) {
|
||||
console.error("[GraphEmbeddingsQuery] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
entities: [],
|
||||
error: { type: "query-error", message: String(err) },
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
QdrantGraphEmbeddingsQueryService,
|
||||
QdrantGraphEmbeddingsQueryService.of(this.query),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig & QdrantGraphQueryConfig, never, QdrantGraphEmbeddingsQueryService>({
|
||||
id: "graph-embeddings-query",
|
||||
make: (config) => new GraphEmbeddingsQueryService(config),
|
||||
specs: () => makeGraphEmbeddingsQuerySpecs(),
|
||||
layer: (config) => QdrantGraphEmbeddingsQueryLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await GraphEmbeddingsQueryService.launch("graph-embeddings-query");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import type { Term } from "@trustgraph/base";
|
||||
import { errorMessage, type Term } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface QdrantGraphQueryConfig {
|
||||
url?: string;
|
||||
|
|
@ -104,3 +106,54 @@ export class QdrantGraphEmbeddingsQuery {
|
|||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
export class QdrantGraphEmbeddingsQueryError extends S.TaggedErrorClass<QdrantGraphEmbeddingsQueryError>()(
|
||||
"QdrantGraphEmbeddingsQueryError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface QdrantGraphEmbeddingsQueryServiceShape {
|
||||
readonly query: (
|
||||
request: GraphEmbeddingsQueryRequest,
|
||||
) => Effect.Effect<ReadonlyArray<EntityMatch>, QdrantGraphEmbeddingsQueryError>;
|
||||
}
|
||||
|
||||
export class QdrantGraphEmbeddingsQueryService extends Context.Service<
|
||||
QdrantGraphEmbeddingsQueryService,
|
||||
QdrantGraphEmbeddingsQueryServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/query/embeddings/qdrant-graph/QdrantGraphEmbeddingsQueryService",
|
||||
) {}
|
||||
|
||||
const qdrantGraphEmbeddingsQueryError = (operation: string, cause: unknown) =>
|
||||
new QdrantGraphEmbeddingsQueryError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeQdrantGraphEmbeddingsQueryService = (
|
||||
config: QdrantGraphQueryConfig = {},
|
||||
): QdrantGraphEmbeddingsQueryServiceShape => {
|
||||
const query = new QdrantGraphEmbeddingsQuery(config);
|
||||
return {
|
||||
query: Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* (request) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => query.query(request),
|
||||
catch: (cause) => qdrantGraphEmbeddingsQueryError("query", cause),
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const QdrantGraphEmbeddingsQueryLive = (
|
||||
config: QdrantGraphQueryConfig = {},
|
||||
): Layer.Layer<QdrantGraphEmbeddingsQueryService> =>
|
||||
Layer.succeed(
|
||||
QdrantGraphEmbeddingsQueryService,
|
||||
QdrantGraphEmbeddingsQueryService.of(makeQdrantGraphEmbeddingsQueryService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,61 +13,95 @@ import {
|
|||
ProducerSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type TriplesQueryRequest,
|
||||
type TriplesQueryResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { FalkorDBTriplesQuery } from "./falkordb.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
FalkorDBTriplesQueryLive,
|
||||
FalkorDBTriplesQueryService,
|
||||
makeFalkorDBTriplesQueryService,
|
||||
type FalkorDBQueryConfig,
|
||||
} from "./falkordb.js";
|
||||
|
||||
export class TriplesQueryService extends FlowProcessor {
|
||||
private query: FalkorDBTriplesQuery;
|
||||
const onTriplesQueryMessage = Effect.fn("TriplesQueryService.onMessage")(function* (
|
||||
msg: TriplesQueryRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<FalkorDBTriplesQueryService>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = yield* flowCtx.flow.producerEffect<TriplesQueryResponse>("triples-response");
|
||||
const query = yield* FalkorDBTriplesQueryService;
|
||||
const triples = yield* query.queryTriples(
|
||||
msg.s,
|
||||
msg.p,
|
||||
msg.o,
|
||||
msg.limit ?? 100,
|
||||
).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[TriplesQuery] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
triples: [],
|
||||
error: { type: "query-error", message: error.message },
|
||||
})
|
||||
),
|
||||
Effect.as(null),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (triples === null) return;
|
||||
|
||||
yield* producer.send(requestId, { triples: Array.from(triples) });
|
||||
});
|
||||
|
||||
export const makeTriplesQuerySpecs = (): ReadonlyArray<Spec<FalkorDBTriplesQueryService>> => [
|
||||
new ConsumerSpec<
|
||||
TriplesQueryRequest,
|
||||
FlowResourceNotFoundError | MessagingDeliveryError,
|
||||
FalkorDBTriplesQueryService
|
||||
>("triples-request", onTriplesQueryMessage),
|
||||
new ProducerSpec<TriplesQueryResponse>("triples-response"),
|
||||
];
|
||||
|
||||
export class TriplesQueryService extends FlowProcessor<FalkorDBTriplesQueryService> {
|
||||
private readonly query = makeFalkorDBTriplesQueryService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.query = new FalkorDBTriplesQuery();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<TriplesQueryRequest>("triples-request", this.onMessage.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<TriplesQueryResponse>("triples-response"));
|
||||
for (const spec of makeTriplesQuerySpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[TriplesQuery] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: TriplesQueryRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<TriplesQueryResponse>("triples-response");
|
||||
|
||||
try {
|
||||
const triples = await this.query.queryTriples(
|
||||
msg.s,
|
||||
msg.p,
|
||||
msg.o,
|
||||
msg.limit ?? 100,
|
||||
);
|
||||
|
||||
await producer.send(requestId, { triples });
|
||||
} catch (err) {
|
||||
console.error("[TriplesQuery] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
triples: [],
|
||||
error: { type: "query-error", message: String(err) },
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
FalkorDBTriplesQueryService,
|
||||
FalkorDBTriplesQueryService.of(this.query),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig & FalkorDBQueryConfig, never, FalkorDBTriplesQueryService>({
|
||||
id: "triples-query",
|
||||
make: (config) => new TriplesQueryService(config),
|
||||
specs: () => makeTriplesQuerySpecs(),
|
||||
layer: (config) => FalkorDBTriplesQueryLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await TriplesQueryService.launch("triples-query");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
*/
|
||||
|
||||
import { createClient, Graph } from "falkordb";
|
||||
import type { Term, Triple } from "@trustgraph/base";
|
||||
import { errorMessage, type Term, type Triple } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface FalkorDBQueryConfig {
|
||||
url?: string;
|
||||
|
|
@ -264,3 +266,61 @@ export class FalkorDBTriplesQuery {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FalkorDBTriplesQueryError extends S.TaggedErrorClass<FalkorDBTriplesQueryError>()(
|
||||
"FalkorDBTriplesQueryError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface FalkorDBTriplesQueryServiceShape {
|
||||
readonly queryTriples: (
|
||||
s: Term | undefined,
|
||||
p: Term | undefined,
|
||||
o: Term | undefined,
|
||||
limit: number,
|
||||
) => Effect.Effect<ReadonlyArray<Triple>, FalkorDBTriplesQueryError>;
|
||||
}
|
||||
|
||||
export class FalkorDBTriplesQueryService extends Context.Service<
|
||||
FalkorDBTriplesQueryService,
|
||||
FalkorDBTriplesQueryServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/query/triples/falkordb/FalkorDBTriplesQueryService",
|
||||
) {}
|
||||
|
||||
const falkorDBTriplesQueryError = (operation: string, cause: unknown) =>
|
||||
new FalkorDBTriplesQueryError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeFalkorDBTriplesQueryService = (
|
||||
config: FalkorDBQueryConfig = {},
|
||||
): FalkorDBTriplesQueryServiceShape => {
|
||||
const query = new FalkorDBTriplesQuery(config);
|
||||
return {
|
||||
queryTriples: Effect.fn("FalkorDBTriplesQuery.queryTriples")((
|
||||
s: Term | undefined,
|
||||
p: Term | undefined,
|
||||
o: Term | undefined,
|
||||
limit: number,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => query.queryTriples(s, p, o, limit),
|
||||
catch: (cause) => falkorDBTriplesQueryError("query-triples", cause),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const FalkorDBTriplesQueryLive = (
|
||||
config: FalkorDBQueryConfig = {},
|
||||
): Layer.Layer<FalkorDBTriplesQueryService> =>
|
||||
Layer.succeed(
|
||||
FalkorDBTriplesQueryService,
|
||||
FalkorDBTriplesQueryService.of(makeFalkorDBTriplesQueryService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,118 +1,166 @@
|
|||
/**
|
||||
* Document RAG service — FlowProcessor wrapper around the DocumentRag class.
|
||||
* Document RAG service.
|
||||
*
|
||||
* Consumes DocumentRagRequest messages, runs the document retrieval pipeline
|
||||
* (embed query → find similar chunks → synthesize answer), emits DocumentRagResponse.
|
||||
*
|
||||
* Each request gets its own DocumentRag instance for security isolation.
|
||||
* Consumes DocumentRagRequest messages, runs the document retrieval pipeline,
|
||||
* and emits DocumentRagResponse.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/retrieval/document_rag/
|
||||
*/
|
||||
|
||||
import {
|
||||
FlowProcessor,
|
||||
ConsumerSpec,
|
||||
FlowProcessor,
|
||||
ProducerSpec,
|
||||
RequestResponseSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type DocumentRagRequest,
|
||||
type DocumentRagResponse,
|
||||
type TextCompletionRequest,
|
||||
type TextCompletionResponse,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
makeFlowProcessorProgram,
|
||||
type DocumentEmbeddingsRequest,
|
||||
type DocumentEmbeddingsResponse,
|
||||
type DocumentRagRequest,
|
||||
type DocumentRagResponse,
|
||||
type EffectRequestOptions,
|
||||
type EffectRequestResponse,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
type FlowContext,
|
||||
type FlowRequestOptions,
|
||||
type FlowRequestor,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type ProcessorConfig,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type Spec,
|
||||
type TextCompletionRequest,
|
||||
type TextCompletionResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { DocumentRag } from "./document-rag.js";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
DocumentRagEngine,
|
||||
DocumentRagEngineError,
|
||||
DocumentRagLive,
|
||||
makeDocumentRagEngine,
|
||||
type DocumentRagClients,
|
||||
} from "./document-rag.js";
|
||||
|
||||
export class DocumentRagService extends FlowProcessor {
|
||||
const toEffectRequestOptions = <TRes>(
|
||||
options: FlowRequestOptions<TRes> | undefined,
|
||||
): EffectRequestOptions<TRes> | undefined => {
|
||||
if (options === undefined) return undefined;
|
||||
return {
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(options.recipient === undefined
|
||||
? {}
|
||||
: {
|
||||
recipient: (response: TRes) =>
|
||||
Effect.promise(() => options.recipient?.(response) ?? Promise.resolve(true)),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const toPromiseRequestor = <TReq, TRes>(
|
||||
requestor: EffectRequestResponse<TReq, TRes>,
|
||||
): FlowRequestor<TReq, TRes> => ({
|
||||
request: (request, options) =>
|
||||
Effect.runPromise(requestor.request(request, toEffectRequestOptions(options))),
|
||||
stop: () => Effect.runPromise(requestor.stop),
|
||||
});
|
||||
|
||||
const onDocumentRagRequest = Effect.fn("DocumentRagService.onRequest")(function* (
|
||||
msg: DocumentRagRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<DocumentRagEngine>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = yield* flowCtx.flow.producerEffect<DocumentRagResponse>("document-rag-response");
|
||||
const engine = yield* DocumentRagEngine;
|
||||
|
||||
const clients: DocumentRagClients = {
|
||||
llm: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<TextCompletionRequest, TextCompletionResponse>("llm")),
|
||||
embeddings: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<EmbeddingsRequest, EmbeddingsResponse>("embeddings")),
|
||||
docEmbeddings: toPromiseRequestor(
|
||||
yield* flowCtx.flow.requestorEffect<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>("doc-embeddings"),
|
||||
),
|
||||
prompt: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<PromptRequest, PromptResponse>("prompt")),
|
||||
};
|
||||
|
||||
const response = yield* engine.query(
|
||||
clients,
|
||||
msg.query,
|
||||
{
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.catch((error: DocumentRagEngineError) =>
|
||||
Effect.logError("[DocumentRag] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
response: "",
|
||||
error: { type: "rag-error", message: error.message },
|
||||
}),
|
||||
),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (response === undefined) return;
|
||||
yield* producer.send(requestId, { response, endOfStream: true });
|
||||
});
|
||||
|
||||
export const makeDocumentRagSpecs = (): ReadonlyArray<Spec<DocumentRagEngine>> => [
|
||||
new ConsumerSpec<DocumentRagRequest, FlowResourceNotFoundError | MessagingDeliveryError, DocumentRagEngine>(
|
||||
"document-rag-request",
|
||||
onDocumentRagRequest,
|
||||
),
|
||||
new ProducerSpec<DocumentRagResponse>("document-rag-response"),
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
new RequestResponseSpec<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>(
|
||||
"doc-embeddings",
|
||||
"document-embeddings-request",
|
||||
"document-embeddings-response",
|
||||
),
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
];
|
||||
|
||||
export class DocumentRagService extends FlowProcessor<DocumentRagEngine> {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
// Consumer: document RAG requests
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<DocumentRagRequest>("document-rag-request", this.onRequest.bind(this)),
|
||||
);
|
||||
|
||||
// Producer: document RAG responses
|
||||
this.registerSpecification(new ProducerSpec<DocumentRagResponse>("document-rag-response"));
|
||||
|
||||
// Request-response clients
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>(
|
||||
"doc-embeddings",
|
||||
"document-embeddings-request",
|
||||
"document-embeddings-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
);
|
||||
|
||||
console.log("[DocumentRag] Service initialized");
|
||||
for (const spec of makeDocumentRagSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: DocumentRagRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<DocumentRagResponse>("document-rag-response");
|
||||
|
||||
try {
|
||||
const documentRag = new DocumentRag({
|
||||
llm: flowCtx.flow.requestor<TextCompletionRequest, TextCompletionResponse>("llm"),
|
||||
embeddings: flowCtx.flow.requestor<EmbeddingsRequest, EmbeddingsResponse>("embeddings"),
|
||||
docEmbeddings: flowCtx.flow.requestor<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>("doc-embeddings"),
|
||||
prompt: flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt"),
|
||||
});
|
||||
|
||||
const response = await documentRag.query(msg.query, {
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
});
|
||||
|
||||
await producer.send(requestId, { response, endOfStream: true });
|
||||
} catch (err) {
|
||||
console.error("[DocumentRag] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
response: "",
|
||||
error: { type: "rag-error", message: String(err) },
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(DocumentRagEngine, DocumentRagEngine.of(makeDocumentRagEngine())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "document-rag",
|
||||
make: (config) => new DocumentRagService(config),
|
||||
specs: makeDocumentRagSpecs,
|
||||
layer: () => DocumentRagLive,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await DocumentRagService.launch("document-rag");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
/**
|
||||
* Document RAG retrieval pipeline.
|
||||
*
|
||||
* Simpler than Graph RAG — embeds the query, finds similar document chunks,
|
||||
* and synthesizes an answer from the chunk content.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/retrieval/document_rag/
|
||||
*/
|
||||
|
||||
import type {
|
||||
FlowRequestor,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
DocumentEmbeddingsRequest,
|
||||
DocumentEmbeddingsResponse,
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
FlowRequestor,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface DocumentRagClients {
|
||||
llm: FlowRequestor<TextCompletionRequest, TextCompletionResponse>;
|
||||
|
|
@ -28,55 +28,110 @@ export interface DocumentRagClients {
|
|||
|
||||
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
|
||||
|
||||
export interface DocumentRagQueryOptions {
|
||||
readonly collection?: string;
|
||||
readonly streaming?: boolean;
|
||||
readonly chunkCallback?: ChunkCallback;
|
||||
}
|
||||
|
||||
export class DocumentRagEngineError extends S.TaggedErrorClass<DocumentRagEngineError>()(
|
||||
"DocumentRagEngineError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface DocumentRagEngineShape {
|
||||
readonly query: (
|
||||
clients: DocumentRagClients,
|
||||
queryText: string,
|
||||
options?: DocumentRagQueryOptions,
|
||||
) => Effect.Effect<string, DocumentRagEngineError>;
|
||||
}
|
||||
|
||||
export class DocumentRagEngine extends Context.Service<DocumentRagEngine, DocumentRagEngineShape>()(
|
||||
"@trustgraph/flow/retrieval/document-rag/DocumentRagEngine",
|
||||
) {}
|
||||
|
||||
const documentRagError = (operation: string, cause: unknown) =>
|
||||
new DocumentRagEngineError({
|
||||
operation,
|
||||
cause,
|
||||
message: errorMessage(cause),
|
||||
});
|
||||
|
||||
export function makeDocumentRagEngine(): DocumentRagEngineShape {
|
||||
return {
|
||||
query: Effect.fn("DocumentRagEngine.query")((
|
||||
clients: DocumentRagClients,
|
||||
queryText: string,
|
||||
options?: DocumentRagQueryOptions,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => queryDocumentRag(clients, queryText, options),
|
||||
catch: (cause) => documentRagError("query", cause),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const DocumentRagLive: Layer.Layer<DocumentRagEngine> = Layer.succeed(
|
||||
DocumentRagEngine,
|
||||
DocumentRagEngine.of(makeDocumentRagEngine()),
|
||||
);
|
||||
|
||||
export class DocumentRag {
|
||||
private readonly engine = makeDocumentRagEngine();
|
||||
private readonly clients: DocumentRagClients;
|
||||
|
||||
constructor(clients: DocumentRagClients) {
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
async query(
|
||||
query(
|
||||
queryText: string,
|
||||
options?: {
|
||||
collection?: string;
|
||||
streaming?: boolean;
|
||||
chunkCallback?: ChunkCallback;
|
||||
},
|
||||
options?: DocumentRagQueryOptions,
|
||||
): Promise<string> {
|
||||
const collection = options?.collection ?? "default";
|
||||
|
||||
// Step 1: Embed the query
|
||||
const embResp = await this.clients.embeddings.request({ text: [queryText] });
|
||||
const vectors = (embResp as EmbeddingsResponse).vectors;
|
||||
|
||||
// Step 2: Find similar document chunks
|
||||
const docResp = await this.clients.docEmbeddings.request({
|
||||
vectors,
|
||||
limit: 10,
|
||||
collection,
|
||||
user: "default",
|
||||
});
|
||||
const chunks = (docResp as DocumentEmbeddingsResponse).chunks ?? [];
|
||||
console.log(`[DocumentRag] Found ${chunks.length} matching chunks`);
|
||||
|
||||
// Step 3: Build context from chunks
|
||||
const context = chunks
|
||||
.flatMap((c) =>
|
||||
c.content !== undefined && c.content.length > 0 ? [c.content] : [],
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
// Step 4: Synthesize answer
|
||||
const promptResp = await this.clients.prompt.request({
|
||||
name: "document-rag-synthesize",
|
||||
variables: { query: queryText, context },
|
||||
});
|
||||
|
||||
const resp = await this.clients.llm.request({
|
||||
system: (promptResp as PromptResponse).system,
|
||||
prompt: (promptResp as PromptResponse).prompt,
|
||||
});
|
||||
|
||||
return (resp as TextCompletionResponse).response;
|
||||
return Effect.runPromise(this.engine.query(this.clients, queryText, options));
|
||||
}
|
||||
}
|
||||
|
||||
async function queryDocumentRag(
|
||||
clients: DocumentRagClients,
|
||||
queryText: string,
|
||||
options?: DocumentRagQueryOptions,
|
||||
): Promise<string> {
|
||||
const collection = options?.collection ?? "default";
|
||||
|
||||
const embResp = await clients.embeddings.request({ text: [queryText] });
|
||||
const vectors = embResp.vectors;
|
||||
|
||||
const docResp = await clients.docEmbeddings.request({
|
||||
vectors,
|
||||
limit: 10,
|
||||
collection,
|
||||
user: "default",
|
||||
});
|
||||
const chunks = docResp.chunks ?? [];
|
||||
console.log(`[DocumentRag] Found ${chunks.length} matching chunks`);
|
||||
|
||||
const context = chunks
|
||||
.flatMap((chunk) =>
|
||||
chunk.content !== undefined && chunk.content.length > 0 ? [chunk.content] : [],
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
const promptResp = await clients.prompt.request({
|
||||
name: "document-rag-synthesize",
|
||||
variables: { query: queryText, context },
|
||||
});
|
||||
|
||||
const resp = await clients.llm.request({
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
});
|
||||
|
||||
return resp.response;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,158 +1,197 @@
|
|||
/**
|
||||
* Graph RAG service — FlowProcessor wrapper around the GraphRag class.
|
||||
* Graph RAG service.
|
||||
*
|
||||
* Consumes GraphRagRequest messages from the agent/gateway, runs the full
|
||||
* Graph RAG pipeline (concept extraction → entity lookup → graph traversal →
|
||||
* edge scoring → answer synthesis), and emits GraphRagResponse.
|
||||
*
|
||||
* Each request gets its own GraphRag instance to prevent data leakage
|
||||
* across requests (security requirement from the Python implementation).
|
||||
* Graph RAG pipeline, and emits GraphRagResponse.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/retrieval/graph_rag/rag.py
|
||||
*/
|
||||
|
||||
import {
|
||||
FlowProcessor,
|
||||
ConsumerSpec,
|
||||
FlowProcessor,
|
||||
ProducerSpec,
|
||||
RequestResponseSpec,
|
||||
type ProcessorConfig,
|
||||
makeFlowProcessorProgram,
|
||||
type EffectRequestOptions,
|
||||
type EffectRequestResponse,
|
||||
type FlowContext,
|
||||
type GraphRagRequest,
|
||||
type GraphRagResponse,
|
||||
type TextCompletionRequest,
|
||||
type TextCompletionResponse,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
type FlowRequestOptions,
|
||||
type FlowRequestor,
|
||||
type FlowResourceNotFoundError,
|
||||
type GraphEmbeddingsRequest,
|
||||
type GraphEmbeddingsResponse,
|
||||
type TriplesQueryRequest,
|
||||
type TriplesQueryResponse,
|
||||
type GraphRagRequest,
|
||||
type GraphRagResponse,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
type MessagingDeliveryError,
|
||||
type ProcessorConfig,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type Spec,
|
||||
type TextCompletionRequest,
|
||||
type TextCompletionResponse,
|
||||
type TriplesQueryRequest,
|
||||
type TriplesQueryResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { GraphRag } from "./graph-rag.js";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
GraphRagEngine,
|
||||
GraphRagEngineError,
|
||||
GraphRagLive,
|
||||
makeGraphRagEngine,
|
||||
type GraphRagClients,
|
||||
type GraphRagConfig,
|
||||
} from "./graph-rag.js";
|
||||
|
||||
export class GraphRagService extends FlowProcessor {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
const toEffectRequestOptions = <TRes>(
|
||||
options: FlowRequestOptions<TRes> | undefined,
|
||||
): EffectRequestOptions<TRes> | undefined => {
|
||||
if (options === undefined) return undefined;
|
||||
return {
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(options.recipient === undefined
|
||||
? {}
|
||||
: {
|
||||
recipient: (response: TRes) =>
|
||||
Effect.promise(() => options.recipient?.(response) ?? Promise.resolve(true)),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// Consumer: graph RAG requests
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<GraphRagRequest>("graph-rag-request", this.onRequest.bind(this)),
|
||||
);
|
||||
const toPromiseRequestor = <TReq, TRes>(
|
||||
requestor: EffectRequestResponse<TReq, TRes>,
|
||||
): FlowRequestor<TReq, TRes> => ({
|
||||
request: (request, options) =>
|
||||
Effect.runPromise(requestor.request(request, toEffectRequestOptions(options))),
|
||||
stop: () => Effect.runPromise(requestor.stop),
|
||||
});
|
||||
|
||||
// Producer: graph RAG responses
|
||||
this.registerSpecification(new ProducerSpec<GraphRagResponse>("graph-rag-response"));
|
||||
const graphRagConfigFromRequest = (msg: GraphRagRequest): GraphRagConfig => ({
|
||||
...(msg.entityLimit !== undefined ? { entityLimit: msg.entityLimit } : {}),
|
||||
...(msg.tripleLimit !== undefined ? { tripleLimit: msg.tripleLimit } : {}),
|
||||
...(msg.maxSubgraphSize !== undefined ? { maxSubgraphSize: msg.maxSubgraphSize } : {}),
|
||||
...(msg.maxPathLength !== undefined ? { maxPathLength: msg.maxPathLength } : {}),
|
||||
});
|
||||
|
||||
// Request-response clients for the pipeline
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<GraphEmbeddingsRequest, GraphEmbeddingsResponse>(
|
||||
"graph-embeddings",
|
||||
"graph-embeddings-request",
|
||||
"graph-embeddings-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
|
||||
"triples",
|
||||
"triples-request",
|
||||
"triples-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
);
|
||||
const onGraphRagRequest = Effect.fn("GraphRagService.onRequest")(function* (
|
||||
msg: GraphRagRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<GraphRagEngine>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
console.log("[GraphRag] Service initialized");
|
||||
const producer = yield* flowCtx.flow.producerEffect<GraphRagResponse>("graph-rag-response");
|
||||
const engine = yield* GraphRagEngine;
|
||||
|
||||
yield* Effect.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`);
|
||||
|
||||
const clients: GraphRagClients = {
|
||||
llm: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<TextCompletionRequest, TextCompletionResponse>("llm")),
|
||||
embeddings: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<EmbeddingsRequest, EmbeddingsResponse>("embeddings")),
|
||||
graphEmbeddings: toPromiseRequestor(
|
||||
yield* flowCtx.flow.requestorEffect<GraphEmbeddingsRequest, GraphEmbeddingsResponse>("graph-embeddings"),
|
||||
),
|
||||
triples: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<TriplesQueryRequest, TriplesQueryResponse>("triples")),
|
||||
prompt: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<PromptRequest, PromptResponse>("prompt")),
|
||||
};
|
||||
|
||||
const result = yield* engine.query(
|
||||
clients,
|
||||
msg.query,
|
||||
{
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
},
|
||||
graphRagConfigFromRequest(msg),
|
||||
).pipe(
|
||||
Effect.catch((error: GraphRagEngineError) =>
|
||||
Effect.logError("[GraphRag] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
response: "",
|
||||
error: { type: "rag-error", message: error.message },
|
||||
}),
|
||||
),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result === undefined) return;
|
||||
|
||||
const response: GraphRagResponse = {
|
||||
response: result.answer,
|
||||
endOfStream: true,
|
||||
};
|
||||
|
||||
if (result.subgraph.length > 0) {
|
||||
(response as Record<string, unknown>).message_type = "explain";
|
||||
(response as Record<string, unknown>).explain_id = `explain-${requestId}`;
|
||||
(response as Record<string, unknown>).explain_triples = result.subgraph;
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: GraphRagRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
yield* producer.send(requestId, response);
|
||||
});
|
||||
|
||||
const producer = flowCtx.flow.producer<GraphRagResponse>("graph-rag-response");
|
||||
console.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`);
|
||||
export const makeGraphRagSpecs = (): ReadonlyArray<Spec<GraphRagEngine>> => [
|
||||
new ConsumerSpec<GraphRagRequest, FlowResourceNotFoundError | MessagingDeliveryError, GraphRagEngine>(
|
||||
"graph-rag-request",
|
||||
onGraphRagRequest,
|
||||
),
|
||||
new ProducerSpec<GraphRagResponse>("graph-rag-response"),
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
new RequestResponseSpec<GraphEmbeddingsRequest, GraphEmbeddingsResponse>(
|
||||
"graph-embeddings",
|
||||
"graph-embeddings-request",
|
||||
"graph-embeddings-response",
|
||||
),
|
||||
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
|
||||
"triples",
|
||||
"triples-request",
|
||||
"triples-response",
|
||||
),
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
];
|
||||
|
||||
try {
|
||||
// Create a per-request GraphRag instance with flow clients
|
||||
const graphRag = new GraphRag(
|
||||
{
|
||||
llm: flowCtx.flow.requestor<TextCompletionRequest, TextCompletionResponse>("llm"),
|
||||
embeddings: flowCtx.flow.requestor<EmbeddingsRequest, EmbeddingsResponse>("embeddings"),
|
||||
graphEmbeddings: flowCtx.flow.requestor<GraphEmbeddingsRequest, GraphEmbeddingsResponse>("graph-embeddings"),
|
||||
triples: flowCtx.flow.requestor<TriplesQueryRequest, TriplesQueryResponse>("triples"),
|
||||
prompt: flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt"),
|
||||
},
|
||||
{
|
||||
...(msg.entityLimit !== undefined ? { entityLimit: msg.entityLimit } : {}),
|
||||
...(msg.tripleLimit !== undefined ? { tripleLimit: msg.tripleLimit } : {}),
|
||||
...(msg.maxSubgraphSize !== undefined
|
||||
? { maxSubgraphSize: msg.maxSubgraphSize }
|
||||
: {}),
|
||||
...(msg.maxPathLength !== undefined ? { maxPathLength: msg.maxPathLength } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await graphRag.query(msg.query, {
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
});
|
||||
|
||||
// Send answer with explain data embedded in a SINGLE message.
|
||||
// Non-streaming callers (agent's RequestResponse) return the first
|
||||
// response — so the answer must be in that first (and only) message.
|
||||
// Streaming callers (gateway) extract explain data + answer from
|
||||
// the same message.
|
||||
const response: GraphRagResponse = {
|
||||
response: result.answer,
|
||||
endOfStream: true,
|
||||
};
|
||||
|
||||
if (result.subgraph.length > 0) {
|
||||
(response as Record<string, unknown>).message_type = "explain";
|
||||
(response as Record<string, unknown>).explain_id = `explain-${requestId}`;
|
||||
(response as Record<string, unknown>).explain_triples = result.subgraph;
|
||||
}
|
||||
|
||||
await producer.send(requestId, response);
|
||||
} catch (err) {
|
||||
console.error("[GraphRag] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
response: "",
|
||||
error: { type: "rag-error", message: String(err) },
|
||||
});
|
||||
export class GraphRagService extends FlowProcessor<GraphRagEngine> {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
for (const spec of makeGraphRagSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
}
|
||||
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(GraphRagEngine, GraphRagEngine.of(makeGraphRagEngine())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "graph-rag",
|
||||
make: (config) => new GraphRagService(config),
|
||||
specs: makeGraphRagSpecs,
|
||||
layer: () => GraphRagLive,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await GraphRagService.launch("graph-rag");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
/**
|
||||
* Graph RAG retrieval pipeline.
|
||||
*
|
||||
* This is the core RAG pipeline that:
|
||||
* 1. Extracts concepts from the query
|
||||
* 2. Embeds concepts to find matching entities
|
||||
* 3. Traverses the knowledge graph from those entities
|
||||
* 4. Scores and filters edges
|
||||
* 5. Synthesizes an answer with the selected context
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/retrieval/graph_rag/graph_rag.py
|
||||
*/
|
||||
|
||||
import type {
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
FlowRequestor,
|
||||
GraphEmbeddingsRequest,
|
||||
GraphEmbeddingsResponse,
|
||||
FlowRequestor,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
Term,
|
||||
|
|
@ -26,6 +19,10 @@ import type {
|
|||
TriplesQueryRequest,
|
||||
TriplesQueryResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface GraphRagConfig {
|
||||
entityLimit?: number;
|
||||
|
|
@ -46,321 +43,373 @@ export interface GraphRagClients {
|
|||
|
||||
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
|
||||
|
||||
export interface GraphRagQueryOptions {
|
||||
readonly collection?: string;
|
||||
readonly streaming?: boolean;
|
||||
readonly chunkCallback?: ChunkCallback;
|
||||
}
|
||||
|
||||
export interface GraphRagResult {
|
||||
answer: string;
|
||||
subgraph: Triple[];
|
||||
}
|
||||
|
||||
interface NormalizedGraphRagConfig {
|
||||
entityLimit: number;
|
||||
tripleLimit: number;
|
||||
maxSubgraphSize: number;
|
||||
maxPathLength: number;
|
||||
edgeScoreLimit: number;
|
||||
edgeLimit: number;
|
||||
}
|
||||
|
||||
export class GraphRagEngineError extends S.TaggedErrorClass<GraphRagEngineError>()(
|
||||
"GraphRagEngineError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface GraphRagEngineShape {
|
||||
readonly query: (
|
||||
clients: GraphRagClients,
|
||||
queryText: string,
|
||||
options?: GraphRagQueryOptions,
|
||||
config?: GraphRagConfig,
|
||||
) => Effect.Effect<GraphRagResult, GraphRagEngineError>;
|
||||
}
|
||||
|
||||
export class GraphRagEngine extends Context.Service<GraphRagEngine, GraphRagEngineShape>()(
|
||||
"@trustgraph/flow/retrieval/graph-rag/GraphRagEngine",
|
||||
) {}
|
||||
|
||||
const graphRagError = (operation: string, cause: unknown) =>
|
||||
new GraphRagEngineError({
|
||||
operation,
|
||||
cause,
|
||||
message: errorMessage(cause),
|
||||
});
|
||||
|
||||
export function normalizeGraphRagConfig(config: GraphRagConfig = {}): NormalizedGraphRagConfig {
|
||||
return {
|
||||
entityLimit: config.entityLimit ?? 50,
|
||||
tripleLimit: config.tripleLimit ?? 30,
|
||||
maxSubgraphSize: config.maxSubgraphSize ?? 1000,
|
||||
maxPathLength: config.maxPathLength ?? 2,
|
||||
edgeScoreLimit: config.edgeScoreLimit ?? 50,
|
||||
edgeLimit: config.edgeLimit ?? 25,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeGraphRagEngine(): GraphRagEngineShape {
|
||||
return {
|
||||
query: Effect.fn("GraphRagEngine.query")((
|
||||
clients: GraphRagClients,
|
||||
queryText: string,
|
||||
options?: GraphRagQueryOptions,
|
||||
config?: GraphRagConfig,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => queryGraphRag(clients, queryText, options, config),
|
||||
catch: (cause) => graphRagError("query", cause),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const GraphRagLive: Layer.Layer<GraphRagEngine> = Layer.succeed(
|
||||
GraphRagEngine,
|
||||
GraphRagEngine.of(makeGraphRagEngine()),
|
||||
);
|
||||
|
||||
export class GraphRag {
|
||||
private readonly engine = makeGraphRagEngine();
|
||||
private readonly clients: GraphRagClients;
|
||||
private config: Required<GraphRagConfig>;
|
||||
private readonly config: GraphRagConfig;
|
||||
|
||||
constructor(
|
||||
clients: GraphRagClients,
|
||||
config: GraphRagConfig = {},
|
||||
) {
|
||||
this.clients = clients;
|
||||
this.config = {
|
||||
entityLimit: config.entityLimit ?? 50,
|
||||
tripleLimit: config.tripleLimit ?? 30,
|
||||
maxSubgraphSize: config.maxSubgraphSize ?? 1000,
|
||||
maxPathLength: config.maxPathLength ?? 2,
|
||||
edgeScoreLimit: config.edgeScoreLimit ?? 50,
|
||||
edgeLimit: config.edgeLimit ?? 25,
|
||||
};
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async query(
|
||||
query(
|
||||
queryText: string,
|
||||
options?: {
|
||||
collection?: string;
|
||||
streaming?: boolean;
|
||||
chunkCallback?: ChunkCallback;
|
||||
},
|
||||
options?: GraphRagQueryOptions,
|
||||
): Promise<GraphRagResult> {
|
||||
console.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`);
|
||||
|
||||
// Step 1: Extract concepts from the query via prompt + LLM
|
||||
const concepts = await this.extractConcepts(queryText);
|
||||
console.log(`[GraphRag] Step 1: extracted ${concepts.length} concepts: ${concepts.slice(0, 5).join(", ")}`);
|
||||
|
||||
// Step 2: Embed concepts concurrently
|
||||
const vectors = await this.getVectors(concepts);
|
||||
console.log(`[GraphRag] Step 2: got ${vectors.length} vectors (dim=${vectors[0]?.length ?? 0})`);
|
||||
|
||||
// Step 3: Find matching entities via graph embeddings
|
||||
const entities = await this.getEntities(vectors, options?.collection);
|
||||
console.log(`[GraphRag] Step 3: found ${entities.length} matching entities`);
|
||||
|
||||
// Step 4: Traverse the knowledge graph from entities
|
||||
const subgraph = await this.followEdges(entities, options?.collection);
|
||||
console.log(`[GraphRag] Step 4: traversed graph, ${subgraph.length} triples in subgraph`);
|
||||
|
||||
// Step 5: Score and filter edges via LLM
|
||||
const scoredEdges = await this.scoreEdges(queryText, subgraph);
|
||||
console.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`);
|
||||
|
||||
// Step 6: Synthesize answer
|
||||
console.log(`[GraphRag] Step 6: synthesizing answer from ${scoredEdges.length} edges...`);
|
||||
const answer = await this.synthesize(
|
||||
queryText,
|
||||
scoredEdges,
|
||||
options?.chunkCallback,
|
||||
return Effect.runPromise(
|
||||
this.engine.query(this.clients, queryText, options, this.config),
|
||||
);
|
||||
console.log(`[GraphRag] Step 6: done (${answer.length} chars)`);
|
||||
|
||||
return { answer, subgraph: scoredEdges };
|
||||
}
|
||||
|
||||
private async extractConcepts(query: string): Promise<string[]> {
|
||||
const promptResp = await this.clients.prompt.request({
|
||||
name: "extract-concepts",
|
||||
variables: { query },
|
||||
});
|
||||
|
||||
const llmResp = await this.clients.llm.request({
|
||||
system: (promptResp as PromptResponse).system,
|
||||
prompt: (promptResp as PromptResponse).prompt,
|
||||
});
|
||||
|
||||
// Parse concepts from LLM response (newline-separated)
|
||||
return (llmResp as TextCompletionResponse).response
|
||||
.split("\n")
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 0);
|
||||
}
|
||||
|
||||
private async getVectors(concepts: string[]): Promise<number[][]> {
|
||||
const resp = await this.clients.embeddings.request({ text: concepts });
|
||||
return (resp as EmbeddingsResponse).vectors;
|
||||
}
|
||||
|
||||
private async getEntities(vectors: number[][], collection?: string): Promise<Term[]> {
|
||||
const resp = await this.clients.graphEmbeddings.request({
|
||||
vectors,
|
||||
user: "default",
|
||||
collection: collection ?? "default",
|
||||
limit: this.config.entityLimit,
|
||||
});
|
||||
return (resp as GraphEmbeddingsResponse).entities;
|
||||
}
|
||||
|
||||
private async followEdges(entities: Term[], collection?: string): Promise<Triple[]> {
|
||||
// BFS multi-hop traversal up to maxPathLength
|
||||
const visited = new Set<string>();
|
||||
const subgraph: Triple[] = [];
|
||||
|
||||
// Current frontier: the set of entities to expand at this depth level
|
||||
let currentLevel = new Set<string>(
|
||||
entities.map((e) => termToString(e)),
|
||||
);
|
||||
|
||||
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);
|
||||
const request: TriplesQueryRequest = {
|
||||
s: term,
|
||||
limit: this.config.tripleLimit,
|
||||
...(collection !== undefined ? { collection } : {}),
|
||||
};
|
||||
return this.clients.triples.request(request);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return subgraph.slice(0, this.config.maxSubgraphSize);
|
||||
}
|
||||
|
||||
private async scoreEdges(query: string, triples: Triple[]): Promise<Triple[]> {
|
||||
if (triples.length === 0) return [];
|
||||
|
||||
// If the subgraph is small enough, skip LLM scoring entirely
|
||||
// 500 triples is well within LLM context limits and avoids lossy scoring
|
||||
if (triples.length <= 500) {
|
||||
console.log(`[GraphRag] Skipping edge scoring — ${triples.length} triples fits in context directly`);
|
||||
return triples;
|
||||
}
|
||||
|
||||
// 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;
|
||||
console.log(`[GraphRag] Edge scoring LLM response (first 500 chars): ${responseText.slice(0, 500)}`);
|
||||
|
||||
// Parse scores from LLM response
|
||||
// Expected format: JSON array of { id: string, score: number }
|
||||
// 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 (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
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.length === 0) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed) as { id?: string; score?: number };
|
||||
if (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
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);
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[GraphRag] Edge scoring: LLM returned ${scored.length} scores, keeping top ${topN.length}, mapped ${result.length} triples`);
|
||||
|
||||
// If scoring failed entirely, fall back to returning the first edgeLimit triples
|
||||
if (result.length === 0) {
|
||||
return triples.slice(0, this.config.edgeLimit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async synthesize(
|
||||
query: string,
|
||||
edges: Triple[],
|
||||
chunkCallback?: ChunkCallback,
|
||||
): Promise<string> {
|
||||
// Format edges as context
|
||||
const context = edges
|
||||
.map((t) => `${termToString(t.s)} -> ${termToString(t.p)} -> ${termToString(t.o)}`)
|
||||
.join("\n");
|
||||
|
||||
const promptResp = await this.clients.prompt.request({
|
||||
name: "graph-rag-synthesize",
|
||||
variables: { query, context },
|
||||
});
|
||||
|
||||
if (chunkCallback !== undefined) {
|
||||
// Streaming response
|
||||
let fullText = "";
|
||||
await this.clients.llm.request(
|
||||
{
|
||||
system: (promptResp as PromptResponse).system,
|
||||
prompt: (promptResp as PromptResponse).prompt,
|
||||
streaming: true,
|
||||
},
|
||||
{
|
||||
recipient: async (resp) => {
|
||||
const r = resp as TextCompletionResponse;
|
||||
if (r.response.length > 0) {
|
||||
fullText += r.response;
|
||||
await chunkCallback(r.response, r.endOfStream === true);
|
||||
}
|
||||
return r.endOfStream === true;
|
||||
},
|
||||
},
|
||||
);
|
||||
return fullText;
|
||||
}
|
||||
|
||||
const resp = await this.clients.llm.request({
|
||||
system: (promptResp as PromptResponse).system,
|
||||
prompt: (promptResp as PromptResponse).prompt,
|
||||
});
|
||||
|
||||
return (resp as TextCompletionResponse).response;
|
||||
}
|
||||
}
|
||||
|
||||
function termToString(term: Term): string {
|
||||
async function queryGraphRag(
|
||||
clients: GraphRagClients,
|
||||
queryText: string,
|
||||
options?: GraphRagQueryOptions,
|
||||
rawConfig?: GraphRagConfig,
|
||||
): Promise<GraphRagResult> {
|
||||
const config = normalizeGraphRagConfig(rawConfig);
|
||||
console.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`);
|
||||
|
||||
const concepts = await extractConcepts(clients, queryText);
|
||||
console.log(`[GraphRag] Step 1: extracted ${concepts.length} concepts: ${concepts.slice(0, 5).join(", ")}`);
|
||||
|
||||
const vectors = await getVectors(clients, concepts);
|
||||
console.log(`[GraphRag] Step 2: got ${vectors.length} vectors (dim=${vectors[0]?.length ?? 0})`);
|
||||
|
||||
const entities = await getEntities(clients, config, vectors, options?.collection);
|
||||
console.log(`[GraphRag] Step 3: found ${entities.length} matching entities`);
|
||||
|
||||
const subgraph = await followEdges(clients, config, entities, options?.collection);
|
||||
console.log(`[GraphRag] Step 4: traversed graph, ${subgraph.length} triples in subgraph`);
|
||||
|
||||
const scoredEdges = await scoreEdges(clients, config, queryText, subgraph);
|
||||
console.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`);
|
||||
|
||||
console.log(`[GraphRag] Step 6: synthesizing answer from ${scoredEdges.length} edges...`);
|
||||
const answer = await synthesize(
|
||||
clients,
|
||||
queryText,
|
||||
scoredEdges,
|
||||
options?.chunkCallback,
|
||||
);
|
||||
console.log(`[GraphRag] Step 6: done (${answer.length} chars)`);
|
||||
|
||||
return { answer, subgraph: scoredEdges };
|
||||
}
|
||||
|
||||
async function extractConcepts(clients: GraphRagClients, query: string): Promise<string[]> {
|
||||
const promptResp = await clients.prompt.request({
|
||||
name: "extract-concepts",
|
||||
variables: { query },
|
||||
});
|
||||
|
||||
const llmResp = await clients.llm.request({
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
});
|
||||
|
||||
return llmResp.response
|
||||
.split("\n")
|
||||
.map((concept) => concept.trim())
|
||||
.filter((concept) => concept.length > 0);
|
||||
}
|
||||
|
||||
async function getVectors(clients: GraphRagClients, concepts: string[]): Promise<number[][]> {
|
||||
const resp = await clients.embeddings.request({ text: concepts });
|
||||
return resp.vectors;
|
||||
}
|
||||
|
||||
async function getEntities(
|
||||
clients: GraphRagClients,
|
||||
config: NormalizedGraphRagConfig,
|
||||
vectors: number[][],
|
||||
collection?: string,
|
||||
): Promise<Term[]> {
|
||||
const resp = await clients.graphEmbeddings.request({
|
||||
vectors,
|
||||
user: "default",
|
||||
collection: collection ?? "default",
|
||||
limit: config.entityLimit,
|
||||
});
|
||||
return resp.entities;
|
||||
}
|
||||
|
||||
async function followEdges(
|
||||
clients: GraphRagClients,
|
||||
config: NormalizedGraphRagConfig,
|
||||
entities: Term[],
|
||||
collection?: string,
|
||||
): Promise<Triple[]> {
|
||||
const visited = new Set<string>();
|
||||
const subgraph: Triple[] = [];
|
||||
let currentLevel = new Set<string>(
|
||||
entities.map((entity) => termToString(entity)),
|
||||
);
|
||||
|
||||
for (let depth = 0; depth < config.maxPathLength; depth++) {
|
||||
if (currentLevel.size === 0 || subgraph.length >= config.maxSubgraphSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
const unvisited = [...currentLevel].filter((entity) => !visited.has(entity));
|
||||
if (unvisited.length === 0) break;
|
||||
|
||||
const queries = unvisited.map((entityStr) => {
|
||||
const term = stringToTerm(entityStr);
|
||||
const request: TriplesQueryRequest = {
|
||||
s: term,
|
||||
limit: config.tripleLimit,
|
||||
...(collection !== undefined ? { collection } : {}),
|
||||
};
|
||||
return clients.triples.request(request);
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
const nextLevel = new Set<string>();
|
||||
|
||||
for (const result of results) {
|
||||
for (const triple of result.triples) {
|
||||
subgraph.push(triple);
|
||||
|
||||
if (depth < config.maxPathLength - 1) {
|
||||
const objStr = termToString(triple.o);
|
||||
if (!visited.has(objStr)) {
|
||||
nextLevel.add(objStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (subgraph.length >= config.maxSubgraphSize) {
|
||||
return subgraph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of currentLevel) {
|
||||
visited.add(entity);
|
||||
}
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
return subgraph.slice(0, config.maxSubgraphSize);
|
||||
}
|
||||
|
||||
async function scoreEdges(
|
||||
clients: GraphRagClients,
|
||||
config: NormalizedGraphRagConfig,
|
||||
query: string,
|
||||
triples: Triple[],
|
||||
): Promise<Triple[]> {
|
||||
if (triples.length === 0) return [];
|
||||
|
||||
if (triples.length <= 500) {
|
||||
console.log(`[GraphRag] Skipping edge scoring - ${triples.length} triples fits in context directly`);
|
||||
return triples;
|
||||
}
|
||||
|
||||
const edgeDescriptions = triples.map((triple, index) => ({
|
||||
id: String(index),
|
||||
s: termToString(triple.s),
|
||||
p: termToString(triple.p),
|
||||
o: termToString(triple.o),
|
||||
}));
|
||||
|
||||
const toScore = edgeDescriptions.slice(0, config.edgeScoreLimit);
|
||||
const knowledgeJson = JSON.stringify(toScore, null, 2);
|
||||
|
||||
const promptResp = await clients.prompt.request({
|
||||
name: "kg-edge-scoring",
|
||||
variables: {
|
||||
query,
|
||||
knowledge: knowledgeJson,
|
||||
},
|
||||
});
|
||||
|
||||
const llmResp = await clients.llm.request({
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
});
|
||||
|
||||
console.log(`[GraphRag] Edge scoring LLM response (first 500 chars): ${llmResp.response.slice(0, 500)}`);
|
||||
|
||||
const scored = parseScoredEdges(llmResp.response);
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const topN = scored.slice(0, config.edgeLimit);
|
||||
|
||||
const result: Triple[] = [];
|
||||
for (const entry of topN) {
|
||||
const idx = Number.parseInt(entry.id, 10);
|
||||
if (!Number.isNaN(idx) && idx >= 0 && idx < triples.length) {
|
||||
result.push(triples[idx]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[GraphRag] Edge scoring: LLM returned ${scored.length} scores, keeping top ${topN.length}, mapped ${result.length} triples`);
|
||||
|
||||
if (result.length === 0) {
|
||||
return triples.slice(0, config.edgeLimit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function synthesize(
|
||||
clients: GraphRagClients,
|
||||
query: string,
|
||||
edges: Triple[],
|
||||
chunkCallback?: ChunkCallback,
|
||||
): Promise<string> {
|
||||
const context = edges
|
||||
.map((triple) => `${termToString(triple.s)} -> ${termToString(triple.p)} -> ${termToString(triple.o)}`)
|
||||
.join("\n");
|
||||
|
||||
const promptResp = await clients.prompt.request({
|
||||
name: "graph-rag-synthesize",
|
||||
variables: { query, context },
|
||||
});
|
||||
|
||||
if (chunkCallback !== undefined) {
|
||||
let fullText = "";
|
||||
await clients.llm.request(
|
||||
{
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
streaming: true,
|
||||
},
|
||||
{
|
||||
recipient: async (resp) => {
|
||||
if (resp.response.length > 0) {
|
||||
fullText += resp.response;
|
||||
await chunkCallback(resp.response, resp.endOfStream === true);
|
||||
}
|
||||
return resp.endOfStream === true;
|
||||
},
|
||||
},
|
||||
);
|
||||
return fullText;
|
||||
}
|
||||
|
||||
const resp = await clients.llm.request({
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
});
|
||||
|
||||
return resp.response;
|
||||
}
|
||||
|
||||
const ScoredEdge = S.Struct({
|
||||
id: S.String,
|
||||
score: S.Number,
|
||||
});
|
||||
const ScoredEdgesFromJson = S.Array(ScoredEdge).pipe(S.fromJsonString);
|
||||
const ScoredEdgeFromJson = ScoredEdge.pipe(S.fromJsonString);
|
||||
const decodeScoredEdges = S.decodeUnknownOption(ScoredEdgesFromJson);
|
||||
const decodeScoredEdge = S.decodeUnknownOption(ScoredEdgeFromJson);
|
||||
|
||||
function parseScoredEdges(responseText: string): Array<typeof ScoredEdge.Type> {
|
||||
const parsedArray = decodeScoredEdges(responseText);
|
||||
if (O.isSome(parsedArray)) {
|
||||
return Array.from(parsedArray.value);
|
||||
}
|
||||
|
||||
const scored: Array<typeof ScoredEdge.Type> = [];
|
||||
for (const line of responseText.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
const parsedLine = decodeScoredEdge(trimmed);
|
||||
if (O.isSome(parsedLine)) {
|
||||
scored.push(parsedLine.value);
|
||||
}
|
||||
}
|
||||
return scored;
|
||||
}
|
||||
|
||||
export function termToString(term: Term): string {
|
||||
switch (term.type) {
|
||||
case "IRI":
|
||||
return term.iri;
|
||||
|
|
@ -373,7 +422,7 @@ function termToString(term: Term): string {
|
|||
}
|
||||
}
|
||||
|
||||
function stringToTerm(value: string): Term {
|
||||
export function stringToTerm(value: string): Term {
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
return { type: "IRI", iri: value };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,83 +15,112 @@ import {
|
|||
RequestResponseSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type MessagingTimeoutError,
|
||||
type EntityContexts,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { QdrantGraphEmbeddingsStore } from "./qdrant-graph.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
QdrantGraphEmbeddingsStoreLive,
|
||||
QdrantGraphEmbeddingsStoreService,
|
||||
makeQdrantGraphEmbeddingsStoreService,
|
||||
type QdrantGraphEmbeddingsConfig,
|
||||
type QdrantGraphEmbeddingsStoreError,
|
||||
} from "./qdrant-graph.js";
|
||||
|
||||
export class GraphEmbeddingsStoreService extends FlowProcessor {
|
||||
private store: QdrantGraphEmbeddingsStore;
|
||||
type GraphEmbeddingsStoreRequirements = QdrantGraphEmbeddingsStoreService;
|
||||
type GraphEmbeddingsStoreError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError
|
||||
| MessagingTimeoutError
|
||||
| QdrantGraphEmbeddingsStoreError;
|
||||
|
||||
const onGraphEmbeddingsStoreMessage = Effect.fn("GraphEmbeddingsStoreService.onMessage")(function* (
|
||||
msg: EntityContexts,
|
||||
_properties: Record<string, string>,
|
||||
flowCtx: FlowContext<GraphEmbeddingsStoreRequirements>,
|
||||
): Effect.fn.Return<void, GraphEmbeddingsStoreError, GraphEmbeddingsStoreRequirements> {
|
||||
if (msg.entities.length === 0) return;
|
||||
|
||||
const embeddingsClient =
|
||||
yield* flowCtx.flow.requestorEffect<EmbeddingsRequest, EmbeddingsResponse>("embeddings-client");
|
||||
|
||||
const user = msg.metadata?.user ?? "default";
|
||||
const collection = msg.metadata?.collection ?? "default";
|
||||
const texts = msg.entities.map((entity) => entity.context);
|
||||
|
||||
const embResponse = yield* embeddingsClient.request({ text: texts });
|
||||
if (embResponse.error !== undefined) {
|
||||
yield* Effect.logError("[GraphEmbeddingsStore] Embeddings error", {
|
||||
error: embResponse.error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const entities = msg.entities.map((entity, index) => ({
|
||||
entity: entity.entity,
|
||||
vector: embResponse.vectors[index],
|
||||
chunkId: entity.chunkId,
|
||||
}));
|
||||
const store = yield* QdrantGraphEmbeddingsStoreService;
|
||||
|
||||
yield* store.store({ user, collection, entities });
|
||||
|
||||
yield* Effect.log(
|
||||
`[GraphEmbeddingsStore] Stored ${entities.length} embeddings for ${user}/${collection}`,
|
||||
);
|
||||
});
|
||||
|
||||
export const makeGraphEmbeddingsStoreSpecs = (): ReadonlyArray<Spec<GraphEmbeddingsStoreRequirements>> => [
|
||||
new ConsumerSpec<EntityContexts, GraphEmbeddingsStoreError, GraphEmbeddingsStoreRequirements>(
|
||||
"store-graph-embeddings-input",
|
||||
onGraphEmbeddingsStoreMessage,
|
||||
),
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings-client",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
];
|
||||
|
||||
export class GraphEmbeddingsStoreService extends FlowProcessor<GraphEmbeddingsStoreRequirements> {
|
||||
private readonly store = makeQdrantGraphEmbeddingsStoreService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.store = new QdrantGraphEmbeddingsStore();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<EntityContexts>(
|
||||
"store-graph-embeddings-input",
|
||||
this.onMessage.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings-client",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
);
|
||||
for (const spec of makeGraphEmbeddingsStoreSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[GraphEmbeddingsStore] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: EntityContexts,
|
||||
_properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
if (msg.entities.length === 0) return;
|
||||
|
||||
const embeddingsClient =
|
||||
flowCtx.flow.requestor<EmbeddingsRequest, EmbeddingsResponse>("embeddings-client");
|
||||
|
||||
const user = msg.metadata?.user ?? "default";
|
||||
const collection = msg.metadata?.collection ?? "default";
|
||||
|
||||
// Get text contexts for vectorization
|
||||
const texts = msg.entities.map((e) => e.context);
|
||||
|
||||
// Call embeddings service
|
||||
const embResponse = await embeddingsClient.request({ text: texts });
|
||||
if (embResponse.error !== undefined) {
|
||||
console.error(
|
||||
"[GraphEmbeddingsStore] Embeddings error:",
|
||||
embResponse.error.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store entity+vector pairs
|
||||
const entities = msg.entities.map((e, i) => ({
|
||||
entity: e.entity,
|
||||
vector: embResponse.vectors[i],
|
||||
chunkId: e.chunkId,
|
||||
}));
|
||||
|
||||
await this.store.store({ user, collection, entities });
|
||||
|
||||
console.log(
|
||||
`[GraphEmbeddingsStore] Stored ${entities.length} embeddings for ${user}/${collection}`,
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
QdrantGraphEmbeddingsStoreService,
|
||||
QdrantGraphEmbeddingsStoreService.of(this.store),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<
|
||||
ProcessorConfig & QdrantGraphEmbeddingsConfig,
|
||||
never,
|
||||
GraphEmbeddingsStoreRequirements
|
||||
>({
|
||||
id: "graph-embeddings-store",
|
||||
make: (config) => new GraphEmbeddingsStoreService(config),
|
||||
specs: () => makeGraphEmbeddingsStoreSpecs(),
|
||||
layer: (config) => QdrantGraphEmbeddingsStoreLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await GraphEmbeddingsStoreService.launch("graph-embeddings-store");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import type { Term } from "@trustgraph/base";
|
||||
import { errorMessage, type Term } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface QdrantGraphEmbeddingsConfig {
|
||||
url?: string;
|
||||
|
|
@ -127,3 +129,67 @@ export class QdrantGraphEmbeddingsStore {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class QdrantGraphEmbeddingsStoreError extends S.TaggedErrorClass<QdrantGraphEmbeddingsStoreError>()(
|
||||
"QdrantGraphEmbeddingsStoreError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface QdrantGraphEmbeddingsStoreServiceShape {
|
||||
readonly store: (
|
||||
message: GraphEmbeddingsMessage,
|
||||
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
|
||||
readonly deleteCollection: (
|
||||
user: string,
|
||||
collection: string,
|
||||
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
|
||||
}
|
||||
|
||||
export class QdrantGraphEmbeddingsStoreService extends Context.Service<
|
||||
QdrantGraphEmbeddingsStoreService,
|
||||
QdrantGraphEmbeddingsStoreServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/storage/embeddings/qdrant-graph/QdrantGraphEmbeddingsStoreService",
|
||||
) {}
|
||||
|
||||
const qdrantGraphEmbeddingsStoreError = (operation: string, cause: unknown) =>
|
||||
new QdrantGraphEmbeddingsStoreError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeQdrantGraphEmbeddingsStoreService = (
|
||||
config: QdrantGraphEmbeddingsConfig = {},
|
||||
): QdrantGraphEmbeddingsStoreServiceShape => {
|
||||
const store = new QdrantGraphEmbeddingsStore(config);
|
||||
return {
|
||||
store: Effect.fn("QdrantGraphEmbeddingsStore.store")(function* (message) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => store.store(message),
|
||||
catch: (cause) => qdrantGraphEmbeddingsStoreError("store", cause),
|
||||
});
|
||||
}),
|
||||
deleteCollection: Effect.fn("QdrantGraphEmbeddingsStore.deleteCollection")(function* (
|
||||
user,
|
||||
collection,
|
||||
) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => store.deleteCollection(user, collection),
|
||||
catch: (cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause),
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const QdrantGraphEmbeddingsStoreLive = (
|
||||
config: QdrantGraphEmbeddingsConfig = {},
|
||||
): Layer.Layer<QdrantGraphEmbeddingsStoreService> =>
|
||||
Layer.succeed(
|
||||
QdrantGraphEmbeddingsStoreService,
|
||||
QdrantGraphEmbeddingsStoreService.of(makeQdrantGraphEmbeddingsStoreService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,47 +14,72 @@ import {
|
|||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type Triples,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { FalkorDBTriplesStore } from "./falkordb.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
FalkorDBTriplesStoreLive,
|
||||
FalkorDBTriplesStoreService,
|
||||
makeFalkorDBTriplesStoreService,
|
||||
type FalkorDBConfig,
|
||||
type FalkorDBTriplesStoreError,
|
||||
} from "./falkordb.js";
|
||||
|
||||
export class TriplesStoreService extends FlowProcessor {
|
||||
private store: FalkorDBTriplesStore;
|
||||
const onStoreTriplesMessage = Effect.fn("TriplesStoreService.onMessage")(function* (
|
||||
msg: Triples,
|
||||
_properties: Record<string, string>,
|
||||
_flowCtx: FlowContext<FalkorDBTriplesStoreService>,
|
||||
): Effect.fn.Return<void, FalkorDBTriplesStoreError, FalkorDBTriplesStoreService> {
|
||||
if (msg.triples.length === 0) return;
|
||||
|
||||
const user = msg.metadata?.user ?? "default";
|
||||
const collection = msg.metadata?.collection ?? "default";
|
||||
const store = yield* FalkorDBTriplesStoreService;
|
||||
|
||||
yield* store.storeTriples(msg.triples, user, collection);
|
||||
|
||||
yield* Effect.log(
|
||||
`[TriplesStore] Stored ${msg.triples.length} triples for ${user}/${collection}`,
|
||||
);
|
||||
});
|
||||
|
||||
export const makeTriplesStoreSpecs = (): ReadonlyArray<Spec<FalkorDBTriplesStoreService>> => [
|
||||
new ConsumerSpec<Triples, FalkorDBTriplesStoreError, FalkorDBTriplesStoreService>(
|
||||
"store-triples-input",
|
||||
onStoreTriplesMessage,
|
||||
),
|
||||
];
|
||||
|
||||
export class TriplesStoreService extends FlowProcessor<FalkorDBTriplesStoreService> {
|
||||
private readonly store = makeFalkorDBTriplesStoreService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.store = new FalkorDBTriplesStore();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<Triples>("store-triples-input", this.onMessage.bind(this)),
|
||||
);
|
||||
for (const spec of makeTriplesStoreSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[TriplesStore] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: Triples,
|
||||
_properties: Record<string, string>,
|
||||
_flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
if (msg.triples.length === 0) return;
|
||||
|
||||
const user = msg.metadata?.user ?? "default";
|
||||
const collection = msg.metadata?.collection ?? "default";
|
||||
|
||||
await this.store.storeTriples(msg.triples, user, collection);
|
||||
|
||||
console.log(
|
||||
`[TriplesStore] Stored ${msg.triples.length} triples for ${user}/${collection}`,
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
FalkorDBTriplesStoreService,
|
||||
FalkorDBTriplesStoreService.of(this.store),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig & FalkorDBConfig, never, FalkorDBTriplesStoreService>({
|
||||
id: "triples-store",
|
||||
make: (config) => new TriplesStoreService(config),
|
||||
specs: () => makeTriplesStoreSpecs(),
|
||||
layer: (config) => FalkorDBTriplesStoreLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await TriplesStoreService.launch("triples-store");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
*/
|
||||
|
||||
import { createClient, Graph } from "falkordb";
|
||||
import type { Term, Triple } from "@trustgraph/base";
|
||||
import { errorMessage, type Term, type Triple } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface FalkorDBConfig {
|
||||
url?: string;
|
||||
|
|
@ -130,3 +132,71 @@ export class FalkorDBTriplesStore {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FalkorDBTriplesStoreError extends S.TaggedErrorClass<FalkorDBTriplesStoreError>()(
|
||||
"FalkorDBTriplesStoreError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface FalkorDBTriplesStoreServiceShape {
|
||||
readonly storeTriples: (
|
||||
triples: ReadonlyArray<Triple>,
|
||||
user: string,
|
||||
collection: string,
|
||||
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
|
||||
readonly deleteCollection: (
|
||||
user: string,
|
||||
collection: string,
|
||||
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
|
||||
}
|
||||
|
||||
export class FalkorDBTriplesStoreService extends Context.Service<
|
||||
FalkorDBTriplesStoreService,
|
||||
FalkorDBTriplesStoreServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/storage/triples/falkordb/FalkorDBTriplesStoreService",
|
||||
) {}
|
||||
|
||||
const falkorDBTriplesStoreError = (operation: string, cause: unknown) =>
|
||||
new FalkorDBTriplesStoreError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeFalkorDBTriplesStoreService = (
|
||||
config: FalkorDBConfig = {},
|
||||
): FalkorDBTriplesStoreServiceShape => {
|
||||
const store = new FalkorDBTriplesStore(config);
|
||||
return {
|
||||
storeTriples: Effect.fn("FalkorDBTriplesStore.storeTriples")((
|
||||
triples: ReadonlyArray<Triple>,
|
||||
user: string,
|
||||
collection: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => store.storeTriples(Array.from(triples), user, collection),
|
||||
catch: (cause) => falkorDBTriplesStoreError("store-triples", cause),
|
||||
})),
|
||||
deleteCollection: Effect.fn("FalkorDBTriplesStore.deleteCollection")((
|
||||
user: string,
|
||||
collection: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => store.deleteCollection(user, collection),
|
||||
catch: (cause) => falkorDBTriplesStoreError("delete-collection", cause),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const FalkorDBTriplesStoreLive = (
|
||||
config: FalkorDBConfig = {},
|
||||
): Layer.Layer<FalkorDBTriplesStoreService> =>
|
||||
Layer.succeed(
|
||||
FalkorDBTriplesStoreService,
|
||||
FalkorDBTriplesStoreService.of(makeFalkorDBTriplesStoreService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,12 +13,26 @@
|
|||
"dependencies": {
|
||||
"@trustgraph/base": "workspace:*",
|
||||
"@trustgraph/client": "workspace:*",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/sql-pg": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-node": "4.0.0-beta.74",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export { createMcpServer, run } from "./server.js";
|
||||
export * from "./server-effect.js";
|
||||
|
|
|
|||
1726
ts/packages/mcp/src/server-effect.ts
Normal file
1726
ts/packages/mcp/src/server-effect.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -418,7 +418,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",
|
||||
gatewayUrl: process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/rpc",
|
||||
user: process.env.USER_ID ?? "mcp",
|
||||
flowId: process.env.FLOW_ID ?? "default",
|
||||
...(process.env.GATEWAY_SECRET !== undefined
|
||||
|
|
|
|||
|
|
@ -20,19 +20,8 @@ server {
|
|||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# WebSocket proxy (client connects to /api/socket, gateway listens on /api/v1/socket)
|
||||
location /api/socket {
|
||||
set $upstream_gateway gateway;
|
||||
proxy_pass http://$upstream_gateway:8088/api/v1/socket;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# WebSocket proxy (direct v1 path)
|
||||
location /api/v1/socket {
|
||||
# Effect RPC WebSocket proxy
|
||||
location /api/v1/rpc {
|
||||
set $upstream_gateway gateway;
|
||||
proxy_pass http://$upstream_gateway:8088;
|
||||
proxy_http_version 1.1;
|
||||
|
|
@ -41,4 +30,13 @@ server {
|
|||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Browser OTLP proxy (client posts /otel/v1/*, collector listens on 4318)
|
||||
location /otel/ {
|
||||
set $upstream_otel otel-collector;
|
||||
rewrite ^/otel/(.*)$ /$1 break;
|
||||
proxy_pass http://$upstream_otel:4318;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"qa:browser": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
|
|
@ -19,10 +20,26 @@
|
|||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.6.0",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"zustand": "^5.0.0"
|
||||
"zustand": "^5.0.0",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/sql-pg": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-node": "4.0.0-beta.74",
|
||||
"@effect/vitest": "4.0.0-beta.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
|
|
|
|||
41
ts/packages/workbench/playwright.config.ts
Normal file
41
ts/packages/workbench/playwright.config.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = Number(process.env.WORKBENCH_QA_PORT ?? 5174);
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/workbench-qa",
|
||||
outputDir: "../../.playwright/workbench/test-results",
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 3 : undefined,
|
||||
reporter: [["list"], ["html", { outputFolder: "../../.playwright/workbench/report", open: "never" }]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
cwd: ".",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "desktop",
|
||||
use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
|
||||
},
|
||||
{
|
||||
name: "tablet",
|
||||
use: { ...devices["iPad (gen 7)"], browserName: "chromium" },
|
||||
},
|
||||
{
|
||||
name: "mobile",
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
1673
ts/packages/workbench/src/atoms/workbench.ts
Normal file
1673
ts/packages/workbench/src/atoms/workbench.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,220 +1,90 @@
|
|||
import { lazy, Suspense } from "react";
|
||||
import { useAtom, useAtomValue } from "@effect/atom-react";
|
||||
import { Network, ChevronRight, ChevronDown, Loader2 } from "lucide-react";
|
||||
import * as Atom from "effect/unstable/reactivity/Atom";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Network,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Maximize,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
explainTriplesAtom,
|
||||
flowIdAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
} from "@/atoms/workbench";
|
||||
import {
|
||||
triplesToGraph,
|
||||
localName,
|
||||
hashColor,
|
||||
type GraphNode,
|
||||
type GraphLink,
|
||||
} from "@/lib/graph-utils";
|
||||
import type { ExplainEvent, Triple } from "@trustgraph/client";
|
||||
import type { ForceGraphMethods, ForceGraphProps } from "react-force-graph-2d";
|
||||
import type { ExplainEvent } from "@trustgraph/client";
|
||||
import type { ForceGraphProps } from "react-force-graph-2d";
|
||||
import type * as React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D (shares the same chunk as the graph page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<GraphNode, GraphLink>>;
|
||||
const explainExpandedAtom = Atom.make<Record<string, boolean>>({}).pipe(Atom.keepAlive);
|
||||
|
||||
interface ExplainGraphProps {
|
||||
explainEvents: ExplainEvent[];
|
||||
collection: string;
|
||||
}
|
||||
|
||||
function paintNode(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
const radius = Math.max(2.5, Math.sqrt(node.degree + 1) * 2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
const fontSize = Math.max(9 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = isLight ? "rgba(24,24,27,0.85)" : "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
}
|
||||
|
||||
function paintLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
if (globalScale < 1.5) return;
|
||||
const source = link.source as unknown as GraphNode;
|
||||
const target = link.target as unknown as GraphNode;
|
||||
if (source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return;
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
const fontSize = Math.max(7 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.6)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
}
|
||||
|
||||
export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [triples, setTriples] = useState<Triple[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// Track container width for the force graph
|
||||
useEffect(() => {
|
||||
if (!expanded || containerRef.current === null) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry !== undefined) setContainerWidth(Math.floor(entry.contentRect.width));
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [expanded]);
|
||||
|
||||
// Load triples when first expanded — use inline triples if available, otherwise fetch
|
||||
useEffect(() => {
|
||||
if (!expanded || fetched) return;
|
||||
setFetched(true);
|
||||
|
||||
// Check if any explain events have inline triples
|
||||
const inlineTriples = explainEvents.flatMap((ev) => ev.explainTriples ?? []);
|
||||
if (inlineTriples.length > 0) {
|
||||
setTriples(inlineTriples);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to fetching from named graph
|
||||
const graphUris = explainEvents.filter(
|
||||
(ev): ev is ExplainEvent & { explainGraph: string } =>
|
||||
ev.explainGraph !== undefined && ev.explainGraph.length > 0,
|
||||
);
|
||||
if (graphUris.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const flow = socket.flow(flowId);
|
||||
|
||||
Promise.all(
|
||||
graphUris.map((ev) =>
|
||||
flow
|
||||
.triplesQuery(undefined, undefined, undefined, 500, collection, ev.explainGraph)
|
||||
.catch(() => [] as Triple[]),
|
||||
),
|
||||
)
|
||||
.then((results) => {
|
||||
setTriples(results.flat());
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [expanded, fetched, explainEvents, socket, flowId, collection]);
|
||||
|
||||
// Build graph data
|
||||
const { data: graphData, typeMap } = useMemo(
|
||||
() => triplesToGraph(triples),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Auto-fit once data loads
|
||||
const hasAutoFit = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
graphData.nodes.length > 0 &&
|
||||
fgRef.current !== undefined &&
|
||||
hasAutoFit.current === false
|
||||
) {
|
||||
hasAutoFit.current = true;
|
||||
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 20), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [graphData.nodes.length]);
|
||||
|
||||
// Node painting (simplified version of graph page)
|
||||
const paintNode = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const radius = Math.max(2.5, Math.sqrt(node.degree + 1) * 2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
|
||||
const fontSize = Math.max(9 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = isLight
|
||||
? "rgba(24,24,27,0.85)"
|
||||
: "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Link label painting
|
||||
const paintLink = useCallback(
|
||||
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (globalScale < 1.5) return;
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (
|
||||
src.x === undefined ||
|
||||
src.y === undefined ||
|
||||
tgt.x === undefined ||
|
||||
tgt.y === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
||||
const fontSize = Math.max(7 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.6)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Compute unique types for mini legend
|
||||
const uniqueTypes = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const [, typeUri] of typeMap) {
|
||||
const name = localName(typeUri);
|
||||
if (!seen.has(name)) {
|
||||
seen.set(name, typeUri);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries());
|
||||
}, [typeMap]);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const [expandedMap, setExpandedMap] = useAtom(explainExpandedAtom);
|
||||
const key = `${flowId}:${collection}:${explainEvents.map((event) => event.explainGraph ?? event.explainId).join("|")}`;
|
||||
const expanded = expandedMap[key];
|
||||
const result = useAtomValue(explainTriplesAtom({ events: explainEvents, flowId, collection }));
|
||||
const triples = resultData(result, []);
|
||||
const loading = expanded && resultLoading(result, triples);
|
||||
const error = resultError(result);
|
||||
const { data: graphData, typeMap } = triplesToGraph(triples);
|
||||
const uniqueTypes = Array.from(new Set(Array.from(typeMap.values()).map(localName))).sort();
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-border/50">
|
||||
{/* Toggle header */}
|
||||
<button
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
onClick={() => setExpandedMap({ ...expandedMap, [key]: !expanded })}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted hover:bg-surface-100/50"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Network className="h-3 w-3 shrink-0 text-brand-400" />
|
||||
<span>View source graph</span>
|
||||
<Badge variant="info">{explainEvents.length} subgraph{explainEvents.length > 1 ? "s" : ""}</Badge>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border/50">
|
||||
{loading && (
|
||||
|
|
@ -223,100 +93,32 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
|||
Loading source graph...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<p className="px-3 py-3 text-xs text-error">
|
||||
Failed to load graph: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error !== null && <p className="px-3 py-3 text-xs text-error">Failed to load graph: {error}</p>}
|
||||
{!loading && error === null && graphData.nodes.length === 0 && (
|
||||
<p className="px-3 py-4 text-center text-xs text-fg-subtle">
|
||||
No graph data available for this query.
|
||||
</p>
|
||||
<p className="px-3 py-4 text-center text-xs text-fg-subtle">No graph data available for this query.</p>
|
||||
)}
|
||||
|
||||
{!loading && graphData.nodes.length > 0 && (
|
||||
<>
|
||||
{/* Graph info bar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-[10px] text-fg-subtle">
|
||||
<span>
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fgRef.current?.zoomToFit(400, 20)}
|
||||
className="rounded p-1 hover:bg-surface-200 hover:text-fg"
|
||||
title="Fit to view"
|
||||
aria-label="Fit to view"
|
||||
>
|
||||
<Maximize className="h-3 w-3" />
|
||||
</button>
|
||||
<span>{graphData.nodes.length} nodes, {graphData.links.length} edges</span>
|
||||
<div className="flex gap-1">
|
||||
{uniqueTypes.slice(0, 4).map((type) => <Badge key={type} variant="info">{type}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini graph canvas */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative bg-surface-0"
|
||||
style={{ height: 280 }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-fg-subtle" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="relative h-[280px] overflow-hidden bg-surface-0">
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-xs text-fg-subtle">Loading graph...</div>}>
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
width={600}
|
||||
height={280}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
nodeCanvasObject={paintNode}
|
||||
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
|
||||
const radius = Math.max(
|
||||
2.5,
|
||||
Math.sqrt(node.degree + 1) * 2,
|
||||
);
|
||||
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={3}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
backgroundColor="transparent"
|
||||
{...(containerWidth > 0 ? { width: containerWidth } : {})}
|
||||
height={280}
|
||||
linkColor={() => "rgba(120,120,140,0.32)"}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Mini type legend */}
|
||||
{uniqueTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 border-t border-border/50 px-3 py-2">
|
||||
{uniqueTypes.slice(0, 8).map(([name]) => (
|
||||
<div key={name} className="flex items-center gap-1.5 text-[10px] text-fg-subtle">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: hashColor(name) }}
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
))}
|
||||
{uniqueTypes.length > 8 && (
|
||||
<span className="text-[10px] text-fg-subtle">
|
||||
+{uniqueTypes.length - 8} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import { Copy, Check, Trash2, RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copiedMessageIdAtom, copyMessageAtom } from "@/atoms/workbench";
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string;
|
||||
messageId: string;
|
||||
isLastAssistant: boolean;
|
||||
onDelete: () => void;
|
||||
onRegenerate?: () => void;
|
||||
|
|
@ -11,38 +13,25 @@ interface MessageActionsProps {
|
|||
|
||||
export function MessageActions({
|
||||
content,
|
||||
messageId,
|
||||
isLastAssistant,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
}: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedMessageId = useAtomValue(copiedMessageIdAtom);
|
||||
const copyMessage = useAtomSet(copyMessageAtom);
|
||||
const copied = copiedMessageId === messageId;
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for insecure contexts
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = content;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [content]);
|
||||
const handleCopy = () => {
|
||||
copyMessage({ id: messageId, content });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-8 right-2 z-10 flex items-center gap-0.5",
|
||||
"mt-1 flex w-fit items-center gap-0.5 lg:absolute lg:-top-8 lg:right-2 lg:z-10 lg:mt-0",
|
||||
"rounded-lg border border-border bg-surface-200 px-1 py-0.5 shadow-sm",
|
||||
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100",
|
||||
"opacity-100 transition-opacity lg:pointer-events-none lg:opacity-0 lg:group-hover:pointer-events-auto lg:group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useAtomValue } from "@effect/atom-react";
|
||||
import { Workflow, Database } from "lucide-react";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { flowIdAtom, settingsAtom } from "@/atoms/workbench";
|
||||
|
||||
/**
|
||||
* 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);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).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">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { useAtomValue } from "@effect/atom-react";
|
||||
import { Outlet } from "react-router";
|
||||
import { WifiOff } from "lucide-react";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { FlowSelector } from "./flow-selector";
|
||||
import { GlowBackground } from "./glow-background";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { connectionStateAtom, isLoadingAtom } from "@/atoms/workbench";
|
||||
|
||||
/**
|
||||
* Top loading bar -- shown when any global activity is in progress.
|
||||
*/
|
||||
function LoadingBar() {
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
const isLoading = useAtomValue(isLoadingAtom);
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ function LoadingBar() {
|
|||
* Root layout: fixed sidebar + scrollable main content area with a top bar.
|
||||
*/
|
||||
export function RootLayout() {
|
||||
const connectionState = useConnectionState();
|
||||
const connectionState = useAtomValue(connectionStateAtom);
|
||||
const isDisconnected =
|
||||
connectionState.status === "failed" ||
|
||||
connectionState.status === "reconnecting";
|
||||
|
|
@ -50,7 +50,7 @@ export function RootLayout() {
|
|||
<GlowBackground />
|
||||
|
||||
{/* Top bar */}
|
||||
<header className="relative z-10 flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50/80 backdrop-blur-sm px-6">
|
||||
<header className="relative z-10 flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50/80 px-3 backdrop-blur-sm sm:px-6">
|
||||
<FlowSelector />
|
||||
</header>
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ export function RootLayout() {
|
|||
)}
|
||||
|
||||
{/* Page content */}
|
||||
<main id="main-content" className="relative z-10 flex-1 overflow-y-auto p-6">
|
||||
<main id="main-content" className="relative z-10 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useAtom, useAtomValue } from "@effect/atom-react";
|
||||
import { NavLink } from "react-router";
|
||||
import {
|
||||
MessageSquareText,
|
||||
|
|
@ -16,10 +17,13 @@ import {
|
|||
} from "lucide-react";
|
||||
import { BeepGraphLogo } from "./beep-graph-logo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useFlows } from "@/hooks/use-flows";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import {
|
||||
connectionStateAtom,
|
||||
flowIdAtom,
|
||||
flowsAtom,
|
||||
resultData,
|
||||
settingsAtom,
|
||||
} from "@/atoms/workbench";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nav item
|
||||
|
|
@ -33,18 +37,23 @@ interface NavItemProps {
|
|||
|
||||
function NavItem({ to, icon: Icon, label }: NavItemProps) {
|
||||
return (
|
||||
<NavLink to={to} className="w-full rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface-50">
|
||||
<NavLink
|
||||
to={to}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="w-full rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface-50"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
"flex items-center justify-center rounded-lg px-2 py-2 text-sm font-medium transition-colors sm:justify-start sm:gap-3 sm:px-3",
|
||||
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>
|
||||
<span className="hidden truncate sm:inline">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
|
|
@ -56,7 +65,7 @@ function NavItem({ to, icon: Icon, label }: NavItemProps) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConnectionBadge() {
|
||||
const state = useConnectionState();
|
||||
const state = useAtomValue(connectionStateAtom);
|
||||
|
||||
const isConnected =
|
||||
state.status === "connected" ||
|
||||
|
|
@ -103,10 +112,9 @@ function ConnectionBadge() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowSelectorDropdown() {
|
||||
const { flows } = useFlows();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const setFlowId = useSessionStore((s) => s.setFlowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const flows = resultData(useAtomValue(flowsAtom), []);
|
||||
const [flowId, setFlowId] = useAtom(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-3">
|
||||
|
|
@ -148,26 +156,26 @@ function FlowSelectorDropdown() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Sidebar() {
|
||||
const { featureSwitches } = useSettings((s) => s.settings);
|
||||
const { featureSwitches } = useAtomValue(settingsAtom);
|
||||
|
||||
return (
|
||||
<aside aria-label="Sidebar" className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50">
|
||||
<aside aria-label="Sidebar" className="flex h-screen w-sidebar-collapsed shrink-0 flex-col border-r border-border bg-surface-50 sm:w-sidebar">
|
||||
{/* Logo area */}
|
||||
<div className="flex h-14 items-center gap-2.5 px-4">
|
||||
<div className="flex h-14 items-center justify-center gap-2.5 px-2 sm:justify-start sm:px-4">
|
||||
<BeepGraphLogo className="h-7 w-7 shrink-0 text-brand-400" />
|
||||
<span className="text-lg font-bold text-fg">Beep Graph</span>
|
||||
<span className="hidden text-lg font-bold text-fg sm:inline">Beep Graph</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
|
||||
{/* Flow & collection selectors */}
|
||||
<div className="py-3">
|
||||
<div className="hidden py-3 sm:block">
|
||||
<FlowSelectorDropdown />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
<div className="hidden mx-3 border-t border-border sm:block" />
|
||||
|
||||
{/* Navigation links */}
|
||||
<nav aria-label="Main navigation" className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-3">
|
||||
|
|
@ -185,7 +193,7 @@ export function Sidebar() {
|
|||
</nav>
|
||||
|
||||
{/* Footer: connection badge */}
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
<div className="hidden border-t border-border px-2 py-2 sm:block">
|
||||
<ConnectionBadge />
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNotification, type NotificationType } from "@/providers/notification-provider";
|
||||
import { notificationsAtom, removeNotificationAtom, type Notification } from "@/atoms/workbench";
|
||||
|
||||
const typeStyles: Record<NotificationType, string> = {
|
||||
const typeStyles: Record<Notification["type"], 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",
|
||||
|
|
@ -13,8 +14,8 @@ const typeStyles: Record<NotificationType, string> = {
|
|||
* 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);
|
||||
const notifications = useAtomValue(notificationsAtom);
|
||||
const removeNotification = useAtomSet(removeNotificationAtom);
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
import {
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { KeyboardEvent, MouseEvent, ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -19,11 +12,6 @@ interface DialogProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple modal dialog built with Tailwind.
|
||||
* Renders a backdrop overlay + centered content panel.
|
||||
* Includes focus trap, auto-focus, and Escape to close.
|
||||
*/
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -32,103 +20,24 @@ export function Dialog({
|
|||
footer,
|
||||
className,
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Save the element that triggered the dialog so we can restore focus on close
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement as HTMLElement | null;
|
||||
} else if (triggerRef.current !== null) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Auto-focus first focusable element when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || dialogRef.current === null) return;
|
||||
const focusable = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter(
|
||||
(el) =>
|
||||
el.hidden === false &&
|
||||
!(el as HTMLButtonElement).disabled &&
|
||||
el.offsetParent !== null &&
|
||||
window.getComputedStyle(el).display !== "none",
|
||||
);
|
||||
// Focus the first input/textarea if available, otherwise the close button
|
||||
const firstInput = focusable.find(
|
||||
(el) => el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT",
|
||||
);
|
||||
(firstInput ?? focusable[0])?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Focus trap — keep Tab within the dialog
|
||||
useEffect(() => {
|
||||
if (!open || dialogRef.current === null) return;
|
||||
const dialog = dialogRef.current;
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const focusable = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter(
|
||||
(el) =>
|
||||
el.hidden === false &&
|
||||
!(el as HTMLButtonElement).disabled &&
|
||||
el.offsetParent !== null &&
|
||||
window.getComputedStyle(el).display !== "none",
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleTab);
|
||||
return () => window.removeEventListener("keydown", handleTab);
|
||||
}, [open]);
|
||||
|
||||
const handleBackdrop = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const titleId = `dialog-title-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
const handleBackdrop = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleBackdrop}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef, useEffect, type TextareaHTMLAttributes } from "react";
|
||||
import type { CSSProperties, TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutoTextareaProps
|
||||
|
|
@ -14,31 +14,21 @@ export function AutoTextarea({
|
|||
maxRows = 6,
|
||||
className,
|
||||
value,
|
||||
style,
|
||||
...props
|
||||
}: AutoTextareaProps) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) 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]);
|
||||
const textareaStyle: CSSProperties & { fieldSizing?: "content" } = {
|
||||
...style,
|
||||
fieldSizing: "content",
|
||||
maxHeight: `calc(${maxRows}lh + 1.5rem)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
style={textareaStyle}
|
||||
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",
|
||||
"w-full resize-none overflow-y-auto 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}
|
||||
|
|
|
|||
|
|
@ -1,284 +0,0 @@
|
|||
import { useCallback, useRef } 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, ExplainEvent } from "@trustgraph/client";
|
||||
|
||||
function metadataFrom(metadata: StreamingMetadata | undefined): ChatMessage["metadata"] | undefined {
|
||||
if (metadata === undefined) return undefined;
|
||||
|
||||
const result: NonNullable<ChatMessage["metadata"]> = {};
|
||||
if (metadata.model !== undefined) result.model = metadata.model;
|
||||
if (metadata.in_token !== undefined) result.inTokens = metadata.in_token;
|
||||
if (metadata.out_token !== undefined) result.outTokens = metadata.out_token;
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function withoutActivePhase(message: ChatMessage): ChatMessage {
|
||||
const next = { ...message };
|
||||
delete next.activePhase;
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseChatReturn {
|
||||
submitMessage: (opts: { input: string }) => void;
|
||||
cancelRequest: () => void;
|
||||
regenerateLastMessage: () => 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 abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
if (abortControllerRef.current !== null) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
updateLastMessage((prev) =>
|
||||
withoutActivePhase({
|
||||
...prev,
|
||||
content: prev.content.length > 0 ? prev.content : "(Cancelled)",
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
removeActivity("Chat request");
|
||||
}, [updateLastMessage, removeActivity]);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
({ input }: { input: string }) => {
|
||||
if (input.trim().length === 0) return;
|
||||
|
||||
// Abort any in-flight request
|
||||
if (abortControllerRef.current !== null) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
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);
|
||||
|
||||
// Collect explainability events during streaming
|
||||
const explainEvents: ExplainEvent[] = [];
|
||||
const onExplain = (event: ExplainEvent) => {
|
||||
explainEvents.push(event);
|
||||
};
|
||||
|
||||
// Attach collected explain events to the message on completion
|
||||
const attachExplainEvents = () => {
|
||||
if (explainEvents.length > 0) {
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
explainEvents: [...explainEvents],
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Shared handler for streaming responses (graph-rag / document-rag)
|
||||
const onChunk = (
|
||||
chunk: string,
|
||||
complete: boolean,
|
||||
metadata?: StreamingMetadata,
|
||||
) => {
|
||||
updateLastMessage((prev) => {
|
||||
const next: ChatMessage = {
|
||||
...prev,
|
||||
content: prev.content + chunk,
|
||||
isStreaming: !complete,
|
||||
};
|
||||
const finalMetadata = complete ? metadataFrom(metadata) : undefined;
|
||||
return finalMetadata !== undefined
|
||||
? { ...next, metadata: finalMetadata }
|
||||
: next;
|
||||
});
|
||||
|
||||
if (complete) {
|
||||
attachExplainEvents();
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
updateLastMessage((prev) =>
|
||||
withoutActivePhase({
|
||||
...prev,
|
||||
content: prev.content.length > 0 ? 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, onExplain);
|
||||
break;
|
||||
|
||||
case "document-rag":
|
||||
flow.documentRagStreaming(input, onChunk, onError, undefined, collection, onExplain);
|
||||
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,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "think" as const }),
|
||||
};
|
||||
});
|
||||
},
|
||||
// observe
|
||||
(chunk, complete) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
observe: phases.observe + chunk,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "observe" as const }),
|
||||
};
|
||||
});
|
||||
},
|
||||
// answer
|
||||
(chunk, complete, metadata) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
const newAnswer = phases.answer + chunk;
|
||||
const next: ChatMessage = {
|
||||
...prev,
|
||||
content: newAnswer,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
answer: newAnswer,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "answer" as const }),
|
||||
isStreaming: !complete,
|
||||
};
|
||||
const finalMetadata = complete ? metadataFrom(metadata) : undefined;
|
||||
const withMetadata = finalMetadata !== undefined
|
||||
? { ...next, metadata: finalMetadata }
|
||||
: next;
|
||||
return complete ? withoutActivePhase(withMetadata) : withMetadata;
|
||||
});
|
||||
if (complete) {
|
||||
attachExplainEvents();
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
},
|
||||
// error
|
||||
onError,
|
||||
// explainability
|
||||
onExplain,
|
||||
// collection
|
||||
collection,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
socket,
|
||||
flowId,
|
||||
chatMode,
|
||||
collection,
|
||||
addMessage,
|
||||
updateLastMessage,
|
||||
setInput,
|
||||
addActivity,
|
||||
removeActivity,
|
||||
],
|
||||
);
|
||||
|
||||
const regenerateLastMessage = useCallback(() => {
|
||||
const msgs = useConversation.getState().messages;
|
||||
const lastAssistant = [...msgs].reverse().find((m) => m.role === "assistant");
|
||||
const lastUser = [...msgs].reverse().find((m) => m.role === "user");
|
||||
if (lastAssistant !== undefined && lastUser !== undefined) {
|
||||
useConversation.getState().deleteMessage(lastAssistant.id);
|
||||
submitMessage({ input: lastUser.content });
|
||||
}
|
||||
}, [submitMessage]);
|
||||
|
||||
return { submitMessage, cancelRequest, regenerateLastMessage };
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { ExplainEvent } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
/** Explainability events received during streaming (graph URIs for source subgraphs) */
|
||||
explainEvents?: ExplainEvent[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
deleteMessage: (id: string) => void;
|
||||
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
let _nextMsgId = 0;
|
||||
export function nextMessageId(): string {
|
||||
return `msg-${++_nextMsgId}-${Date.now()}`;
|
||||
}
|
||||
|
||||
export const useConversation = create<ConversationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
messages: [] as ChatMessage[],
|
||||
input: "",
|
||||
chatMode: "graph-rag" as ChatMode,
|
||||
|
||||
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],
|
||||
};
|
||||
}),
|
||||
|
||||
deleteMessage: (id) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.filter((m) => m.id !== id),
|
||||
})),
|
||||
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
}),
|
||||
{
|
||||
name: "tg-conversation",
|
||||
// Only persist messages and chatMode, not input or transient state
|
||||
partialize: (state) => {
|
||||
const MAX_PERSISTED_MESSAGES = 200;
|
||||
const filtered = state.messages.filter((m) => m.isStreaming !== true);
|
||||
return {
|
||||
messages: filtered.slice(-MAX_PERSISTED_MESSAGES),
|
||||
chatMode: state.chatMode,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
import type { DocumentMetadata } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessingMetadata {
|
||||
id: string;
|
||||
"document-id": string;
|
||||
flow: string;
|
||||
collection: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
phase: "preparing" | "uploading" | "finalizing";
|
||||
chunksTotal: number;
|
||||
chunksUploaded: number;
|
||||
bytesTotal: number;
|
||||
bytesUploaded: number;
|
||||
}
|
||||
|
||||
export interface UseLibraryReturn {
|
||||
documents: DocumentMetadata[];
|
||||
processing: ProcessingMetadata[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Refresh the documents list */
|
||||
getDocuments: () => Promise<void>;
|
||||
/** Upload a new document (auto-selects simple vs chunked based on size) */
|
||||
uploadDocument: (
|
||||
document: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
id?: string,
|
||||
) => Promise<void>;
|
||||
/** Upload a large document using chunked upload with progress tracking */
|
||||
uploadDocumentChunked: (
|
||||
base64Content: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
onProgress?: (progress: UploadProgress) => void,
|
||||
) => Promise<void>;
|
||||
/** Remove a document */
|
||||
removeDocument: (id: string, collection?: string) => Promise<void>;
|
||||
/** Get the list of currently-processing documents */
|
||||
getProcessing: () => Promise<void>;
|
||||
/** Fetch full metadata for a single document */
|
||||
getDocumentMetadata: (documentId: string) => Promise<DocumentMetadata | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLibrary(): UseLibraryReturn {
|
||||
const socket = useSocket();
|
||||
const user = useSettings((s) => s.settings.user);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
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 uploadDocumentChunked = useCallback(
|
||||
async (
|
||||
base64Content: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
onProgress?: (progress: UploadProgress) => void,
|
||||
) => {
|
||||
const act = "Upload document (chunked)";
|
||||
try {
|
||||
addActivity(act);
|
||||
const lib = socket.librarian();
|
||||
const totalSize = base64Content.length;
|
||||
|
||||
onProgress?.({
|
||||
phase: "preparing",
|
||||
chunksTotal: 0,
|
||||
chunksUploaded: 0,
|
||||
bytesTotal: totalSize,
|
||||
bytesUploaded: 0,
|
||||
});
|
||||
|
||||
// Begin the upload session
|
||||
const beginResp = await lib.beginUpload(
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
kind: mimeType,
|
||||
title,
|
||||
comments,
|
||||
tags,
|
||||
user,
|
||||
},
|
||||
totalSize,
|
||||
);
|
||||
|
||||
const uploadId = beginResp["upload-id"];
|
||||
const chunkSize = beginResp["chunk-size"];
|
||||
const totalChunks = beginResp["total-chunks"];
|
||||
|
||||
// Upload chunks sequentially
|
||||
let bytesUploaded = 0;
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, totalSize);
|
||||
const chunk = base64Content.slice(start, end);
|
||||
|
||||
await lib.uploadChunk(uploadId, i, chunk);
|
||||
bytesUploaded += chunk.length;
|
||||
|
||||
onProgress?.({
|
||||
phase: "uploading",
|
||||
chunksTotal: totalChunks,
|
||||
chunksUploaded: i + 1,
|
||||
bytesTotal: totalSize,
|
||||
bytesUploaded,
|
||||
});
|
||||
}
|
||||
|
||||
// Finalize
|
||||
onProgress?.({
|
||||
phase: "finalizing",
|
||||
chunksTotal: totalChunks,
|
||||
chunksUploaded: totalChunks,
|
||||
bytesTotal: totalSize,
|
||||
bytesUploaded: totalSize,
|
||||
});
|
||||
|
||||
await lib.completeUpload(uploadId);
|
||||
await getDocuments();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getDocuments],
|
||||
);
|
||||
|
||||
const getProcessing = useCallback(async () => {
|
||||
const act = "Load processing";
|
||||
try {
|
||||
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]);
|
||||
|
||||
const getDocumentMetadata = useCallback(
|
||||
async (documentId: string): Promise<DocumentMetadata | null> => {
|
||||
try {
|
||||
return await socket.librarian().getDocumentMetadata(documentId);
|
||||
} catch (err) {
|
||||
console.error("useLibrary.getDocumentMetadata error:", err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
return {
|
||||
documents,
|
||||
processing,
|
||||
loading,
|
||||
error,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
uploadDocumentChunked,
|
||||
removeDocument,
|
||||
getProcessing,
|
||||
getDocumentMetadata,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
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 McpServerConfig {
|
||||
url: string;
|
||||
"remote-name"?: string;
|
||||
"auth-token"?: string;
|
||||
}
|
||||
|
||||
export interface McpServerEntry {
|
||||
key: string;
|
||||
config: McpServerConfig;
|
||||
}
|
||||
|
||||
export interface ToolArgument {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ToolConfig {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
"mcp-tool"?: string;
|
||||
group?: string[];
|
||||
arguments?: ToolArgument[];
|
||||
}
|
||||
|
||||
export interface ToolEntry {
|
||||
key: string;
|
||||
config: ToolConfig;
|
||||
}
|
||||
|
||||
export interface UseMcpConfigReturn {
|
||||
servers: McpServerEntry[];
|
||||
tools: ToolEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
loadServers: () => Promise<void>;
|
||||
saveServer: (key: string, config: McpServerConfig) => Promise<void>;
|
||||
deleteServer: (key: string) => Promise<void>;
|
||||
|
||||
loadTools: () => Promise<void>;
|
||||
saveTool: (key: string, config: ToolConfig) => Promise<void>;
|
||||
deleteTool: (key: string) => Promise<void>;
|
||||
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMcpConfig(): UseMcpConfigReturn {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [servers, setServers] = useState<McpServerEntry[]>([]);
|
||||
const [tools, setTools] = useState<ToolEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadServers = useCallback(async () => {
|
||||
try {
|
||||
const raw = await socket.config().getValues("mcp");
|
||||
const entries: McpServerEntry[] = [];
|
||||
for (const item of raw as { key: string; value: string }[]) {
|
||||
try {
|
||||
entries.push({ key: item.key, config: JSON.parse(item.value) });
|
||||
} catch {
|
||||
console.warn(`[useMcpConfig] Failed to parse MCP server config: ${item.key}`);
|
||||
}
|
||||
}
|
||||
setServers(entries);
|
||||
} catch (err) {
|
||||
console.error("[useMcpConfig] loadServers error:", err);
|
||||
throw err;
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const loadTools = useCallback(async () => {
|
||||
try {
|
||||
const raw = await socket.config().getValues("tool");
|
||||
const entries: ToolEntry[] = [];
|
||||
for (const item of raw as { key: string; value: string }[]) {
|
||||
try {
|
||||
entries.push({ key: item.key, config: JSON.parse(item.value) });
|
||||
} catch {
|
||||
console.warn(`[useMcpConfig] Failed to parse tool config: ${item.key}`);
|
||||
}
|
||||
}
|
||||
setTools(entries);
|
||||
} catch (err) {
|
||||
console.error("[useMcpConfig] loadTools error:", err);
|
||||
throw err;
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const act = "Load MCP config";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
await Promise.all([loadServers(), loadTools()]);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [addActivity, removeActivity, loadServers, loadTools]);
|
||||
|
||||
const saveServer = useCallback(
|
||||
async (key: string, config: McpServerConfig) => {
|
||||
const act = `Save MCP server ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket
|
||||
.config()
|
||||
.putConfig([{ type: "mcp", key, value: JSON.stringify(config) }]);
|
||||
await loadServers();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadServers],
|
||||
);
|
||||
|
||||
const deleteServer = useCallback(
|
||||
async (key: string) => {
|
||||
const act = `Delete MCP server ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.config().deleteConfig({ type: "mcp", key });
|
||||
await loadServers();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadServers],
|
||||
);
|
||||
|
||||
const saveTool = useCallback(
|
||||
async (key: string, config: ToolConfig) => {
|
||||
const act = `Save tool ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket
|
||||
.config()
|
||||
.putConfig([{ type: "tool", key, value: JSON.stringify(config) }]);
|
||||
await loadTools();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadTools],
|
||||
);
|
||||
|
||||
const deleteTool = useCallback(
|
||||
async (key: string) => {
|
||||
const act = `Delete tool ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.config().deleteConfig({ type: "tool", key });
|
||||
await loadTools();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadTools],
|
||||
);
|
||||
|
||||
// Auto-load when connection becomes ready
|
||||
useEffect(() => {
|
||||
if (
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated"
|
||||
) {
|
||||
refresh();
|
||||
}
|
||||
}, [connectionState.status, refresh]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
tools,
|
||||
loading,
|
||||
error,
|
||||
loadServers,
|
||||
saveServer,
|
||||
deleteServer,
|
||||
loadTools,
|
||||
saveTool,
|
||||
deleteTool,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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 };
|
||||
}),
|
||||
}));
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
|
||||
export function usePrompts() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const [prompts, setPrompts] = useState<Array<{ id: string; name?: string; description?: string }>>([]);
|
||||
const [systemPrompt, setSystemPrompt] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPrompts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const list = await socket.config().getPrompts();
|
||||
setPrompts(Array.isArray(list) ? list : []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load prompts:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const loadSystemPrompt = useCallback(async () => {
|
||||
try {
|
||||
const sp = await socket.config().getSystemPrompt();
|
||||
setSystemPrompt(typeof sp === "string" ? sp : JSON.stringify(sp, null, 2));
|
||||
} catch (err) {
|
||||
console.error("Failed to load system prompt:", err);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const getPrompt = useCallback(async (id: string) => {
|
||||
return socket.config().getPrompt(id);
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadPrompts();
|
||||
loadSystemPrompt();
|
||||
}
|
||||
}, [connectionState.status, loadPrompts, loadSystemPrompt]);
|
||||
|
||||
return { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt };
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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 }),
|
||||
}));
|
||||
|
|
@ -1,37 +1,23 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RegistryProvider, useAtomMount } from "@effect/atom-react";
|
||||
import App from "@/App";
|
||||
import { SocketProvider } from "@/providers/socket-provider";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { connectionStateAtom, themeClassAtom } from "@/atoms/workbench";
|
||||
import { getWorkbenchQaInitialValues } from "@/qa/initial-values";
|
||||
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);
|
||||
useAtomMount(themeClassAtom);
|
||||
useAtomMount(connectionStateAtom);
|
||||
|
||||
return (
|
||||
<SocketProvider
|
||||
user={settings.user}
|
||||
{...(settings.apiKey.length > 0 ? { apiKey: settings.apiKey } : {})}
|
||||
{...(settings.gatewayUrl.length > 0 ? { socketUrl: settings.gatewayUrl } : {})}
|
||||
>
|
||||
<App />
|
||||
</SocketProvider>
|
||||
);
|
||||
return <App />;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RegistryProvider defaultIdleTTL={1_000} initialValues={getWorkbenchQaInitialValues()}>
|
||||
<AppRoot />
|
||||
</QueryClientProvider>
|
||||
</RegistryProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import {
|
||||
MessageSquareText,
|
||||
Send,
|
||||
|
|
@ -20,47 +15,49 @@ import {
|
|||
} 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 {
|
||||
agentPhaseExpandedAtom,
|
||||
cancelChatAtom,
|
||||
clearMessagesAtom,
|
||||
conversationAtom,
|
||||
deleteMessageAtom,
|
||||
isLoadingAtom,
|
||||
regenerateLastMessageAtom,
|
||||
setChatModeAtom,
|
||||
setConversationInputAtom,
|
||||
settingsAtom,
|
||||
submitMessageAtom,
|
||||
type ChatMessage,
|
||||
} from "@/atoms/workbench";
|
||||
import { AutoTextarea } from "@/components/ui/textarea";
|
||||
import { MessageActions } from "@/components/chat/message-actions";
|
||||
import { ExplainGraph } from "@/components/chat/explain-graph";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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({
|
||||
messageId,
|
||||
phase,
|
||||
icon,
|
||||
label,
|
||||
content,
|
||||
isActive,
|
||||
}: {
|
||||
messageId: string;
|
||||
phase: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
content: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
|
||||
|
||||
const [expandedMap, setExpandedMap] = useAtom(agentPhaseExpandedAtom);
|
||||
const key = `${messageId}:${phase}`;
|
||||
if (content.length === 0 && !isActive) return null;
|
||||
|
||||
// Auto-expand while actively streaming; user can override
|
||||
const expanded = manualToggle ?? isActive;
|
||||
const expanded = expandedMap[key] ?? isActive;
|
||||
|
||||
const phaseColors: Record<string, string> = {
|
||||
think: "border-amber-500/30 bg-amber-500/5",
|
||||
|
|
@ -75,40 +72,22 @@ function AgentPhaseBlock({
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
phaseColors[phase] ?? "border-border bg-surface-100",
|
||||
)}
|
||||
>
|
||||
<div className={cn("rounded-md border", phaseColors[phase] ?? "border-border bg-surface-100")}>
|
||||
<button
|
||||
onClick={() => setManualToggle((prev) => !(prev ?? isActive))}
|
||||
onClick={() => setExpandedMap({ ...expandedMap, [key]: !expanded })}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{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",
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
)}
|
||||
{isActive && <Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />}
|
||||
</button>
|
||||
{expanded && (content.length > 0 || isActive) && (
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
|
||||
<p className="whitespace-pre-wrap">
|
||||
{content.length > 0 ? content : isActive ? "..." : ""}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{content.length > 0 ? content : isActive ? "..." : ""}</p>
|
||||
{isActive && content.length > 0 && (
|
||||
<span className="mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
|
||||
)}
|
||||
|
|
@ -118,168 +97,146 @@ function AgentPhaseBlock({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: string }) {
|
||||
function MessageBubble({
|
||||
msg,
|
||||
collection,
|
||||
isLastAssistant,
|
||||
}: {
|
||||
msg: ChatMessage;
|
||||
collection: string;
|
||||
isLastAssistant: boolean;
|
||||
}) {
|
||||
const deleteMessage = useAtomSet(deleteMessageAtom);
|
||||
const regenerateLastMessage = useAtomSet(regenerateLastMessageAtom);
|
||||
const isUser = msg.role === "user";
|
||||
const agentPhases = msg.agentPhases;
|
||||
const isError = !isUser && msg.content.startsWith("Error:");
|
||||
|
||||
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"
|
||||
: isError
|
||||
? "mr-auto max-w-[80%] border border-error/30 bg-error/10 text-error"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{/* Agent phase blocks (only for agent messages) */}
|
||||
{agentPhases !== undefined && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{agentPhases.answer.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
<div className="group relative">
|
||||
<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"
|
||||
: isError
|
||||
? "mr-auto max-w-[80%] border border-error/30 bg-error/10 text-error"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{agentPhases !== undefined && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
messageId={msg.id}
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
messageId={msg.id}
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{agentPhases.answer.length > 0 && (
|
||||
<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>
|
||||
) : isError ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
) : isError ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{msg.isStreaming === true && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
{msg.isStreaming === true && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
|
||||
{/* Token metadata */}
|
||||
{msg.metadata !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <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>
|
||||
)}
|
||||
{msg.metadata !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <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>
|
||||
)}
|
||||
|
||||
{/* Explainability graph */}
|
||||
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
{!isUser && (
|
||||
<MessageActions
|
||||
messageId={msg.id}
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
onRegenerate={() => regenerateLastMessage()}
|
||||
/>
|
||||
)}
|
||||
</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, cancelRequest, regenerateLastMessage } = useChat();
|
||||
const deleteMessage = useConversation((s) => s.deleteMessage);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
const conversation = useAtomValue(conversationAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
const isLoading = useAtomValue(isLoadingAtom);
|
||||
const setInput = useAtomSet(setConversationInputAtom);
|
||||
const setChatMode = useAtomSet(setChatModeAtom);
|
||||
const clearMessages = useAtomSet(clearMessagesAtom);
|
||||
const submitMessage = useAtomSet(submitMessageAtom);
|
||||
const cancelRequest = useAtomSet(cancelChatAtom);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Elapsed time counter while loading
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsed(0);
|
||||
return;
|
||||
const handleSubmit = () => {
|
||||
if (conversation.input.trim().length > 0) {
|
||||
submitMessage({ input: conversation.input });
|
||||
}
|
||||
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (input.trim().length > 0) {
|
||||
submitMessage({ input });
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [input, submitMessage]);
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
const lastAssistantId = [...conversation.messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<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-muted">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Mode selector */}
|
||||
<div role="group" aria-label="Chat mode" className="flex rounded-lg border border-border bg-surface-100 p-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border border-border bg-surface-100 p-1">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode.value}
|
||||
onClick={() => setChatMode(mode.value)}
|
||||
aria-pressed={chatMode === mode.value}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
chatMode === mode.value
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
conversation.chatMode === mode.value
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
: "text-fg-muted hover:bg-surface-200 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{mode.label}
|
||||
|
|
@ -287,84 +244,68 @@ export default function ChatPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { cancelRequest(); clearMessages(); }}
|
||||
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
title="Clear messages"
|
||||
aria-label="Clear messages"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{conversation.messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => clearMessages(null)}
|
||||
className="rounded-lg border border-border p-2 text-fg-subtle hover:bg-surface-200 hover:text-error"
|
||||
aria-label="Clear conversation"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4 pt-10">
|
||||
{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">{MODES.find((m) => m.value === chatMode)?.label ?? chatMode}</span>
|
||||
</p>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-surface-50 p-4">
|
||||
{conversation.messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-sm text-fg-subtle">Start a conversation with TrustGraph.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{conversation.messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
msg={message}
|
||||
collection={collection}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastAssistant =
|
||||
msg.role === "assistant" &&
|
||||
idx === messages.length - 1;
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="group relative">
|
||||
{msg.isStreaming !== true && (
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
{...(isLastAssistant ? { onRegenerate: regenerateLastMessage } : {})}
|
||||
/>
|
||||
)}
|
||||
<MessageBubble msg={msg} collection={collection} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<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... {elapsed}s</span>
|
||||
<button
|
||||
onClick={cancelRequest}
|
||||
className="flex items-center gap-1 rounded-lg px-3 py-1 text-xs text-red-400 hover:bg-surface-200"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface-50 p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<AutoTextarea
|
||||
value={conversation.input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Ask using ${MODES.find((mode) => mode.value === conversation.chatMode)?.label ?? "TrustGraph"}...`}
|
||||
disabled={isLoading}
|
||||
maxRows={8}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={() => cancelRequest(null)}
|
||||
className="rounded-lg border border-border p-3 text-fg-muted hover:bg-error/10 hover:text-error"
|
||||
aria-label="Cancel request"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={conversation.input.trim().length === 0}
|
||||
className="rounded-lg bg-brand-600 p-3 text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</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)"
|
||||
aria-label="Chat message"
|
||||
maxRows={6}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={input.trim().length === 0 || isLoading}
|
||||
aria-label="Send message"
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Workflow,
|
||||
Plus,
|
||||
|
|
@ -7,579 +7,201 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
} 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 {
|
||||
activeActionAtom,
|
||||
encodeJsonUnknownString,
|
||||
flowBlueprintAtom,
|
||||
flowBlueprintsAtom,
|
||||
flowExpandedAtom,
|
||||
flowsAtom,
|
||||
flowsStartDialogOpenAtom,
|
||||
parseJsonUnknown,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
startFlowAtom,
|
||||
startFlowFormAtom,
|
||||
stopFlowAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start flow dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
function StartFlowDialog() {
|
||||
const [open, setOpen] = useAtom(flowsStartDialogOpenAtom);
|
||||
const [form, setForm] = useAtom(startFlowFormAtom);
|
||||
const startFlow = useAtomSet(startFlowAtom);
|
||||
const blueprintsResult = useAtomValue(flowBlueprintsAtom);
|
||||
const blueprints = resultData(blueprintsResult, []);
|
||||
const blueprintDetail = resultData(useAtomValue(flowBlueprintAtom(form.blueprint)), null) as Record<string, unknown> | null;
|
||||
const loadingBlueprints = resultLoading(blueprintsResult, blueprints);
|
||||
const isValid = form.id.trim().length > 0 && form.blueprint.length > 0 && form.description.trim().length > 0;
|
||||
|
||||
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);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [blueprintDef, setBlueprintDef] = useState<Record<string, unknown> | null>(null);
|
||||
const [loadingDef, setLoadingDef] = useState(false);
|
||||
const [defExpanded, setDefExpanded] = useState(false);
|
||||
|
||||
// Fetch blueprints when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoadingBlueprints(true);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprints()
|
||||
.then((names) => {
|
||||
const list = names ?? [];
|
||||
setBlueprints(list);
|
||||
if (list.length > 0 && blueprint.length === 0) {
|
||||
setBlueprint(list[0]!);
|
||||
}
|
||||
})
|
||||
.catch(() => setBlueprints([]))
|
||||
.finally(() => setLoadingBlueprints(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, socket]);
|
||||
|
||||
// Fetch blueprint definition when selection changes
|
||||
useEffect(() => {
|
||||
if (blueprint.length === 0) {
|
||||
setBlueprintDef(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingDef(true);
|
||||
setBlueprintDef(null);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprint(blueprint)
|
||||
.then((def) => {
|
||||
if (cancelled) return;
|
||||
setBlueprintDef(def);
|
||||
// Pre-populate parameters with defaults from the definition
|
||||
const paramsDef =
|
||||
def?.parameters ?? def?.params ?? def?.["parameters"] ?? def?.["params"];
|
||||
if (paramsDef !== undefined && paramsDef !== null && typeof paramsDef === "object") {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
const params = paramsDef as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (val !== null && typeof val === "object" && "default" in (val as Record<string, unknown>)) {
|
||||
defaults[key] = (val as Record<string, unknown>).default;
|
||||
}
|
||||
}
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
setParamsJson(JSON.stringify(defaults, null, 2));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled === false) setBlueprintDef(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled === false) setLoadingDef(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [blueprint, socket]);
|
||||
|
||||
const reset = () => {
|
||||
setId("");
|
||||
setBlueprint("");
|
||||
setDescription("");
|
||||
setParamsJson("{}");
|
||||
setParamsError(null);
|
||||
setSubmitting(false);
|
||||
setSubmitted(false);
|
||||
setBlueprintDef(null);
|
||||
setLoadingDef(false);
|
||||
setDefExpanded(false);
|
||||
const close = () => {
|
||||
setForm({
|
||||
id: "",
|
||||
blueprint: "",
|
||||
description: "",
|
||||
paramsJson: "{}",
|
||||
submitting: false,
|
||||
paramsError: null,
|
||||
submitted: false,
|
||||
definitionExpanded: false,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitted(true);
|
||||
if (!isValid) return;
|
||||
|
||||
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();
|
||||
}
|
||||
}}
|
||||
onClose={close}
|
||||
title="Start Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={submitting}
|
||||
onClick={close}
|
||||
disabled={form.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={submitting}
|
||||
onClick={() => {
|
||||
setForm({ ...form, submitted: true });
|
||||
if (!isValid) return;
|
||||
const parameters = parseJsonUnknown(form.paramsJson);
|
||||
if (parameters === undefined || typeof parameters !== "object" || parameters === null || Array.isArray(parameters)) {
|
||||
setForm({ ...form, paramsError: "Invalid JSON", submitted: true });
|
||||
return;
|
||||
}
|
||||
startFlow({
|
||||
id: form.id.trim(),
|
||||
blueprint: form.blueprint,
|
||||
description: form.description.trim(),
|
||||
parameters: parameters as Record<string, unknown>,
|
||||
});
|
||||
close();
|
||||
}}
|
||||
disabled={form.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" />}
|
||||
{form.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 htmlFor="flow-id" className="block text-sm font-medium text-fg-muted">
|
||||
Flow ID <span className="text-error">*</span>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Flow ID</span>
|
||||
<input
|
||||
value={form.id}
|
||||
onChange={(event) => setForm({ ...form, id: event.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"
|
||||
/>
|
||||
{form.submitted && form.id.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="flow-id"
|
||||
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"
|
||||
/>
|
||||
{submitted && id.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blueprint name */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-blueprint" 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
|
||||
id="flow-blueprint"
|
||||
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>
|
||||
)}
|
||||
{submitted && blueprint.length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
|
||||
)}
|
||||
|
||||
{/* Blueprint details info section */}
|
||||
{loadingDef && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprint details...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blueprintDef !== null && !loadingDef && (
|
||||
<div className="mt-2 rounded-lg border border-border bg-surface-50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<Info className="h-3.5 w-3.5 text-brand-400" />
|
||||
Blueprint Details
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Blueprint</span>
|
||||
{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={form.blueprint}
|
||||
onChange={(event) => setForm({ ...form, blueprint: event.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="">Select a blueprint</option>
|
||||
{blueprints.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{form.submitted && form.blueprint.length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Description from definition */}
|
||||
{(blueprintDef.description !== undefined || blueprintDef.desc !== undefined) && (
|
||||
<p className="mt-1.5 text-xs text-fg-muted">
|
||||
{String(blueprintDef.description ?? blueprintDef.desc)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Parameters schema */}
|
||||
{(() => {
|
||||
const paramsDef =
|
||||
blueprintDef.parameters ??
|
||||
blueprintDef.params ??
|
||||
blueprintDef["parameters"] ??
|
||||
blueprintDef["params"];
|
||||
if (paramsDef === undefined || paramsDef === null || typeof paramsDef !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entries = Object.entries(paramsDef as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-fg-muted">Parameters</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{entries.map(([name, schema]) => {
|
||||
const s = schema as Record<string, unknown> | null;
|
||||
const type = s?.type !== undefined ? String(s.type) : undefined;
|
||||
const defaultVal = s !== null && "default" in s ? s.default : undefined;
|
||||
const desc = s?.description !== undefined ? String(s.description) : undefined;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex flex-wrap items-baseline gap-x-2 text-xs"
|
||||
>
|
||||
<span className="font-mono font-medium text-fg">{name}</span>
|
||||
{type !== undefined && (
|
||||
<span className="rounded bg-surface-200 px-1 py-0.5 text-[10px] text-fg-subtle">
|
||||
{type}
|
||||
</span>
|
||||
)}
|
||||
{defaultVal !== undefined && (
|
||||
<span className="text-fg-subtle">
|
||||
default: <span className="font-mono">{JSON.stringify(defaultVal)}</span>
|
||||
</span>
|
||||
)}
|
||||
{desc !== undefined && <span className="text-fg-subtle">- {desc}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Raw JSON toggle */}
|
||||
{blueprintDetail !== null && (
|
||||
<div className="rounded-lg border border-border bg-surface-50 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefExpanded((p) => !p)}
|
||||
className="mt-2 flex items-center gap-1 text-[11px] text-fg-subtle hover:text-fg-muted"
|
||||
onClick={() => setForm({ ...form, definitionExpanded: !form.definitionExpanded })}
|
||||
className="flex w-full items-center gap-1.5 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{defExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
Raw definition
|
||||
{form.definitionExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<Info className="h-3.5 w-3.5 text-brand-400" />
|
||||
Blueprint Details
|
||||
</button>
|
||||
{defExpanded && (
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded border border-border bg-surface-100 p-2 font-mono text-[11px] text-fg-subtle">
|
||||
{JSON.stringify(blueprintDef, null, 2)}
|
||||
{form.definitionExpanded && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-surface-100 p-2 font-mono text-[10px] text-fg-muted">
|
||||
{encodeJsonUnknownString(blueprintDetail)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-description" className="block text-sm font-medium text-fg-muted">
|
||||
Description <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="flow-description"
|
||||
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"
|
||||
/>
|
||||
{submitted && description.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Description is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parameters (JSON) */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="flow-params" className="block text-sm font-medium text-fg-muted">
|
||||
Parameters (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
id="flow-params"
|
||||
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 !== null
|
||||
? "border-error focus:border-error focus:ring-error"
|
||||
: "border-border focus:border-brand-500 focus:ring-brand-500",
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Description</span>
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
||||
placeholder="What this flow does"
|
||||
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"
|
||||
/>
|
||||
{form.submitted && form.description.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Description is required</p>
|
||||
)}
|
||||
/>
|
||||
{paramsError !== null && (
|
||||
<p className="text-xs text-error">{paramsError}</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Parameters JSON</span>
|
||||
<textarea
|
||||
value={form.paramsJson}
|
||||
onChange={(event) => setForm({ ...form, paramsJson: event.target.value, paramsError: null })}
|
||||
rows={6}
|
||||
className="w-full resize-none rounded-lg border border-border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.paramsError !== null && <p className="mt-1 text-xs text-red-400">{form.paramsError}</p>}
|
||||
</label>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop flow confirm dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StopFlowDialog({
|
||||
open,
|
||||
flowId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
flowId: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
const handleStop = async () => {
|
||||
setStopping(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!stopping) onClose();
|
||||
}}
|
||||
title="Stop Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={stopping}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-2 rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{stopping && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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 ?? "").length > 0 ? 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"
|
||||
aria-label={`Stop flow ${flow.id}`}
|
||||
>
|
||||
<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 === null || stopTarget.length === 0) 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);
|
||||
};
|
||||
const flowsResult = useAtomValue(flowsAtom);
|
||||
const refreshFlows = useAtomRefresh(flowsAtom);
|
||||
const [expanded, setExpanded] = useAtom(flowExpandedAtom);
|
||||
const setStartOpen = useAtomSet(flowsStartDialogOpenAtom);
|
||||
const stopFlow = useAtomSet(stopFlowAtom);
|
||||
const actionInProgress = useAtomValue(activeActionAtom);
|
||||
const flows = resultData(flowsResult, []);
|
||||
const loading = resultLoading(flowsResult, flows);
|
||||
const error = resultError(flowsResult);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<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-muted">
|
||||
{flows.length} active
|
||||
</span>
|
||||
{!loading && <Badge>{flows.length} flow{flows.length !== 1 ? "s" : ""}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => getFlows()}
|
||||
onClick={refreshFlows}
|
||||
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"
|
||||
>
|
||||
|
|
@ -587,16 +209,15 @@ export default function FlowsPage() {
|
|||
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"
|
||||
onClick={() => setStartOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
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" />
|
||||
|
|
@ -605,7 +226,7 @@ export default function FlowsPage() {
|
|||
)}
|
||||
|
||||
{error !== null && (
|
||||
<p role="alert" className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -613,50 +234,51 @@ export default function FlowsPage() {
|
|||
{!loading && error === null && 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>
|
||||
<p className="text-fg-subtle">No flows are running.</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 className="space-y-3">
|
||||
{flows.map((flow) => {
|
||||
const isExpanded = expanded[flow.id] === true;
|
||||
return (
|
||||
<div key={flow.id} className="rounded-lg border border-border bg-surface-50">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<button
|
||||
onClick={() => setExpanded({ ...expanded, [flow.id]: !isExpanded })}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4 text-fg-subtle" /> : <ChevronRight className="h-4 w-4 text-fg-subtle" />}
|
||||
<span className="truncate font-mono text-sm font-medium text-fg">{flow.id}</span>
|
||||
{flow.description !== undefined && (
|
||||
<span className="truncate text-xs text-fg-muted">{flow.description}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => stopFlow(flow.id)}
|
||||
disabled={actionInProgress === flow.id}
|
||||
aria-label={`Stop flow ${flow.id}`}
|
||||
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-error hover:bg-error/10 disabled:opacity-40"
|
||||
>
|
||||
{actionInProgress === flow.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-surface-100 p-3 font-mono text-xs text-fg-muted">
|
||||
{encodeJsonUnknownString(flow)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<StartFlowDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
|
||||
<StopFlowDialog
|
||||
open={stopTarget !== null}
|
||||
flowId={stopTarget ?? ""}
|
||||
onClose={() => setStopTarget(null)}
|
||||
onConfirm={handleStop}
|
||||
/>
|
||||
<StartFlowDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,37 @@
|
|||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Rotate3d,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
Loader2,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
} 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";
|
||||
import {
|
||||
termValue,
|
||||
flowIdAtom,
|
||||
graphTriplesAtom,
|
||||
graphViewAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
settingsAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import type { Triple } from "@trustgraph/client";
|
||||
import {
|
||||
localName,
|
||||
hashColor,
|
||||
triplesToGraph,
|
||||
RDFS_LABEL,
|
||||
RDF_TYPE,
|
||||
termValue,
|
||||
type GraphNode,
|
||||
type GraphLink,
|
||||
} from "@/lib/graph-utils";
|
||||
import type { ForceGraphProps } from "react-force-graph-2d";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D to keep bundle size down
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type {
|
||||
ForceGraphMethods,
|
||||
ForceGraphProps,
|
||||
} from "react-force-graph-2d";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
|
||||
|
||||
// Graph helpers imported from @/lib/graph-utils
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<GraphNode, GraphLink>>;
|
||||
|
||||
function NodeDetailPanel({
|
||||
nodeId,
|
||||
|
|
@ -68,672 +46,243 @@ function NodeDetailPanel({
|
|||
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 }[] = [];
|
||||
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),
|
||||
});
|
||||
}
|
||||
for (const triple of triples) {
|
||||
const subject = termValue(triple.s);
|
||||
const predicate = termValue(triple.p);
|
||||
const object = termValue(triple.o);
|
||||
if (predicate === RDFS_LABEL || predicate === RDF_TYPE) continue;
|
||||
if (subject === nodeId) {
|
||||
outbound.push({
|
||||
predicate: labelMap.get(predicate) ?? localName(predicate),
|
||||
object,
|
||||
objectLabel: labelMap.get(object) ?? localName(object),
|
||||
});
|
||||
}
|
||||
return { outbound, inbound };
|
||||
}, [nodeId, triples, labelMap]);
|
||||
if (object === nodeId) {
|
||||
inbound.push({
|
||||
predicate: labelMap.get(predicate) ?? localName(predicate),
|
||||
subject,
|
||||
subjectLabel: labelMap.get(subject) ?? localName(subject),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
aria-label="Close detail panel"
|
||||
>
|
||||
<aside className="absolute right-4 top-4 z-20 max-h-[calc(100%-2rem)] w-96 overflow-y-auto rounded-lg border border-border bg-surface-50 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-fg">{label}</h2>
|
||||
<p className="break-all font-mono text-[10px] text-fg-subtle">{nodeId}</p>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Close node details" 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 className="space-y-4 p-4">
|
||||
{outbound.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Outgoing</h3>
|
||||
<div className="space-y-2">
|
||||
{outbound.map((edge, index) => (
|
||||
<div key={`${edge.object}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
|
||||
<p className="text-fg-muted">{edge.predicate}</p>
|
||||
<p className="mt-0.5 break-all font-mono text-fg">{edge.objectLabel}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{inbound.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Incoming</h3>
|
||||
<div className="space-y-2">
|
||||
{inbound.map((edge, index) => (
|
||||
<div key={`${edge.subject}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
|
||||
<p className="text-fg-muted">{edge.predicate}</p>
|
||||
<p className="mt-0.5 break-all font-mono text-fg">{edge.subjectLabel}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{related.outbound.length === 0 && related.inbound.length === 0 && (
|
||||
<p className="text-xs text-fg-subtle">No relationships found.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph page
|
||||
// ---------------------------------------------------------------------------
|
||||
function paintNode(showLabels: boolean) {
|
||||
return (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
if (!showLabels || globalScale < 0.7) return;
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const light = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = light ? "rgba(24,24,27,0.85)" : "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
};
|
||||
}
|
||||
|
||||
function paintLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
if (globalScale < 1.5) return;
|
||||
const source = link.source as unknown as GraphNode;
|
||||
const target = link.target as unknown as GraphNode;
|
||||
if (source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return;
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
const fontSize = Math.max(8 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.65)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Query filters
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [subjectFilter, setSubjectFilter] = useState("");
|
||||
const [predicateFilter, setPredicateFilter] = useState("");
|
||||
const [objectFilter, setObjectFilter] = useState("");
|
||||
const [tripleLimit, setTripleLimit] = useState(2000);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const hasActiveFilters =
|
||||
subjectFilter.length > 0 ||
|
||||
predicateFilter.length > 0 ||
|
||||
objectFilter.length > 0;
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [containerSize, setContainerSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
// Auto-fit tracking — declared early so fetchTriples can reset it
|
||||
const hasAutoFit = useRef(false);
|
||||
|
||||
// Ref callback — attaches ResizeObserver when the container mounts
|
||||
const containerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
// Disconnect previous observer
|
||||
if (roRef.current !== null) {
|
||||
roRef.current.disconnect();
|
||||
roRef.current = null;
|
||||
}
|
||||
if (el === null) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry !== undefined) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setContainerSize({ width: Math.floor(width), height: Math.floor(height) });
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
// Fetch triples with optional filters
|
||||
const fetchTriples = useCallback(async () => {
|
||||
const act = "Load graph";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
hasAutoFit.current = false;
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
const s: Term | undefined = subjectFilter.length > 0 ? { t: "i", i: subjectFilter } : undefined;
|
||||
const p: Term | undefined = predicateFilter.length > 0 ? { t: "i", i: predicateFilter } : undefined;
|
||||
const o: Term | undefined = objectFilter.length > 0 ? { t: "i", i: objectFilter } : undefined;
|
||||
|
||||
const result = await flow.triplesQuery(
|
||||
s,
|
||||
p,
|
||||
o,
|
||||
tripleLimit,
|
||||
collection,
|
||||
);
|
||||
setTriples(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, flowId, collection, subjectFilter, predicateFilter, objectFilter, tripleLimit, addActivity, removeActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriples();
|
||||
}, [fetchTriples]);
|
||||
|
||||
// Build graph
|
||||
const { data: graphData, labelMap, typeMap } = useMemo(
|
||||
() => triplesToGraph(Array.isArray(triples) ? triples : []),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Unique types for legend
|
||||
const uniqueTypes = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const [, typeUri] of typeMap) {
|
||||
const name = localName(typeUri);
|
||||
if (!seen.has(name)) {
|
||||
seen.set(name, typeUri);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries());
|
||||
}, [typeMap]);
|
||||
|
||||
// Search filter -- highlight matching nodes
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchingIds = useMemo(() => {
|
||||
if (searchLower.length === 0) 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 !== null
|
||||
? labelMap.get(selectedNode) ?? localName(selectedNode)
|
||||
: "";
|
||||
|
||||
// Auto-fit graph to view once data loads
|
||||
useEffect(() => {
|
||||
if (
|
||||
graphData.nodes.length > 0 &&
|
||||
fgRef.current !== undefined &&
|
||||
hasAutoFit.current === false
|
||||
) {
|
||||
hasAutoFit.current = true;
|
||||
// Wait for force simulation to settle briefly before fitting
|
||||
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 40), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [graphData.nodes.length]);
|
||||
|
||||
// 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 — with glow effect
|
||||
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;
|
||||
|
||||
const baseColor = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isSelected
|
||||
? "#fbbf24"
|
||||
: isMatch
|
||||
? "#22c55e"
|
||||
: node.color ?? "#5b80ff";
|
||||
|
||||
// Outer glow (only when not dimmed)
|
||||
if (!dim) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = baseColor;
|
||||
ctx.shadowBlur = isSelected ? 16 : 8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Node circle (crisp, on top of glow)
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fill();
|
||||
|
||||
// Inner highlight (subtle white dot for depth)
|
||||
if (!dim && radius > 3) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x - radius * 0.25, y - radius * 0.25, radius * 0.3, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.2)";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (isSelected || isMatch) {
|
||||
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
|
||||
ctx.lineWidth = 1.5 / globalScale;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Label
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `600 ${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isLight
|
||||
? "rgba(24,24,27,0.9)"
|
||||
: "rgba(250,250,250,0.9)";
|
||||
ctx.fillText(node.label, x, y + radius + 2);
|
||||
},
|
||||
[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 === undefined ||
|
||||
src.y === undefined ||
|
||||
tgt.x === undefined ||
|
||||
tgt.y === undefined
|
||||
) {
|
||||
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);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
const [view, setView] = useAtom(graphViewAtom);
|
||||
const triplesResult = useAtomValue(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
|
||||
const refresh = useAtomRefresh(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
|
||||
const triples = resultData(triplesResult, []);
|
||||
const loading = resultLoading(triplesResult, triples);
|
||||
const error = resultError(triplesResult);
|
||||
const { data, labelMap, typeMap } = triplesToGraph(triples);
|
||||
const search = view.searchTerm.trim().toLowerCase();
|
||||
const graphData = search.length === 0
|
||||
? data
|
||||
: (() => {
|
||||
const nodes = data.nodes.filter((node) => node.label.toLowerCase().includes(search) || node.id.toLowerCase().includes(search));
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
return {
|
||||
nodes,
|
||||
links: data.links.filter((link) => {
|
||||
const source = typeof link.source === "string" ? link.source : (link.source as GraphNode).id;
|
||||
const target = typeof link.target === "string" ? link.target : (link.target as GraphNode).id;
|
||||
return nodeIds.has(source) && nodeIds.has(target);
|
||||
}),
|
||||
};
|
||||
})();
|
||||
const selectedNode = view.selectedNodeId !== null
|
||||
? data.nodes.find((node) => node.id === view.selectedNodeId)
|
||||
: undefined;
|
||||
const uniqueTypes = Array.from(new Set(Array.from(typeMap.values()).map(localName))).sort();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<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-muted">
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
<Badge>{graphData.nodes.length} nodes</Badge>
|
||||
<Badge>{graphData.links.length} edges</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap 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" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-surface-100 px-3 py-2">
|
||||
<Search className="h-4 w-4 text-fg-subtle" />
|
||||
<input
|
||||
id="graph-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search nodes..."
|
||||
aria-label="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"
|
||||
value={view.searchTerm}
|
||||
onChange={(event) => setView({ ...view, searchTerm: event.target.value })}
|
||||
placeholder="Search graph..."
|
||||
className="w-48 bg-transparent text-sm text-fg placeholder:text-fg-subtle focus:outline-none"
|
||||
/>
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<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"
|
||||
aria-label="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"
|
||||
aria-label="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"
|
||||
aria-label="Fit to view"
|
||||
>
|
||||
<Maximize className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showFilters || hasActiveFilters
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Query filters"
|
||||
aria-label="Toggle query filters"
|
||||
aria-expanded={showFilters}
|
||||
<select
|
||||
value={view.nodeLimit}
|
||||
onChange={(event) => setView({ ...view, nodeLimit: Number(event.target.value) })}
|
||||
aria-label="Node limit"
|
||||
className="rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none"
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Filters
|
||||
{hasActiveFilters && !showFilters && (
|
||||
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-brand-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Legend toggle */}
|
||||
{uniqueTypes.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLegend((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showLegend
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Type legend"
|
||||
aria-label="Toggle type legend"
|
||||
aria-expanded={showLegend}
|
||||
>
|
||||
Legend
|
||||
</button>
|
||||
)}
|
||||
|
||||
{[100, 250, 500, 1000].map((limit) => <option key={limit} value={limit}>{limit}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
onClick={refresh}
|
||||
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"
|
||||
aria-label="Refresh graph"
|
||||
className="rounded-lg border border-border px-3 py-2 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
|
||||
<RefreshCwIcon loading={loading} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{showFilters && (
|
||||
<div className="mb-4 rounded-lg border border-border bg-surface-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-xs font-medium text-fg-muted">
|
||||
<Filter className="h-3 w-3" />
|
||||
Query Filters
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubjectFilter("");
|
||||
setPredicateFilter("");
|
||||
setObjectFilter("");
|
||||
}}
|
||||
className="text-xs text-brand-400 hover:text-brand-300"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-subject" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="filter-subject"
|
||||
type="text"
|
||||
value={subjectFilter}
|
||||
onChange={(e) => setSubjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-predicate" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Predicate
|
||||
</label>
|
||||
<input
|
||||
id="filter-predicate"
|
||||
type="text"
|
||||
value={predicateFilter}
|
||||
onChange={(e) => setPredicateFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-object" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Object
|
||||
</label>
|
||||
<input
|
||||
id="filter-object"
|
||||
type="text"
|
||||
value={objectFilter}
|
||||
onChange={(e) => setObjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="filter-limit" className="text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Limit
|
||||
</label>
|
||||
<input
|
||||
id="filter-limit"
|
||||
type="range"
|
||||
min={100}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={tripleLimit}
|
||||
onChange={(e) => setTripleLimit(Number(e.target.value))}
|
||||
className="w-24 accent-brand-500"
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">{tripleLimit}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
disabled={loading}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setView({ ...view, showLabels: !view.showLabels })}
|
||||
className={cn("flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs", view.showLabels ? "bg-brand-600/10 text-brand-400" : "text-fg-muted")}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Labels
|
||||
</button>
|
||||
{uniqueTypes.slice(0, 8).map((type) => <Badge key={type} variant="info">{type}</Badge>)}
|
||||
</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 className="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-surface-50">
|
||||
{loading && triples.length === 0 && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-surface-50">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading graph...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<div className="relative flex flex-1 overflow-hidden rounded-lg border border-border">
|
||||
{/* Graph canvas */}
|
||||
<div ref={containerRef} className="relative min-w-0 flex-1 bg-surface-0">
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center"><Loader2 className="h-5 w-5 animate-spin text-fg-subtle" /></div>}>
|
||||
{!loading && graphData.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<Rotate3d className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No graph triples available.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-fg-subtle">Loading graph renderer...</div>}>
|
||||
<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();
|
||||
}}
|
||||
width={900}
|
||||
height={650}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
nodeCanvasObject={paintNode(view.showLabels)}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
linkColor={() => "rgba(91,128,255,0.18)"}
|
||||
linkWidth={1.5}
|
||||
linkDirectionalArrowLength={5}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
linkDirectionalArrowColor={() => "rgba(91,128,255,0.5)"}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleWidth={2}
|
||||
linkDirectionalParticleSpeed={0.004}
|
||||
linkDirectionalParticleColor={() => "rgba(91,128,255,0.6)"}
|
||||
linkCurvature={0.1}
|
||||
onNodeClick={(node: GraphNode) => {
|
||||
setSelectedNode((prev) =>
|
||||
prev === node.id ? null : node.id,
|
||||
);
|
||||
linkColor={() => "rgba(120,120,140,0.32)"}
|
||||
nodePointerAreaPaint={(node, color, ctx) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, Math.max(6, Math.sqrt(node.degree + 1) * 3), 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}}
|
||||
onBackgroundClick={() => setSelectedNode(null)}
|
||||
backgroundColor="transparent"
|
||||
cooldownTicks={100}
|
||||
warmupTicks={30}
|
||||
{...(containerSize !== null
|
||||
? { width: containerSize.width, height: containerSize.height }
|
||||
: {})}
|
||||
onNodeClick={(node) => setView({ ...view, selectedNodeId: node.id, selectedNodeLabel: node.label })}
|
||||
/>
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Search results badge overlay */}
|
||||
{searchTerm.length > 0 && matchingIds.size > 0 && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<Badge variant="success">
|
||||
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type legend overlay */}
|
||||
{showLegend && uniqueTypes.length > 0 && (
|
||||
<div className="absolute bottom-3 left-3 z-10 max-h-48 overflow-y-auto rounded-lg border border-border bg-surface-50/95 px-3 py-2 shadow-lg backdrop-blur-sm">
|
||||
<h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Node Types
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{uniqueTypes.map(([name]) => (
|
||||
<div key={name} className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hashColor(name) }}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail panel -- positioned absolutely so it overlays the graph */}
|
||||
{selectedNode !== null && (
|
||||
<div className="absolute inset-y-0 right-0 z-10">
|
||||
<NodeDetailPanel
|
||||
nodeId={selectedNode}
|
||||
label={selectedLabel}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedNode !== undefined && view.selectedNodeId !== null && (
|
||||
<NodeDetailPanel
|
||||
nodeId={view.selectedNodeId}
|
||||
label={view.selectedNodeLabel ?? selectedNode.label}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setView({ ...view, selectedNodeId: null, selectedNodeLabel: null })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshCwIcon({ loading }: { loading: boolean }) {
|
||||
return loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
BrainCircuit,
|
||||
Loader2,
|
||||
|
|
@ -8,30 +8,30 @@ import {
|
|||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import {
|
||||
activeActionAtom,
|
||||
deleteKgCoreAtom,
|
||||
kgCoresAtom,
|
||||
knowledgeDeleteTargetAtom,
|
||||
loadKgCoreAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
} from "@/atoms/workbench";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DeleteCoreDialog({
|
||||
open,
|
||||
coreId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
coreId: string;
|
||||
coreId: string | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
open={coreId !== null}
|
||||
onClose={onClose}
|
||||
title="Delete Knowledge Core"
|
||||
footer={
|
||||
|
|
@ -55,7 +55,7 @@ function DeleteCoreDialog({
|
|||
<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 knowledge core{" "}
|
||||
<span className="font-mono font-medium text-fg">{coreId}</span>?
|
||||
<span className="font-mono font-medium text-fg">{coreId ?? ""}</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -63,93 +63,20 @@ function DeleteCoreDialog({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge Cores page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function KnowledgeCoresPage() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const notify = useNotification();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const result = useAtomValue(kgCoresAtom);
|
||||
const refresh = useAtomRefresh(kgCoresAtom);
|
||||
const loadCore = useAtomSet(loadKgCoreAtom);
|
||||
const deleteCore = useAtomSet(deleteKgCoreAtom);
|
||||
const [deleteTarget, setDeleteTarget] = useAtom(knowledgeDeleteTargetAtom);
|
||||
const actionInProgress = useAtomValue(activeActionAtom);
|
||||
|
||||
const [cores, setCores] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const loadCores = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Request timed out")), 15000),
|
||||
);
|
||||
const ids = await Promise.race([
|
||||
socket.knowledge().getKnowledgeCores(),
|
||||
timeoutPromise,
|
||||
]);
|
||||
setCores(Array.isArray(ids) ? ids : []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load knowledge cores:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadCores();
|
||||
}
|
||||
}, [connectionState.status, loadCores]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (id: string) => {
|
||||
setActionInProgress(id);
|
||||
try {
|
||||
await socket.knowledge().loadKgCore(id, flowId);
|
||||
notify.success("Core loaded", `Knowledge core "${id}" has been loaded.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to load core",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
}
|
||||
},
|
||||
[socket, flowId, notify],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (deleteTarget === null || deleteTarget.length === 0) return;
|
||||
setActionInProgress(deleteTarget);
|
||||
try {
|
||||
await socket.knowledge().deleteKgCore(deleteTarget);
|
||||
notify.success("Core deleted", `Knowledge core "${deleteTarget}" has been deleted.`);
|
||||
await loadCores();
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to delete core",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [socket, deleteTarget, notify, loadCores]);
|
||||
const cores = resultData(result, []);
|
||||
const loading = resultLoading(result, cores);
|
||||
const error = resultError(result);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrainCircuit className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -162,7 +89,7 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadCores}
|
||||
onClick={refresh}
|
||||
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"
|
||||
>
|
||||
|
|
@ -171,7 +98,6 @@ export default function KnowledgeCoresPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && cores.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -179,7 +105,7 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
@ -210,7 +136,7 @@ export default function KnowledgeCoresPage() {
|
|||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleLoad(id)}
|
||||
onClick={() => loadCore(id)}
|
||||
disabled={actionInProgress === id}
|
||||
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-brand-400 hover:bg-brand-600/10 disabled:opacity-40"
|
||||
title="Load core"
|
||||
|
|
@ -242,12 +168,13 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<DeleteCoreDialog
|
||||
open={deleteTarget != null}
|
||||
coreId={deleteTarget ?? ""}
|
||||
coreId={deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget !== null) deleteCore(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
MessageCircleCode,
|
||||
Loader2,
|
||||
|
|
@ -9,47 +9,40 @@ import {
|
|||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePrompts } from "@/hooks/use-prompts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompts page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = "templates" | "system";
|
||||
import {
|
||||
promptActiveTabAtom,
|
||||
promptDetailAtom,
|
||||
promptsAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
selectedPromptIdAtom,
|
||||
systemPromptAtom,
|
||||
} from "@/atoms/workbench";
|
||||
|
||||
export default function PromptsPage() {
|
||||
const { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt } = usePrompts();
|
||||
const promptsResult = useAtomValue(promptsAtom);
|
||||
const systemPromptResult = useAtomValue(systemPromptAtom);
|
||||
const refreshPrompts = useAtomRefresh(promptsAtom);
|
||||
const refreshSystemPrompt = useAtomRefresh(systemPromptAtom);
|
||||
const [activeTab, setActiveTab] = useAtom(promptActiveTabAtom);
|
||||
const [selectedPromptId, setSelectedPromptId] = useAtom(selectedPromptIdAtom);
|
||||
const promptDetailResult = useAtomValue(promptDetailAtom(selectedPromptId ?? ""));
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("templates");
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
|
||||
const [promptDetail, setPromptDetail] = useState<{ system?: string; prompt?: string } | string | null>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const prompts = resultData(promptsResult, []);
|
||||
const systemPrompt = resultData(systemPromptResult, "");
|
||||
const loading = resultLoading(promptsResult, prompts) || resultLoading(systemPromptResult, systemPrompt);
|
||||
const error = resultError(promptsResult) ?? resultError(systemPromptResult);
|
||||
const promptDetail = resultData(promptDetailResult, null) as { system?: string; prompt?: string } | string | null;
|
||||
const loadingDetail = selectedPromptId !== null && resultLoading(promptDetailResult, promptDetail);
|
||||
|
||||
const handleSelectPrompt = useCallback(
|
||||
async (id: string) => {
|
||||
setSelectedPromptId(id);
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const detail = await getPrompt(id);
|
||||
setPromptDetail(detail as typeof promptDetail);
|
||||
} catch (err) {
|
||||
console.error("Failed to load prompt detail:", err);
|
||||
setPromptDetail("Error loading prompt.");
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
},
|
||||
[getPrompt],
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
loadPrompts();
|
||||
loadSystemPrompt();
|
||||
}, [loadPrompts, loadSystemPrompt]);
|
||||
const refresh = () => {
|
||||
refreshPrompts();
|
||||
refreshSystemPrompt();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageCircleCode className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -57,7 +50,7 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
onClick={refresh}
|
||||
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"
|
||||
>
|
||||
|
|
@ -66,7 +59,6 @@ export default function PromptsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div role="tablist" aria-label="Prompt sections" className="mb-4 flex gap-1 rounded-lg bg-surface-100 p-1">
|
||||
<button
|
||||
id="tab-templates"
|
||||
|
|
@ -75,9 +67,7 @@ export default function PromptsPage() {
|
|||
onClick={() => setActiveTab("templates")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "templates"
|
||||
? "bg-surface-50 text-fg shadow-sm"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
activeTab === "templates" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
|
|
@ -90,9 +80,7 @@ export default function PromptsPage() {
|
|||
onClick={() => setActiveTab("system")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "system"
|
||||
? "bg-surface-50 text-fg shadow-sm"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
activeTab === "system" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
|
|
@ -100,14 +88,12 @@ export default function PromptsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Templates tab */}
|
||||
{activeTab === "templates" && (
|
||||
<div id="panel-templates" role="tabpanel" aria-labelledby="tab-templates" tabIndex={0} className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
{loading && prompts.length === 0 && (
|
||||
|
|
@ -125,21 +111,20 @@ export default function PromptsPage() {
|
|||
)}
|
||||
|
||||
{prompts.length > 0 && (
|
||||
<div className="flex flex-1 gap-4 overflow-hidden">
|
||||
{/* Prompt list */}
|
||||
<div className="w-80 shrink-0 overflow-y-auto rounded-lg border border-border">
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden lg:flex-row">
|
||||
<div className="max-h-56 w-full shrink-0 overflow-y-auto rounded-lg border border-border lg:max-h-none lg:w-80">
|
||||
<div className="border-b border-border bg-surface-100 px-4 py-3">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
|
||||
Templates ({prompts.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{prompts.map((p) => {
|
||||
const id = p.id ?? (p as Record<string, unknown>).name ?? String(p);
|
||||
{prompts.map((prompt) => {
|
||||
const id = prompt.id ?? (prompt as Record<string, unknown>).name ?? String(prompt);
|
||||
return (
|
||||
<button
|
||||
key={String(id)}
|
||||
onClick={() => handleSelectPrompt(String(id))}
|
||||
onClick={() => setSelectedPromptId(String(id))}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition-colors",
|
||||
selectedPromptId === String(id)
|
||||
|
|
@ -155,8 +140,7 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt detail */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
<div className="min-h-0 flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
{selectedPromptId !== null && selectedPromptId.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-border bg-surface-100 px-4 py-3">
|
||||
|
|
@ -164,10 +148,8 @@ export default function PromptsPage() {
|
|||
<span className="font-mono">{selectedPromptId}</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPromptId(null);
|
||||
setPromptDetail("");
|
||||
}}
|
||||
onClick={() => setSelectedPromptId(null)}
|
||||
aria-label="Close prompt detail"
|
||||
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -220,7 +202,6 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* System Prompt tab */}
|
||||
{activeTab === "system" && (
|
||||
<div id="panel-system" role="tabpanel" aria-labelledby="tab-system" tabIndex={0} className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
<div className="border-b border-border bg-surface-100 px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Wifi,
|
||||
|
|
@ -9,42 +9,44 @@ import {
|
|||
Database,
|
||||
Workflow,
|
||||
Info,
|
||||
Loader2,
|
||||
Moon,
|
||||
Sun,
|
||||
Plus,
|
||||
Trash2,
|
||||
} 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 type * as React from "react";
|
||||
import {
|
||||
collectionFormAtom,
|
||||
collectionsAtom,
|
||||
connectionStateAtom,
|
||||
createCollectionAtom,
|
||||
createCollectionDialogOpenAtom,
|
||||
deleteCollectionAtom,
|
||||
deleteCollectionDialogOpenAtom,
|
||||
flowIdAtom,
|
||||
flowsAtom,
|
||||
resultData,
|
||||
settingsAtom,
|
||||
settingsShowApiKeyAtom,
|
||||
setSettingsFieldAtom,
|
||||
themeAtom,
|
||||
toggleThemeAtom,
|
||||
updateFeatureSwitchesAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACRONYMS: Record<string, string> = { mcp: "MCP", llm: "LLM", api: "API" };
|
||||
|
||||
/** Convert camelCase key to display label, preserving known acronyms. */
|
||||
function featureLabel(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map((w) => ACRONYMS[w.toLowerCase()] ?? w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.map((word) => ACRONYMS[word.toLowerCase()] ?? word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon,
|
||||
|
|
@ -65,181 +67,27 @@ function Section({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { settings, updateSetting, updateFeatureSwitches } = useSettings();
|
||||
const connectionState = useConnectionState();
|
||||
const socket = useSocket();
|
||||
const { flows } = useFlows();
|
||||
const notify = useNotification();
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const setField = useAtomSet(setSettingsFieldAtom);
|
||||
const updateFeatureSwitches = useAtomSet(updateFeatureSwitchesAtom);
|
||||
const connectionState = useAtomValue(connectionStateAtom);
|
||||
const flows = resultData(useAtomValue(flowsAtom), []);
|
||||
const [flowId, setFlowId] = useAtom(flowIdAtom);
|
||||
const [showApiKey, setShowApiKey] = useAtom(settingsShowApiKeyAtom);
|
||||
const [theme] = useAtom(themeAtom);
|
||||
const toggleTheme = useAtomSet(toggleThemeAtom);
|
||||
const collections = resultData(useAtomValue(collectionsAtom), []);
|
||||
const [createOpen, setCreateOpen] = useAtom(createCollectionDialogOpenAtom);
|
||||
const [deleteOpen, setDeleteOpen] = useAtom(deleteCollectionDialogOpenAtom);
|
||||
const [collectionForm, setCollectionForm] = useAtom(collectionFormAtom);
|
||||
const createCollection = useAtomSet(createCollectionAtom);
|
||||
const deleteCollection = useAtomSet(deleteCollectionAtom);
|
||||
|
||||
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);
|
||||
|
||||
// Create-collection dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newId, setNewId] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDescription, setNewDescription] = useState("");
|
||||
const [newTags, setNewTags] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Delete-collection confirmation dialog state
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Dark mode toggle -- uses a class on <html>/<body> and persists to localStorage
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
const saved = localStorage.getItem("tg-theme");
|
||||
if (saved !== null) return saved === "dark";
|
||||
return !document.documentElement.classList.contains("light");
|
||||
});
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = !isDark;
|
||||
setIsDark(next);
|
||||
if (next) {
|
||||
document.documentElement.classList.remove("light");
|
||||
document.body.classList.remove("light");
|
||||
document.body.classList.add("dark");
|
||||
localStorage.setItem("tg-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.add("light");
|
||||
document.body.classList.add("light");
|
||||
document.body.classList.remove("dark");
|
||||
localStorage.setItem("tg-theme", "light");
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
// Reusable function to fetch collections from the backend
|
||||
const refreshCollections = useCallback(() => {
|
||||
setLoadingCollections(true);
|
||||
return socket
|
||||
.collectionManagement()
|
||||
.listCollections()
|
||||
.then((cols) => {
|
||||
const list = Array.isArray(cols)
|
||||
? (cols as Array<{ id?: string; collection?: string; name?: string; [key: string]: unknown }>)
|
||||
: [];
|
||||
// Ensure "default" collection is always present
|
||||
const hasDefault = list.some(
|
||||
(c) => (c.collection ?? c.id ?? c.name) === "default",
|
||||
);
|
||||
if (!hasDefault) {
|
||||
list.unshift({ id: "default", collection: "default", name: "default" });
|
||||
}
|
||||
setCollections(list);
|
||||
return list;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: at minimum show "default"
|
||||
setCollections([{ id: "default", collection: "default", name: "default" }]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingCollections(false);
|
||||
});
|
||||
}, [socket]);
|
||||
|
||||
// Fetch collections on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
refreshCollections().then(() => {
|
||||
if (cancelled) return;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshCollections]);
|
||||
|
||||
// Create a new collection
|
||||
const handleCreateCollection = useCallback(async () => {
|
||||
const trimmedId = newId.trim();
|
||||
if (trimmedId.length === 0) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const tags = newTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
|
||||
await socket
|
||||
.collectionManagement()
|
||||
.updateCollection(
|
||||
trimmedId,
|
||||
newName.trim().length > 0 ? newName.trim() : undefined,
|
||||
newDescription.trim().length > 0 ? newDescription.trim() : undefined,
|
||||
tags.length > 0 ? tags : undefined,
|
||||
);
|
||||
|
||||
await refreshCollections();
|
||||
updateSetting("collection", trimmedId);
|
||||
notify.success("Collection created", `"${newName.trim() || trimmedId}" is now active.`);
|
||||
|
||||
// Reset form and close
|
||||
setNewId("");
|
||||
setNewName("");
|
||||
setNewDescription("");
|
||||
setNewTags("");
|
||||
setCreateOpen(false);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to create collection",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [newId, newName, newDescription, newTags, socket, refreshCollections, updateSetting, notify]);
|
||||
|
||||
// Delete the current collection
|
||||
const handleDeleteCollection = useCallback(async () => {
|
||||
const currentId = settings.collection;
|
||||
if (currentId.length === 0) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await socket.collectionManagement().deleteCollection(currentId);
|
||||
await refreshCollections();
|
||||
|
||||
// Switch to the first remaining collection
|
||||
const remaining = collections.filter((c) => {
|
||||
const id = c.id ?? String(c.name ?? c);
|
||||
return id !== currentId;
|
||||
});
|
||||
if (remaining.length > 0) {
|
||||
const firstId = remaining[0].id ?? String(remaining[0].name ?? remaining[0]);
|
||||
updateSetting("collection", firstId);
|
||||
}
|
||||
|
||||
notify.success("Collection deleted", `"${currentId}" has been removed.`);
|
||||
setDeleteOpen(false);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to delete collection",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [settings.collection, socket, refreshCollections, collections, updateSetting, notify]);
|
||||
|
||||
// Connection status helpers
|
||||
const isConnected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
|
||||
const isWarning = connectionState.status === "unauthenticated";
|
||||
const statusBadge = isConnected ? (
|
||||
<Badge variant={isWarning ? "info" : "success"}>
|
||||
|
|
@ -253,418 +101,235 @@ export default function SettingsPage() {
|
|||
|
||||
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 pb-8 overflow-y-auto">
|
||||
{/* 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>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Section title="Connection" icon={<Wifi className="h-4 w-4 text-brand-400" />}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-100 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg">Gateway status</p>
|
||||
{connectionState.lastError !== undefined && (
|
||||
<p className="mt-0.5 text-xs text-error">{connectionState.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
{statusBadge}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-gateway-url" className="block text-sm font-medium text-fg-muted">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
id="settings-gateway-url"
|
||||
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 Beep Graph gateway.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-user-id" className="block text-sm font-medium text-fg-muted">
|
||||
User ID
|
||||
</label>
|
||||
<input
|
||||
id="settings-user-id"
|
||||
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 htmlFor="settings-api-key" className="block text-sm font-medium text-fg-muted">
|
||||
API Key
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center gap-1.5 text-sm font-medium text-fg-muted">
|
||||
<Key className="h-3.5 w-3.5" /> API Key
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="settings-api-key"
|
||||
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"
|
||||
onChange={(event) => setField({ key: "apiKey", value: event.target.value })}
|
||||
placeholder="Optional gateway bearer token"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 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)}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
className="absolute right-2.5 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" />
|
||||
)}
|
||||
{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>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Gateway URL</span>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.gatewayUrl}
|
||||
onChange={(event) => setField({ key: "gatewayUrl", value: event.target.value })}
|
||||
placeholder="/api/v1/rpc"
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">User</span>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.user}
|
||||
onChange={(event) => setField({ key: "user", value: event.target.value })}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
</Section>
|
||||
|
||||
{/* Collection */}
|
||||
<Section
|
||||
title="Collection"
|
||||
icon={<Database className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-collection" 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 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id="settings-collection"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="flex-1 rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
{collections.map((c) => {
|
||||
const cObj = c as { collection?: string; id?: string; name?: string };
|
||||
const collId = cObj.collection ?? cObj.id ?? String(cObj.name ?? c);
|
||||
const label = cObj.name ?? collId;
|
||||
return (
|
||||
<option key={collId} value={collId}>
|
||||
{label !== collId ? `${label} (${collId})` : collId}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="New collection"
|
||||
title="New collection"
|
||||
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{collections.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
aria-label="Delete collection"
|
||||
title="Delete collection"
|
||||
className="rounded-lg border border-red-500/30 bg-surface-100 p-2 text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="settings-collection"
|
||||
type="text"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="flex-1 rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="New collection"
|
||||
title="New collection"
|
||||
className="rounded-lg border border-border bg-surface-100 p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Create Collection Dialog */}
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="New Collection"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newId.trim().length === 0 || creating}
|
||||
onClick={handleCreateCollection}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{creating && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Create
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-id" className="block text-sm font-medium text-fg-muted">
|
||||
Collection ID <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="new-collection-id"
|
||||
type="text"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
placeholder="my-collection"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
A unique identifier for this collection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-name" className="block text-sm font-medium text-fg-muted">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
id="new-collection-name"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="My Collection"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-description" className="block text-sm font-medium text-fg-muted">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="new-collection-description"
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
placeholder="What this collection is for..."
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-collection-tags" className="block text-sm font-medium text-fg-muted">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
id="new-collection-tags"
|
||||
type="text"
|
||||
value={newTags}
|
||||
onChange={(e) => setNewTags(e.target.value)}
|
||||
placeholder="research, finance, internal"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Comma-separated list of tags for categorization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Collection Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
title="Delete Collection"
|
||||
className="max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={handleDeleteCollection}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to delete the collection{" "}
|
||||
<span className="font-semibold text-fg">"{settings.collection}"</span>?
|
||||
This will remove the collection and all its data. This action cannot be undone.
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
{/* Flow */}
|
||||
<Section
|
||||
title="Active Flow"
|
||||
icon={<Workflow className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-flow" className="block text-sm font-medium text-fg-muted">
|
||||
Flow
|
||||
</label>
|
||||
{flows.length > 0 ? (
|
||||
<Section title="Workspace" icon={<Database className="h-4 w-4 text-brand-400" />}>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Collection</span>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
id="settings-flow"
|
||||
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"
|
||||
value={settings.collection}
|
||||
onChange={(event) => setField({ key: "collection", value: event.target.value })}
|
||||
className="min-w-0 flex-1 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="default">default</option>
|
||||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.id}
|
||||
{f.description !== undefined && f.description.length > 0 ? ` -- ${f.description}` : ""}
|
||||
</option>
|
||||
))}
|
||||
{collections.map((collection) => {
|
||||
const id = String(collection.collection ?? collection.id ?? collection.name ?? "default");
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="settings-flow"
|
||||
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>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Currently using {isDark ? "dark" : "light"} mode.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="rounded-lg border border-border px-3 py-2 text-fg-muted hover:bg-surface-200 hover:text-fg"
|
||||
aria-label="Create collection"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="rounded-lg border border-border px-3 py-2 text-error hover:bg-error/10"
|
||||
aria-label="Delete collection"
|
||||
disabled={settings.collection === "default"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={isDark}
|
||||
aria-label="Dark mode"
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isDark ? "bg-brand-600" : "bg-fg-subtle",
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 flex items-center gap-1.5 text-sm font-medium text-fg-muted">
|
||||
<Workflow className="h-3.5 w-3.5" /> Flow
|
||||
</span>
|
||||
<select
|
||||
value={flowId}
|
||||
onChange={(event) => setFlowId(event.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"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isDark ? "translate-x-6" : "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<option value="default">default</option>
|
||||
{flows.map((flow) => (
|
||||
<option key={flow.id} value={flow.id}>
|
||||
{flow.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTheme(null)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg hover:bg-surface-200"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{theme === "dark" ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
Theme
|
||||
</span>
|
||||
<span className="capitalize text-fg-muted">{theme}</span>
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{/* Feature Switches */}
|
||||
<Section
|
||||
title="Feature Switches"
|
||||
icon={<SettingsIcon className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
{Object.entries(settings.featureSwitches).map(([key, enabled]) => {
|
||||
const isEnabled = enabled === true;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-fg">{featureLabel(key)}</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
aria-label={featureLabel(key)}
|
||||
onClick={() => updateFeatureSwitches({ [key]: !isEnabled })}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isEnabled ? "bg-brand-600" : "bg-fg-subtle",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isEnabled ? "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">Beep Graph</span>{" "}
|
||||
v0.1.0
|
||||
</p>
|
||||
<p>
|
||||
A web-based interface for interacting with the Beep Graph
|
||||
knowledge-graph system.
|
||||
</p>
|
||||
<Section title="Feature Switches" icon={<Info className="h-4 w-4 text-brand-400" />}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(settings.featureSwitches).map(([key, value]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-fg-muted">{featureLabel(key)}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(event) => updateFeatureSwitches({ [key]: event.target.checked })}
|
||||
className="h-4 w-4 accent-brand-500"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Create Collection"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (collectionForm.id.trim().length === 0) return;
|
||||
createCollection(collectionForm);
|
||||
setCollectionForm({ id: "", name: "", description: "", tags: "", submitting: false });
|
||||
setCreateOpen(false);
|
||||
}}
|
||||
disabled={collectionForm.id.trim().length === 0}
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
["id", "Collection ID", "research"] as const,
|
||||
["name", "Display Name", "Research"] as const,
|
||||
["description", "Description", "Optional description"] as const,
|
||||
["tags", "Tags", "comma, separated"] as const,
|
||||
].map(([key, label, placeholder]) => (
|
||||
<label key={key} className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">{label}</span>
|
||||
<input
|
||||
value={collectionForm[key]}
|
||||
onChange={(event) => setCollectionForm({ ...collectionForm, [key]: event.target.value })}
|
||||
placeholder={placeholder}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
title="Delete Collection"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteCollection(settings.collection);
|
||||
setDeleteOpen(false);
|
||||
}}
|
||||
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">
|
||||
<Trash2 className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Delete <span className="font-mono text-fg">{settings.collection}</span> and its data?
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,22 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import { Coins, Loader2, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { resultData, resultError, resultLoading, tokenCostsAtom } from "@/atoms/workbench";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenCost {
|
||||
model: string;
|
||||
input_price: number;
|
||||
output_price: number;
|
||||
function formatPrice(price: number) {
|
||||
if (!Number.isFinite(price)) return "--";
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token Cost page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function TokenCostPage() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const [costs, setCosts] = useState<TokenCost[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadCosts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await socket.config().getTokenCosts();
|
||||
setCosts(
|
||||
Array.isArray(data)
|
||||
? data.map((d: Record<string, unknown>) => ({
|
||||
model: String(d.model ?? ""),
|
||||
input_price: Number(d.input_price ?? 0),
|
||||
output_price: Number(d.output_price ?? 0),
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load token costs:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadCosts();
|
||||
}
|
||||
}, [connectionState.status, loadCosts]);
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
if (!Number.isFinite(price)) return "--";
|
||||
return `$${price.toFixed(2)}`;
|
||||
};
|
||||
const result = useAtomValue(tokenCostsAtom);
|
||||
const refresh = useAtomRefresh(tokenCostsAtom);
|
||||
const costs = resultData(result, []);
|
||||
const loading = resultLoading(result, costs);
|
||||
const error = resultError(result);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Coins className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -79,7 +29,7 @@ export default function TokenCostPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadCosts}
|
||||
onClick={refresh}
|
||||
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"
|
||||
>
|
||||
|
|
@ -88,7 +38,6 @@ export default function TokenCostPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && costs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -96,7 +45,7 @@ export default function TokenCostPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
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 !== undefined ? { 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),
|
||||
};
|
||||
});
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
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: "default",
|
||||
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 as boolean,
|
||||
|
||||
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 !== undefined) state.isLoaded = true;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue