2026-06-01 16:22:25 -05:00
|
|
|
import { Clipboard as BrowserClipboard } from "@effect/platform-browser";
|
|
|
|
|
import * as BrowserHttpClient from "@effect/platform-browser/BrowserHttpClient";
|
|
|
|
|
import * as BrowserKeyValueStore from "@effect/platform-browser/BrowserKeyValueStore";
|
2026-06-11 06:29:29 -05:00
|
|
|
import {
|
|
|
|
|
DispatchPayload,
|
|
|
|
|
GatewayWorkbenchHttpApi,
|
|
|
|
|
type GraphRagOptions,
|
|
|
|
|
makeBaseApi,
|
|
|
|
|
TrustGraphRpcs,
|
|
|
|
|
type BaseApi,
|
|
|
|
|
type BeginUploadResponse,
|
|
|
|
|
type ChunkedUploadDocumentMetadata,
|
|
|
|
|
type CompleteUploadResponse,
|
|
|
|
|
type ConnectionState,
|
|
|
|
|
type DocumentMetadata,
|
|
|
|
|
type ExplainEvent,
|
|
|
|
|
type StreamingMetadata,
|
|
|
|
|
type Term,
|
|
|
|
|
type Triple,
|
|
|
|
|
type UploadChunkResponse,
|
|
|
|
|
} from "@trustgraph/client";
|
|
|
|
|
import { Cause, Clock, Context, Effect, Layer, Match, Metric, Option, Random, Schema as S, Scope, Stream } from "effect";
|
2026-06-04 07:49:58 -05:00
|
|
|
import * as MutableHashMap from "effect/MutableHashMap";
|
2026-06-04 08:33:31 -05:00
|
|
|
import * as Predicate from "effect/Predicate";
|
2026-06-11 06:29:29 -05:00
|
|
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
|
2026-06-01 16:22:25 -05:00
|
|
|
import * as Otlp from "effect/unstable/observability/Otlp";
|
2026-06-11 06:29:29 -05:00
|
|
|
import * as RpcClient from "effect/unstable/rpc/RpcClient";
|
|
|
|
|
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
2026-06-01 16:22:25 -05:00
|
|
|
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
|
|
|
|
|
import * as Atom from "effect/unstable/reactivity/Atom";
|
2026-06-11 06:29:29 -05:00
|
|
|
import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
|
|
|
|
|
import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
|
|
|
|
|
import * as AtomRpc from "effect/unstable/reactivity/AtomRpc";
|
2026-06-01 16:22:25 -05:00
|
|
|
import * as Reactivity from "effect/unstable/reactivity/Reactivity";
|
2026-06-11 06:29:29 -05:00
|
|
|
import * as Socket from "effect/unstable/socket/Socket";
|
2026-06-01 16:22:25 -05:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Browser runtime, telemetry, and generic workbench HTTP API marker
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const serviceVersion = import.meta.env.VITE_APP_VERSION ?? "local";
|
|
|
|
|
|
|
|
|
|
const browserObservabilityLayer = Otlp.layerJson({
|
|
|
|
|
baseUrl: "/otel",
|
|
|
|
|
resource: {
|
|
|
|
|
serviceName: "trustgraph-workbench",
|
|
|
|
|
serviceVersion,
|
|
|
|
|
attributes: {
|
|
|
|
|
"trustgraph.component": "workbench",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
loggerExportInterval: "2 seconds",
|
|
|
|
|
metricsExportInterval: "5 seconds",
|
|
|
|
|
tracerExportInterval: "2 seconds",
|
|
|
|
|
shutdownTimeout: "1 second",
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-01 23:19:54 -05:00
|
|
|
class WorkbenchPromiseError extends S.TaggedErrorClass<WorkbenchPromiseError>()(
|
|
|
|
|
"WorkbenchPromiseError",
|
|
|
|
|
{
|
|
|
|
|
cause: S.Unknown,
|
|
|
|
|
message: S.String,
|
|
|
|
|
},
|
|
|
|
|
) {}
|
2026-06-01 16:22:25 -05:00
|
|
|
|
|
|
|
|
type WorkbenchError = WorkbenchPromiseError;
|
|
|
|
|
|
2026-06-01 23:19:54 -05:00
|
|
|
const isWorkbenchPromiseError = S.is(WorkbenchPromiseError);
|
|
|
|
|
|
2026-06-01 16:22:25 -05:00
|
|
|
function errorMessage(error: unknown): string {
|
2026-06-01 23:19:54 -05:00
|
|
|
if (isWorkbenchPromiseError(error)) return error.message;
|
2026-06-04 08:33:31 -05:00
|
|
|
if (Predicate.isObject(error) && Predicate.hasProperty(error, "message")) {
|
|
|
|
|
const message = error.message;
|
|
|
|
|
if (Predicate.isString(message)) return message;
|
2026-06-01 23:19:54 -05:00
|
|
|
}
|
2026-06-01 16:22:25 -05:00
|
|
|
return String(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function promiseBoundary<A>(evaluate: () => Promise<A>): Effect.Effect<A, WorkbenchPromiseError> {
|
|
|
|
|
return Effect.tryPromise({
|
|
|
|
|
try: evaluate,
|
2026-06-01 23:19:54 -05:00
|
|
|
catch: (cause) => WorkbenchPromiseError.make({ cause, message: errorMessage(cause) }),
|
2026-06-01 16:22:25 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function base64FromArrayBuffer(buffer: ArrayBuffer): string {
|
|
|
|
|
const bytes = new Uint8Array(buffer);
|
|
|
|
|
const chunkSize = 0x8000;
|
|
|
|
|
let binary = "";
|
|
|
|
|
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
|
|
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
|
|
|
|
}
|
|
|
|
|
return globalThis.btoa(binary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class WorkbenchFiles extends Context.Service<
|
|
|
|
|
WorkbenchFiles,
|
|
|
|
|
{
|
|
|
|
|
readonly readAsBase64: (file: File) => Effect.Effect<string, WorkbenchPromiseError>;
|
|
|
|
|
}
|
|
|
|
|
>()("@trustgraph/workbench/atoms/workbench/WorkbenchFiles") {
|
|
|
|
|
static readonly layer = Layer.succeed(
|
|
|
|
|
WorkbenchFiles,
|
|
|
|
|
WorkbenchFiles.of({
|
|
|
|
|
readAsBase64: Effect.fn("WorkbenchFiles.readAsBase64")(function*(file: File) {
|
|
|
|
|
return yield* promiseBoundary(() => file.arrayBuffer()).pipe(
|
|
|
|
|
Effect.flatMap((buffer) =>
|
|
|
|
|
Effect.try({
|
|
|
|
|
try: () => base64FromArrayBuffer(buffer),
|
2026-06-01 23:19:54 -05:00
|
|
|
catch: (cause) => WorkbenchPromiseError.make({ cause, message: errorMessage(cause) }),
|
2026-06-01 16:22:25 -05:00
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workbenchBaseLayer = Layer.mergeAll(
|
|
|
|
|
BrowserKeyValueStore.layerLocalStorage,
|
|
|
|
|
BrowserClipboard.layer,
|
|
|
|
|
WorkbenchFiles.layer,
|
|
|
|
|
Layer.provide(browserObservabilityLayer, BrowserHttpClient.layerFetch),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const workbenchRuntimeFactory = Atom.context({
|
|
|
|
|
memoMap: Layer.makeMemoMapUnsafe(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-11 06:29:29 -05:00
|
|
|
workbenchRuntimeFactory.addGlobalLayer(workbenchBaseLayer);
|
|
|
|
|
|
|
|
|
|
export const workbenchRuntime = workbenchRuntimeFactory(workbenchBaseLayer);
|
2026-06-01 16:22:25 -05:00
|
|
|
|
|
|
|
|
const queryCounter = Metric.counter("trustgraph_workbench_query_total", {
|
|
|
|
|
description: "Workbench atom-backed query attempts",
|
|
|
|
|
});
|
|
|
|
|
const mutationCounter = Metric.counter("trustgraph_workbench_mutation_total", {
|
|
|
|
|
description: "Workbench atom-backed mutation attempts",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Shared 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 {
|
|
|
|
|
user: string;
|
|
|
|
|
apiKey: string;
|
|
|
|
|
collection: string;
|
|
|
|
|
gatewayUrl: string;
|
|
|
|
|
featureSwitches: FeatureSwitches;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WorkbenchApiFactory {
|
|
|
|
|
readonly create: (settings: Settings) => BaseApi;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type Theme = "dark" | "light";
|
|
|
|
|
|
|
|
|
|
export type ChatMode = "graph-rag" | "document-rag" | "agent";
|
|
|
|
|
export type MessageRole = "user" | "assistant" | "system";
|
|
|
|
|
export type AgentPhase = "think" | "observe" | "answer";
|
|
|
|
|
|
|
|
|
|
export interface ChatMessage {
|
|
|
|
|
id: string;
|
|
|
|
|
role: MessageRole;
|
|
|
|
|
content: string;
|
|
|
|
|
timestamp: number;
|
|
|
|
|
isStreaming?: boolean;
|
|
|
|
|
metadata?: {
|
|
|
|
|
model?: string;
|
|
|
|
|
inTokens?: number;
|
|
|
|
|
outTokens?: number;
|
|
|
|
|
};
|
|
|
|
|
agentPhases?: {
|
|
|
|
|
think: string;
|
|
|
|
|
observe: string;
|
|
|
|
|
answer: string;
|
|
|
|
|
};
|
|
|
|
|
activePhase?: AgentPhase;
|
|
|
|
|
explainEvents?: ExplainEvent[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ConversationState {
|
|
|
|
|
messages: ChatMessage[];
|
|
|
|
|
input: string;
|
|
|
|
|
chatMode: ChatMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FlowSummary {
|
|
|
|
|
id: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
[key: string]: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 UploadForm {
|
|
|
|
|
file: File | null;
|
|
|
|
|
title: string;
|
|
|
|
|
tags: string;
|
|
|
|
|
comments: string;
|
|
|
|
|
uploading: boolean;
|
|
|
|
|
dragOver: boolean;
|
|
|
|
|
progress: UploadProgress | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 TokenCost {
|
|
|
|
|
model: string;
|
|
|
|
|
input_price: number;
|
|
|
|
|
output_price: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CollectionSummary {
|
|
|
|
|
id?: string;
|
|
|
|
|
collection?: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
tags?: string[];
|
|
|
|
|
[key: string]: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Notification {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "success" | "error" | "warning" | "info";
|
|
|
|
|
title: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface McpServerForm {
|
|
|
|
|
key: string;
|
|
|
|
|
url: string;
|
|
|
|
|
remoteName: string;
|
|
|
|
|
authToken: string;
|
|
|
|
|
showToken: boolean;
|
|
|
|
|
saving: boolean;
|
|
|
|
|
keyError: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface McpToolForm {
|
|
|
|
|
key: string;
|
|
|
|
|
name: string;
|
|
|
|
|
description: string;
|
|
|
|
|
mcpTool: string;
|
|
|
|
|
group: string;
|
|
|
|
|
args: ToolArgument[];
|
|
|
|
|
saving: boolean;
|
|
|
|
|
keyError: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface StartFlowForm {
|
|
|
|
|
id: string;
|
|
|
|
|
blueprint: string;
|
|
|
|
|
description: string;
|
|
|
|
|
paramsJson: string;
|
|
|
|
|
submitting: boolean;
|
|
|
|
|
paramsError: string | null;
|
|
|
|
|
submitted: boolean;
|
|
|
|
|
definitionExpanded: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CollectionForm {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
description: string;
|
|
|
|
|
tags: string;
|
|
|
|
|
submitting: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GraphViewState {
|
|
|
|
|
searchTerm: string;
|
|
|
|
|
selectedNodeId: string | null;
|
|
|
|
|
selectedNodeLabel: string | null;
|
|
|
|
|
showLabels: boolean;
|
|
|
|
|
showTypes: boolean;
|
|
|
|
|
nodeLimit: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_FEATURE_SWITCHES: FeatureSwitches = {
|
|
|
|
|
flowClasses: false,
|
|
|
|
|
submissions: false,
|
|
|
|
|
tokenCost: false,
|
|
|
|
|
schemas: false,
|
|
|
|
|
structuredQuery: false,
|
|
|
|
|
ontologyEditor: false,
|
|
|
|
|
agentTools: false,
|
|
|
|
|
mcpTools: false,
|
|
|
|
|
llmModels: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const DEFAULT_SETTINGS: Settings = {
|
|
|
|
|
user: "default",
|
|
|
|
|
apiKey: "",
|
|
|
|
|
collection: "default",
|
|
|
|
|
gatewayUrl: "",
|
|
|
|
|
featureSwitches: DEFAULT_FEATURE_SWITCHES,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SettingsSchema = S.Struct({
|
|
|
|
|
user: S.String,
|
|
|
|
|
apiKey: S.String,
|
|
|
|
|
collection: S.String,
|
|
|
|
|
gatewayUrl: S.String,
|
|
|
|
|
featureSwitches: S.Struct({
|
|
|
|
|
flowClasses: S.Boolean,
|
|
|
|
|
submissions: S.Boolean,
|
|
|
|
|
tokenCost: S.Boolean,
|
|
|
|
|
schemas: S.Boolean,
|
|
|
|
|
structuredQuery: S.Boolean,
|
|
|
|
|
ontologyEditor: S.Boolean,
|
|
|
|
|
agentTools: S.Boolean,
|
|
|
|
|
mcpTools: S.Boolean,
|
|
|
|
|
llmModels: S.Boolean,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const ChatMessageSchema = S.Struct({
|
|
|
|
|
id: S.String,
|
|
|
|
|
role: S.Union([S.Literal("user"), S.Literal("assistant"), S.Literal("system")]),
|
|
|
|
|
content: S.String,
|
2026-06-06 10:33:10 -05:00
|
|
|
timestamp: S.Finite,
|
2026-06-01 16:22:25 -05:00
|
|
|
isStreaming: S.optionalKey(S.Boolean),
|
|
|
|
|
metadata: S.optionalKey(S.Struct({
|
|
|
|
|
model: S.optionalKey(S.String),
|
2026-06-06 10:33:10 -05:00
|
|
|
inTokens: S.optionalKey(S.Finite),
|
|
|
|
|
outTokens: S.optionalKey(S.Finite),
|
2026-06-01 16:22:25 -05:00
|
|
|
})),
|
|
|
|
|
agentPhases: S.optionalKey(S.Struct({
|
|
|
|
|
think: S.String,
|
|
|
|
|
observe: S.String,
|
|
|
|
|
answer: S.String,
|
|
|
|
|
})),
|
|
|
|
|
activePhase: S.optionalKey(S.Union([S.Literal("think"), S.Literal("observe"), S.Literal("answer")])),
|
|
|
|
|
explainEvents: S.optionalKey(S.Array(S.Unknown)),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const ConversationSchema = S.Struct({
|
|
|
|
|
messages: S.Array(ChatMessageSchema),
|
|
|
|
|
input: S.String,
|
|
|
|
|
chatMode: S.Union([S.Literal("graph-rag"), S.Literal("document-rag"), S.Literal("agent")]),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const ThemeSchema = S.Union([S.Literal("dark"), S.Literal("light")]);
|
|
|
|
|
const FlowIdSchema = S.String;
|
|
|
|
|
|
|
|
|
|
const DEFAULT_CONVERSATION: ConversationState = {
|
|
|
|
|
messages: [],
|
|
|
|
|
input: "",
|
|
|
|
|
chatMode: "graph-rag",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const defaultUploadForm = (): UploadForm => ({
|
|
|
|
|
file: null,
|
|
|
|
|
title: "",
|
|
|
|
|
tags: "",
|
|
|
|
|
comments: "",
|
|
|
|
|
uploading: false,
|
|
|
|
|
dragOver: false,
|
|
|
|
|
progress: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const defaultStartFlowForm = (): StartFlowForm => ({
|
|
|
|
|
id: "",
|
|
|
|
|
blueprint: "",
|
|
|
|
|
description: "",
|
|
|
|
|
paramsJson: "{}",
|
|
|
|
|
submitting: false,
|
|
|
|
|
paramsError: null,
|
|
|
|
|
submitted: false,
|
|
|
|
|
definitionExpanded: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const defaultCollectionForm = (): CollectionForm => ({
|
|
|
|
|
id: "",
|
|
|
|
|
name: "",
|
|
|
|
|
description: "",
|
|
|
|
|
tags: "",
|
|
|
|
|
submitting: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function legacySettings(): Settings {
|
|
|
|
|
if (typeof localStorage === "undefined") return DEFAULT_SETTINGS;
|
|
|
|
|
const raw = localStorage.getItem("trustgraph-settings");
|
|
|
|
|
if (raw === null) return DEFAULT_SETTINGS;
|
|
|
|
|
const parsed = parseJsonUnknown(raw) as { state?: { settings?: Partial<Settings> } } | undefined;
|
|
|
|
|
if (parsed === undefined) return DEFAULT_SETTINGS;
|
|
|
|
|
return {
|
|
|
|
|
...DEFAULT_SETTINGS,
|
|
|
|
|
...parsed.state?.settings,
|
|
|
|
|
featureSwitches: {
|
|
|
|
|
...DEFAULT_FEATURE_SWITCHES,
|
|
|
|
|
...parsed.state?.settings?.featureSwitches,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function legacyConversation(): ConversationState {
|
|
|
|
|
if (typeof localStorage === "undefined") return DEFAULT_CONVERSATION;
|
|
|
|
|
const raw = localStorage.getItem("tg-conversation");
|
|
|
|
|
if (raw === null) return DEFAULT_CONVERSATION;
|
|
|
|
|
const parsed = parseJsonUnknown(raw) as { state?: Partial<ConversationState> } | undefined;
|
|
|
|
|
if (parsed === undefined) return DEFAULT_CONVERSATION;
|
|
|
|
|
const messages = (parsed.state?.messages ?? []).filter((message) => message.isStreaming !== true).slice(-200);
|
|
|
|
|
return {
|
|
|
|
|
...DEFAULT_CONVERSATION,
|
|
|
|
|
messages,
|
|
|
|
|
chatMode: parsed.state?.chatMode ?? "graph-rag",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defaultTheme(): Theme {
|
|
|
|
|
if (typeof localStorage !== "undefined") {
|
2026-06-02 08:03:43 -05:00
|
|
|
const legacyTheme = localStorage.getItem("tg-theme");
|
|
|
|
|
if (legacyTheme === "dark" || legacyTheme === "light") return legacyTheme;
|
2026-06-01 16:22:25 -05:00
|
|
|
}
|
|
|
|
|
if (typeof document !== "undefined" && document.documentElement.classList.contains("light")) {
|
|
|
|
|
return "light";
|
|
|
|
|
}
|
|
|
|
|
return "dark";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resultErrorMessage<A, E>(result: AsyncResult.AsyncResult<A, E>): string | null {
|
|
|
|
|
if (result._tag !== "Failure") return null;
|
|
|
|
|
return errorMessage(Cause.squash(result.cause));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const decodeJsonUnknown = S.decodeUnknownOption(S.UnknownFromJsonString);
|
|
|
|
|
const encodeJsonUnknown = S.encodeUnknownOption(S.fromJsonString(S.Unknown));
|
|
|
|
|
|
|
|
|
|
export function parseJsonUnknown(raw: string): unknown {
|
|
|
|
|
return Option.getOrUndefined(decodeJsonUnknown(raw));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function encodeJsonUnknownString(value: unknown): string {
|
|
|
|
|
return Option.getOrElse(encodeJsonUnknown(value), () => "{}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resultData<A, E>(result: AsyncResult.AsyncResult<A, E>, fallback: A): A {
|
|
|
|
|
return AsyncResult.getOrElse(result, () => fallback);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resultLoading<A, E>(result: AsyncResult.AsyncResult<A, E>, value: unknown = null): boolean {
|
|
|
|
|
if (result.waiting) return true;
|
|
|
|
|
if (result._tag !== "Initial") return false;
|
|
|
|
|
if (Array.isArray(value)) return value.length === 0;
|
|
|
|
|
if (typeof value === "string") return value.length === 0;
|
|
|
|
|
return value === null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resultError<A, E>(result: AsyncResult.AsyncResult<A, E>): string | null {
|
|
|
|
|
return resultErrorMessage(result);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 07:39:08 -05:00
|
|
|
const randomId = Effect.fn("trustgraph.workbench.randomId")(function*(prefix: string) {
|
|
|
|
|
const left = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
|
|
|
|
|
const right = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
|
|
|
|
|
return `${prefix}-${left.toString(36).padStart(6, "0")}${right.toString(36).padStart(6, "0")}`;
|
|
|
|
|
});
|
2026-06-01 16:22:25 -05:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapConfigEntries(raw: unknown): Array<{ key: string; value: string }> {
|
|
|
|
|
return Array.isArray(raw)
|
|
|
|
|
? raw
|
|
|
|
|
.filter((item): item is { key: string; value: string } =>
|
|
|
|
|
typeof item === "object" &&
|
|
|
|
|
item !== null &&
|
|
|
|
|
typeof (item as { key?: unknown }).key === "string" &&
|
|
|
|
|
typeof (item as { value?: unknown }).value === "string",
|
|
|
|
|
)
|
|
|
|
|
: [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 06:29:29 -05:00
|
|
|
function configValuesFromResponse(raw: unknown): Array<{ key: string; value: unknown; type?: string; workspace?: string }> {
|
|
|
|
|
const values = jsonRecordProperty(raw, "values");
|
|
|
|
|
if (!Array.isArray(values)) return [];
|
|
|
|
|
return values.flatMap((value) => {
|
|
|
|
|
if (!Predicate.isObject(value) || !Predicate.hasProperty(value, "key") || typeof value.key !== "string") {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
const entry: { key: string; value: unknown; type?: string; workspace?: string } = {
|
|
|
|
|
key: value.key,
|
|
|
|
|
value: Predicate.hasProperty(value, "value") ? value.value : undefined,
|
|
|
|
|
};
|
|
|
|
|
if (Predicate.hasProperty(value, "type") && typeof value.type === "string") entry.type = value.type;
|
|
|
|
|
if (Predicate.hasProperty(value, "workspace") && typeof value.workspace === "string") entry.workspace = value.workspace;
|
|
|
|
|
return [entry];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseConfigValue(value: unknown): unknown {
|
|
|
|
|
return typeof value === "string" ? parseJsonUnknown(value) ?? value : value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 16:22:25 -05:00
|
|
|
function parseConfigEntries<T>(raw: unknown, label: string): T[] {
|
|
|
|
|
const entries: T[] = [];
|
|
|
|
|
for (const item of mapConfigEntries(raw)) {
|
|
|
|
|
const config = parseJsonUnknown(item.value);
|
|
|
|
|
if (config === undefined) {
|
2026-06-01 23:19:54 -05:00
|
|
|
Effect.runSync(Effect.logWarning(`[workbench-atoms] Failed to parse ${label}: ${item.key}`));
|
2026-06-01 16:22:25 -05:00
|
|
|
} else {
|
|
|
|
|
entries.push({ key: item.key, config } as T);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return entries;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function withDefaultCollection(collection: string): string {
|
|
|
|
|
return collection.length > 0 ? collection : "default";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type WorkbenchReactivityKeys = ReadonlyArray<unknown> | Record<string, ReadonlyArray<unknown>>;
|
|
|
|
|
type WorkbenchRuntimeRequirements = BrowserClipboard.Clipboard | WorkbenchFiles | Reactivity.Reactivity;
|
2026-06-11 06:29:29 -05:00
|
|
|
type WorkbenchHttpAtomRequirements =
|
|
|
|
|
| AtomRegistry.AtomRegistry
|
|
|
|
|
| Reactivity.Reactivity
|
|
|
|
|
| Scope.Scope
|
|
|
|
|
| WorkbenchGatewayHttp;
|
|
|
|
|
type WorkbenchGatewayAtomRequirements =
|
|
|
|
|
| AtomRegistry.AtomRegistry
|
|
|
|
|
| Reactivity.Reactivity
|
|
|
|
|
| Scope.Scope
|
|
|
|
|
| WorkbenchGatewayHttp
|
|
|
|
|
| WorkbenchGatewayRpc;
|
|
|
|
|
type WorkbenchDispatchInput = DispatchPayload;
|
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
const StreamingEnvelopeSchema = S.Struct({
|
|
|
|
|
response: S.optionalKey(S.Unknown),
|
|
|
|
|
complete: S.optionalKey(S.Boolean),
|
|
|
|
|
error: S.optionalKey(S.String),
|
|
|
|
|
});
|
|
|
|
|
type StreamingEnvelope = typeof StreamingEnvelopeSchema.Type;
|
|
|
|
|
|
|
|
|
|
const ClientTripleSchema: S.Codec<Triple, Triple> = S.suspend(() =>
|
|
|
|
|
S.Struct({
|
|
|
|
|
s: ClientTermSchema,
|
|
|
|
|
p: ClientTermSchema,
|
|
|
|
|
o: ClientTermSchema,
|
|
|
|
|
g: S.optionalKey(S.String),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const ClientTermSchema: S.Codec<Term, Term> = S.suspend(() =>
|
|
|
|
|
S.Union([
|
|
|
|
|
S.Struct({
|
|
|
|
|
t: S.Literal("i"),
|
|
|
|
|
i: S.String,
|
|
|
|
|
}),
|
|
|
|
|
S.Struct({
|
|
|
|
|
t: S.Literal("b"),
|
|
|
|
|
d: S.String,
|
|
|
|
|
}),
|
|
|
|
|
S.Struct({
|
|
|
|
|
t: S.Literal("l"),
|
|
|
|
|
v: S.String,
|
|
|
|
|
dt: S.optionalKey(S.String),
|
|
|
|
|
ln: S.optionalKey(S.String),
|
|
|
|
|
}),
|
|
|
|
|
S.Struct({
|
|
|
|
|
t: S.Literal("t"),
|
|
|
|
|
tr: S.optionalKey(ClientTripleSchema),
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const decodeStreamingEnvelope = S.decodeUnknownOption(StreamingEnvelopeSchema);
|
|
|
|
|
const decodeClientTriples = S.decodeUnknownOption(S.Array(ClientTripleSchema).pipe(S.mutable));
|
|
|
|
|
|
|
|
|
|
function gatewayHttpBaseUrl(settings: Settings): string {
|
|
|
|
|
const raw = settings.gatewayUrl.trim();
|
|
|
|
|
if (raw.length === 0 || raw === "/api/v1/rpc") return "";
|
|
|
|
|
if (raw.startsWith("/")) {
|
|
|
|
|
return raw.replace(/\/api\/v1\/rpc$/, "").replace(/\/api\/v1$/, "");
|
|
|
|
|
}
|
|
|
|
|
const normalized = raw.startsWith("ws://")
|
|
|
|
|
? `http://${raw.slice("ws://".length)}`
|
|
|
|
|
: raw.startsWith("wss://")
|
|
|
|
|
? `https://${raw.slice("wss://".length)}`
|
|
|
|
|
: raw;
|
|
|
|
|
const parsed = new URL(normalized);
|
|
|
|
|
return parsed.origin;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function gatewayRpcUrl(settings: Settings): string {
|
|
|
|
|
const raw = settings.gatewayUrl.trim().length > 0 ? settings.gatewayUrl.trim() : "/api/v1/rpc";
|
|
|
|
|
const normalized = raw.startsWith("http://")
|
|
|
|
|
? `ws://${raw.slice("http://".length)}`
|
|
|
|
|
: raw.startsWith("https://")
|
|
|
|
|
? `wss://${raw.slice("https://".length)}`
|
|
|
|
|
: raw;
|
|
|
|
|
if (settings.apiKey.length === 0) return normalized;
|
|
|
|
|
const separator = normalized.includes("?") ? "&" : "?";
|
|
|
|
|
return `${normalized}${separator}token=${encodeURIComponent(settings.apiKey)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const gatewayHttpClientLayer = (get: Atom.AtomContext) => {
|
|
|
|
|
const settings = get(settingsAtom);
|
|
|
|
|
const baseUrl = gatewayHttpBaseUrl(settings);
|
|
|
|
|
const token = settings.apiKey.length > 0 ? settings.apiKey : undefined;
|
|
|
|
|
|
|
|
|
|
return Layer.effect(
|
|
|
|
|
HttpClient.HttpClient,
|
|
|
|
|
HttpClient.HttpClient.pipe(
|
|
|
|
|
Effect.map((client) =>
|
|
|
|
|
HttpClient.mapRequest(client, (request) => {
|
|
|
|
|
const withBaseUrl = baseUrl.length > 0
|
|
|
|
|
? HttpClientRequest.prependUrl(request, baseUrl)
|
|
|
|
|
: request;
|
|
|
|
|
return token === undefined
|
|
|
|
|
? withBaseUrl
|
|
|
|
|
: HttpClientRequest.bearerToken(withBaseUrl, token);
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
).pipe(Layer.provide(BrowserHttpClient.layerFetch));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const gatewayRpcProtocolLayer = (get: Atom.AtomContext) => {
|
|
|
|
|
const socketLayer = Layer.effect(
|
|
|
|
|
Socket.Socket,
|
|
|
|
|
Socket.makeWebSocket(gatewayRpcUrl(get(settingsAtom)), {
|
|
|
|
|
closeCodeIsError: (code) => code !== 1000,
|
|
|
|
|
openTimeout: "10 seconds",
|
|
|
|
|
}),
|
|
|
|
|
).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal));
|
|
|
|
|
|
|
|
|
|
return RpcClient.layerProtocolSocket({
|
|
|
|
|
retryTransientErrors: true,
|
|
|
|
|
}).pipe(
|
|
|
|
|
Layer.provide(socketLayer),
|
|
|
|
|
Layer.provide(RpcSerialization.layerNdjson),
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export class WorkbenchGatewayHttp extends AtomHttpApi.Service<WorkbenchGatewayHttp>()(
|
|
|
|
|
"@trustgraph/workbench/atoms/workbench/WorkbenchGatewayHttp",
|
|
|
|
|
{
|
|
|
|
|
api: GatewayWorkbenchHttpApi,
|
|
|
|
|
httpClient: gatewayHttpClientLayer,
|
|
|
|
|
runtime: workbenchRuntimeFactory,
|
|
|
|
|
},
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
export class WorkbenchGatewayRpc extends AtomRpc.Service<WorkbenchGatewayRpc>()(
|
|
|
|
|
"@trustgraph/workbench/atoms/workbench/WorkbenchGatewayRpc",
|
|
|
|
|
{
|
|
|
|
|
group: TrustGraphRpcs,
|
|
|
|
|
protocol: gatewayRpcProtocolLayer,
|
|
|
|
|
runtime: workbenchRuntimeFactory,
|
|
|
|
|
},
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
const workbenchGatewayRuntime = workbenchRuntimeFactory((get) =>
|
|
|
|
|
Layer.merge(
|
|
|
|
|
get(WorkbenchGatewayHttp.runtime.layer),
|
|
|
|
|
get(WorkbenchGatewayRpc.runtime.layer),
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-06-01 16:22:25 -05:00
|
|
|
|
|
|
|
|
interface CommandOptions<A> {
|
|
|
|
|
readonly initialValue?: A;
|
|
|
|
|
readonly reactivityKeys?: WorkbenchReactivityKeys;
|
|
|
|
|
readonly concurrent?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 06:29:29 -05:00
|
|
|
function asJsonRecord(value: unknown): JsonRecord {
|
|
|
|
|
return Predicate.isObject(value) && !Array.isArray(value) ? value as JsonRecord : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function jsonRecordProperty(value: unknown, key: string): unknown | undefined {
|
|
|
|
|
return Predicate.isObject(value) && Predicate.hasProperty(value, key) ? value[key] : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function streamingEnvelopeFrom(message: unknown): StreamingEnvelope {
|
|
|
|
|
return Option.getOrElse(decodeStreamingEnvelope(message), () => ({
|
|
|
|
|
complete: true,
|
|
|
|
|
error: "Streaming message could not be decoded",
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function propertyValue(source: unknown, key: string): unknown | undefined {
|
|
|
|
|
return Predicate.hasProperty(source, key) ? source[key] : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringProperty(source: unknown, key: string): string | undefined {
|
|
|
|
|
const value = propertyValue(source, key);
|
|
|
|
|
return typeof value === "string" ? value : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numberProperty(source: unknown, key: string): number | undefined {
|
|
|
|
|
const value = propertyValue(source, key);
|
|
|
|
|
return typeof value === "number" ? value : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function booleanProperty(source: unknown, key: string): boolean | undefined {
|
|
|
|
|
const value = propertyValue(source, key);
|
|
|
|
|
return typeof value === "boolean" ? value : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function gatewayResponseErrorMessage(value: unknown): string | undefined {
|
|
|
|
|
const error = jsonRecordProperty(value, "error");
|
|
|
|
|
if (typeof error === "string") return error;
|
|
|
|
|
const message = jsonRecordProperty(error, "message");
|
|
|
|
|
return typeof message === "string" && message.length > 0 ? message : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function responseErrorMessage(source: unknown): string | undefined {
|
|
|
|
|
const error = propertyValue(source, "error");
|
|
|
|
|
if (typeof error === "string") return error;
|
|
|
|
|
return stringProperty(error, "message");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function streamComplete(
|
|
|
|
|
envelope: StreamingEnvelope,
|
|
|
|
|
response: unknown,
|
|
|
|
|
responseMarkers: ReadonlyArray<string> = [],
|
|
|
|
|
): boolean {
|
|
|
|
|
return envelope.complete === true || responseMarkers.some((key) => booleanProperty(response, key) === true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function explainTriplesFrom(source: unknown): Triple[] | undefined {
|
|
|
|
|
return Option.getOrUndefined(decodeClientTriples(propertyValue(source, "explain_triples")));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function streamingMetadataFrom(source: unknown): StreamingMetadata | undefined {
|
|
|
|
|
const metadata: StreamingMetadata = {};
|
|
|
|
|
let hasMetadata = false;
|
|
|
|
|
|
|
|
|
|
const inToken = numberProperty(source, "in_token");
|
|
|
|
|
if (inToken !== undefined) {
|
|
|
|
|
metadata.in_token = inToken;
|
|
|
|
|
hasMetadata = true;
|
|
|
|
|
}
|
|
|
|
|
const outToken = numberProperty(source, "out_token");
|
|
|
|
|
if (outToken !== undefined) {
|
|
|
|
|
metadata.out_token = outToken;
|
|
|
|
|
hasMetadata = true;
|
|
|
|
|
}
|
|
|
|
|
const model = stringProperty(source, "model");
|
|
|
|
|
if (model !== undefined) {
|
|
|
|
|
metadata.model = model;
|
|
|
|
|
hasMetadata = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return hasMetadata ? metadata : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function failWorkbenchRemote(operation: string, cause: unknown): WorkbenchPromiseError {
|
|
|
|
|
return WorkbenchPromiseError.make({ cause, message: `${operation}: ${errorMessage(cause)}` });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function decodeResponseJsonEffect(value: unknown, operation: string): Effect.Effect<unknown, WorkbenchPromiseError> {
|
|
|
|
|
const parsed = typeof value === "string" ? parseJsonUnknown(value) : value;
|
|
|
|
|
return parsed === undefined
|
|
|
|
|
? Effect.fail(WorkbenchPromiseError.make({ cause: value, message: `${operation}: response JSON could not be decoded` }))
|
|
|
|
|
: Effect.succeed(parsed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureNoGatewayResponseError<A>(operation: string, value: A): Effect.Effect<A, WorkbenchPromiseError> {
|
|
|
|
|
const message = gatewayResponseErrorMessage(value);
|
|
|
|
|
return message === undefined
|
|
|
|
|
? Effect.succeed(value)
|
|
|
|
|
: Effect.fail(WorkbenchPromiseError.make({ cause: value, message: `${operation}: ${message}` }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function qaBaseApi(): BaseApi | undefined {
|
|
|
|
|
if (typeof window === "undefined") return undefined;
|
|
|
|
|
return (window as Window & { __TRUSTGRAPH_WORKBENCH_QA_API__?: BaseApi }).__TRUSTGRAPH_WORKBENCH_QA_API__;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeWorkbenchGatewayApi(settings: Settings) {
|
|
|
|
|
const user = settings.user;
|
|
|
|
|
const dispatch = (
|
|
|
|
|
service: string,
|
|
|
|
|
request: JsonRecord,
|
|
|
|
|
flow?: string,
|
|
|
|
|
): Effect.Effect<JsonRecord, WorkbenchPromiseError, WorkbenchGatewayHttp> =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const qaApi = qaBaseApi();
|
|
|
|
|
if (qaApi !== undefined) {
|
|
|
|
|
const response = yield* promiseBoundary(() =>
|
|
|
|
|
qaApi.makeRequest(service, request, undefined, undefined, flow)
|
|
|
|
|
);
|
|
|
|
|
return asJsonRecord(response);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const client = yield* WorkbenchGatewayHttp;
|
|
|
|
|
const scope = flow === undefined ? "global" as const : "flow" as const;
|
|
|
|
|
const input: WorkbenchDispatchInput = flow === undefined
|
|
|
|
|
? { scope, service, request }
|
|
|
|
|
: { scope, service, flow, request };
|
|
|
|
|
const response = yield* client.dispatch({ payload: DispatchPayload.make(input) });
|
|
|
|
|
return asJsonRecord(response);
|
|
|
|
|
}).pipe(
|
|
|
|
|
Effect.mapError((cause) => failWorkbenchRemote(`dispatch ${service}`, cause)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const flowDispatch = (flow: string, service: string, request: JsonRecord) =>
|
|
|
|
|
dispatch(service, request, flow);
|
|
|
|
|
|
|
|
|
|
const dispatchStream = (
|
|
|
|
|
service: string,
|
|
|
|
|
request: JsonRecord,
|
|
|
|
|
flow: string,
|
|
|
|
|
receive: (message: StreamingEnvelope) => boolean,
|
|
|
|
|
label: string,
|
|
|
|
|
onError: (message: string) => void,
|
|
|
|
|
): Effect.Effect<void, never, WorkbenchGatewayRpc> =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const qaApi = qaBaseApi();
|
|
|
|
|
if (qaApi !== undefined) {
|
|
|
|
|
return yield* promiseBoundary(() =>
|
|
|
|
|
qaApi.makeRequestMulti(
|
|
|
|
|
service,
|
|
|
|
|
request,
|
|
|
|
|
(message) => receive(streamingEnvelopeFrom(message)),
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
flow,
|
|
|
|
|
)
|
|
|
|
|
).pipe(
|
|
|
|
|
Effect.catch((cause) =>
|
|
|
|
|
Effect.sync(() => {
|
|
|
|
|
onError(`${label} request failed: ${errorMessage(cause)}`);
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
Effect.asVoid,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const client = yield* WorkbenchGatewayRpc;
|
|
|
|
|
const payload = DispatchPayload.make({
|
|
|
|
|
scope: "flow",
|
|
|
|
|
service,
|
|
|
|
|
flow,
|
|
|
|
|
request,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
yield* client("DispatchStream", payload).pipe(
|
|
|
|
|
Stream.runForEachWhile((chunk) =>
|
|
|
|
|
Effect.sync(() => !receive({ response: chunk.response, complete: chunk.complete }))
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}).pipe(
|
|
|
|
|
Effect.catch((cause) =>
|
|
|
|
|
Effect.sync(() => {
|
|
|
|
|
onError(`${label} request failed: ${errorMessage(cause)}`);
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const configAll = () => dispatch("config", { operation: "config" }, undefined);
|
|
|
|
|
|
|
|
|
|
const configApi = {
|
|
|
|
|
getConfigAll: configAll,
|
|
|
|
|
getPrompts: () =>
|
|
|
|
|
configAll().pipe(
|
|
|
|
|
Effect.map((response) => {
|
|
|
|
|
const config = asJsonRecord(response.config);
|
|
|
|
|
const promptNs = asJsonRecord(config.prompt);
|
|
|
|
|
return Object.keys(promptNs)
|
|
|
|
|
.filter((key) => key !== "system")
|
|
|
|
|
.sort()
|
|
|
|
|
.map((id) => ({ id, name: id }));
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
getSystemPrompt: () =>
|
|
|
|
|
configAll().pipe(
|
|
|
|
|
Effect.map((response) => {
|
|
|
|
|
const config = asJsonRecord(response.config);
|
|
|
|
|
const prompt = asJsonRecord(config.prompt);
|
|
|
|
|
const raw = prompt.system;
|
|
|
|
|
return raw == null ? "" : raw;
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
getPrompt: (id: string) =>
|
|
|
|
|
configAll().pipe(
|
|
|
|
|
Effect.map((response) => {
|
|
|
|
|
const config = asJsonRecord(response.config);
|
|
|
|
|
return asJsonRecord(config.prompt)[id] ?? null;
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
getValues: (type: string) =>
|
|
|
|
|
dispatch("config", { operation: "getvalues", type }, undefined).pipe(
|
|
|
|
|
Effect.map((response) => configValuesFromResponse(response)),
|
|
|
|
|
),
|
|
|
|
|
getTokenCosts: () =>
|
|
|
|
|
dispatch("config", { operation: "getvalues", type: "token-cost" }, undefined).pipe(
|
|
|
|
|
Effect.map((response) =>
|
|
|
|
|
configValuesFromResponse(response).map((item) => {
|
|
|
|
|
const value = parseConfigValue(item.value) as Record<string, number>;
|
|
|
|
|
return {
|
|
|
|
|
model: item.key,
|
|
|
|
|
input_price: value.input_price,
|
|
|
|
|
output_price: value.output_price,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
putConfig: (items: { type: string; key: string; value: string }[]) =>
|
|
|
|
|
dispatch("config", { operation: "put", values: items }, undefined),
|
|
|
|
|
deleteConfig: (target: { type: string; key: string }) =>
|
|
|
|
|
dispatch("config", { operation: "delete", keys: [target] }, undefined),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
user,
|
|
|
|
|
flows: () => ({
|
|
|
|
|
getFlows: () =>
|
|
|
|
|
dispatch("flow", { operation: "list-flows" }, undefined).pipe(
|
|
|
|
|
Effect.map((response) => Array.isArray(response["flow-ids"]) ? response["flow-ids"] as string[] : []),
|
|
|
|
|
),
|
|
|
|
|
getFlow: (id: string) =>
|
|
|
|
|
dispatch("flow", { operation: "get-flow", "flow-id": id }, undefined).pipe(
|
|
|
|
|
Effect.flatMap((response) => decodeResponseJsonEffect(response.flow, "get-flow")),
|
|
|
|
|
),
|
|
|
|
|
getFlowBlueprints: () =>
|
|
|
|
|
dispatch("flow", { operation: "list-blueprints" }, undefined).pipe(
|
|
|
|
|
Effect.map((response) => Array.isArray(response["blueprint-names"]) ? response["blueprint-names"] as string[] : []),
|
|
|
|
|
),
|
|
|
|
|
getFlowBlueprint: (name: string) =>
|
|
|
|
|
dispatch("flow", { operation: "get-blueprint", "blueprint-name": name }, undefined).pipe(
|
|
|
|
|
Effect.flatMap((response) => decodeResponseJsonEffect(response["blueprint-definition"], "get-blueprint")),
|
|
|
|
|
),
|
|
|
|
|
startFlow: (id: string, blueprint: string, description: string, parameters?: Record<string, unknown>) => {
|
|
|
|
|
const request: JsonRecord = {
|
|
|
|
|
operation: "start-flow",
|
|
|
|
|
"flow-id": id,
|
|
|
|
|
"blueprint-name": blueprint,
|
|
|
|
|
description,
|
|
|
|
|
};
|
|
|
|
|
if (parameters !== undefined && Object.keys(parameters).length > 0) request.parameters = parameters;
|
|
|
|
|
return dispatch("flow", request, undefined).pipe(
|
|
|
|
|
Effect.flatMap((response) => ensureNoGatewayResponseError("start-flow", response)),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
stopFlow: (id: string) =>
|
|
|
|
|
dispatch("flow", { operation: "stop-flow", "flow-id": id }, undefined),
|
|
|
|
|
}),
|
|
|
|
|
config: () => configApi,
|
|
|
|
|
librarian: () => ({
|
|
|
|
|
getDocuments: () =>
|
|
|
|
|
dispatch("librarian", { operation: "list-documents", user }, undefined).pipe(
|
|
|
|
|
Effect.map((response) => (response["document-metadatas"] ?? response.documents ?? []) as DocumentMetadata[]),
|
|
|
|
|
),
|
|
|
|
|
getProcessing: () =>
|
|
|
|
|
dispatch("librarian", { operation: "list-processing", user }, undefined).pipe(
|
|
|
|
|
Effect.map((response) => (response["processing-metadatas"] ?? response.processing ?? response["processing-metadata"] ?? []) as ProcessingMetadata[]),
|
|
|
|
|
),
|
|
|
|
|
getDocumentMetadata: (documentId: string) =>
|
|
|
|
|
dispatch("librarian", {
|
|
|
|
|
operation: "get-document-metadata",
|
|
|
|
|
"document-id": documentId,
|
|
|
|
|
documentId,
|
|
|
|
|
user,
|
|
|
|
|
}, undefined).pipe(
|
|
|
|
|
Effect.map((response) => (response["document-metadata"] ?? response.documentMetadata ?? null) as DocumentMetadata | null),
|
|
|
|
|
),
|
|
|
|
|
loadDocument: Effect.fn("trustgraph.workbench.gateway.librarian.loadDocument")(function*(
|
|
|
|
|
document: string,
|
|
|
|
|
mimeType: string,
|
|
|
|
|
title: string,
|
|
|
|
|
comments: string,
|
|
|
|
|
tags: string[],
|
|
|
|
|
id?: string,
|
|
|
|
|
metadata?: Triple[],
|
|
|
|
|
) {
|
|
|
|
|
const timestamp = yield* Clock.currentTimeMillis;
|
|
|
|
|
const documentMetadata: DocumentMetadata = {
|
|
|
|
|
time: Math.floor(timestamp / 1000),
|
|
|
|
|
kind: mimeType,
|
|
|
|
|
title,
|
|
|
|
|
comments,
|
|
|
|
|
user,
|
|
|
|
|
tags,
|
|
|
|
|
"document-type": "source",
|
|
|
|
|
documentType: "source",
|
|
|
|
|
};
|
|
|
|
|
if (id !== undefined) documentMetadata.id = id;
|
|
|
|
|
if (metadata !== undefined) documentMetadata.metadata = metadata;
|
|
|
|
|
return yield* dispatch("librarian", {
|
|
|
|
|
operation: "add-document",
|
|
|
|
|
"document-metadata": documentMetadata,
|
|
|
|
|
documentMetadata,
|
|
|
|
|
content: document,
|
|
|
|
|
}, undefined);
|
|
|
|
|
}),
|
|
|
|
|
removeDocument: (id: string, collection?: string) =>
|
|
|
|
|
dispatch("librarian", {
|
|
|
|
|
operation: "remove-document",
|
|
|
|
|
"document-id": id,
|
|
|
|
|
documentId: id,
|
|
|
|
|
user,
|
|
|
|
|
collection: withDefaultCollection(collection ?? "default"),
|
|
|
|
|
}, undefined),
|
|
|
|
|
beginUpload: (
|
|
|
|
|
metadata: ChunkedUploadDocumentMetadata,
|
|
|
|
|
totalSize: number,
|
|
|
|
|
chunkSize?: number,
|
|
|
|
|
): Effect.Effect<BeginUploadResponse, WorkbenchPromiseError, WorkbenchGatewayHttp> => {
|
|
|
|
|
const request: JsonRecord = {
|
|
|
|
|
operation: "begin-upload",
|
|
|
|
|
"document-metadata": metadata,
|
|
|
|
|
documentMetadata: metadata,
|
|
|
|
|
"total-size": totalSize,
|
|
|
|
|
};
|
|
|
|
|
if (chunkSize !== undefined) request["chunk-size"] = chunkSize;
|
|
|
|
|
return dispatch("librarian", request, undefined).pipe(
|
|
|
|
|
Effect.flatMap((response) => ensureNoGatewayResponseError("begin-upload", response)),
|
|
|
|
|
Effect.map((response) => response as unknown as BeginUploadResponse),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
uploadChunk: (
|
|
|
|
|
uploadId: string,
|
|
|
|
|
chunkIndex: number,
|
|
|
|
|
content: string,
|
|
|
|
|
): Effect.Effect<UploadChunkResponse, WorkbenchPromiseError, WorkbenchGatewayHttp> =>
|
|
|
|
|
dispatch("librarian", {
|
|
|
|
|
operation: "upload-chunk",
|
|
|
|
|
"upload-id": uploadId,
|
|
|
|
|
"chunk-index": chunkIndex,
|
|
|
|
|
content,
|
|
|
|
|
user,
|
|
|
|
|
}, undefined).pipe(
|
|
|
|
|
Effect.flatMap((response) => ensureNoGatewayResponseError("upload-chunk", response)),
|
|
|
|
|
Effect.map((response) => response as unknown as UploadChunkResponse),
|
|
|
|
|
),
|
|
|
|
|
completeUpload: (uploadId: string): Effect.Effect<CompleteUploadResponse, WorkbenchPromiseError, WorkbenchGatewayHttp> =>
|
|
|
|
|
dispatch("librarian", {
|
|
|
|
|
operation: "complete-upload",
|
|
|
|
|
"upload-id": uploadId,
|
|
|
|
|
user,
|
|
|
|
|
}, undefined).pipe(
|
|
|
|
|
Effect.flatMap((response) => ensureNoGatewayResponseError("complete-upload", response)),
|
|
|
|
|
Effect.map((response) => response as unknown as CompleteUploadResponse),
|
|
|
|
|
),
|
|
|
|
|
}),
|
|
|
|
|
knowledge: () => ({
|
|
|
|
|
getKnowledgeCores: () =>
|
|
|
|
|
dispatch("knowledge", { operation: "list-kg-cores", user }, undefined).pipe(
|
|
|
|
|
Effect.map((response) => Array.isArray(response.ids) ? response.ids as string[] : []),
|
|
|
|
|
),
|
|
|
|
|
getDocumentEmbeddingCores: () =>
|
|
|
|
|
dispatch("knowledge", { operation: "list-de-cores", user }, undefined).pipe(
|
|
|
|
|
Effect.map((response) => Array.isArray(response.ids) ? response.ids as string[] : []),
|
|
|
|
|
),
|
|
|
|
|
loadKgCore: (id: string, flow: string, collection?: string) =>
|
|
|
|
|
dispatch("knowledge", {
|
|
|
|
|
operation: "load-kg-core",
|
|
|
|
|
id,
|
|
|
|
|
flow,
|
|
|
|
|
user,
|
|
|
|
|
collection: withDefaultCollection(collection ?? "default"),
|
|
|
|
|
}, undefined),
|
|
|
|
|
deleteKgCore: (id: string, collection?: string) =>
|
|
|
|
|
dispatch("knowledge", {
|
|
|
|
|
operation: "delete-kg-core",
|
|
|
|
|
id,
|
|
|
|
|
user,
|
|
|
|
|
collection: withDefaultCollection(collection ?? "default"),
|
|
|
|
|
}, undefined),
|
|
|
|
|
}),
|
|
|
|
|
collectionManagement: () => ({
|
|
|
|
|
listCollections: (tagFilter?: string[]) => {
|
|
|
|
|
const request: JsonRecord = { operation: "list-collections", user };
|
|
|
|
|
if (tagFilter !== undefined && tagFilter.length > 0) request.tag_filter = tagFilter;
|
|
|
|
|
return dispatch("collection-management", request, undefined).pipe(
|
|
|
|
|
Effect.map((response) => (response.collections ?? []) as CollectionSummary[]),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
updateCollection: (collection: string, name?: string, description?: string, tags?: string[]) => {
|
|
|
|
|
const request: JsonRecord = { operation: "update-collection", user, collection };
|
|
|
|
|
if (name !== undefined) request.name = name;
|
|
|
|
|
if (description !== undefined) request.description = description;
|
|
|
|
|
if (tags !== undefined) request.tags = tags;
|
|
|
|
|
return dispatch("collection-management", request, undefined).pipe(
|
|
|
|
|
Effect.flatMap((response) => {
|
|
|
|
|
const collections = response.collections;
|
|
|
|
|
return Array.isArray(collections) && collections.length > 0
|
|
|
|
|
? Effect.succeed(collections[0])
|
|
|
|
|
: Effect.fail(WorkbenchPromiseError.make({ cause: response, message: "update-collection: failed to update collection" }));
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
deleteCollection: (collection: string) =>
|
|
|
|
|
dispatch("collection-management", {
|
|
|
|
|
operation: "delete-collection",
|
|
|
|
|
user,
|
|
|
|
|
collection,
|
|
|
|
|
}, undefined),
|
|
|
|
|
}),
|
|
|
|
|
flow: (flowId: string) => ({
|
|
|
|
|
triplesQuery: Effect.fn("trustgraph.workbench.gateway.flow.triplesQuery")(function*(
|
|
|
|
|
s?: Term,
|
|
|
|
|
p?: Term,
|
|
|
|
|
o?: Term,
|
|
|
|
|
limit?: number,
|
|
|
|
|
collection?: string,
|
|
|
|
|
graph?: string,
|
|
|
|
|
) {
|
|
|
|
|
const request: JsonRecord = {
|
|
|
|
|
limit: limit ?? 20,
|
|
|
|
|
user,
|
|
|
|
|
collection: withDefaultCollection(collection ?? "default"),
|
|
|
|
|
};
|
|
|
|
|
if (s !== undefined) request.s = s;
|
|
|
|
|
if (p !== undefined) request.p = p;
|
|
|
|
|
if (o !== undefined) request.o = o;
|
|
|
|
|
if (graph !== undefined) request.g = graph;
|
|
|
|
|
return yield* flowDispatch(flowId, "triples", request).pipe(
|
|
|
|
|
Effect.map((response) => (response.triples ?? response.response ?? []) as Triple[]),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
graphRagStreaming: Effect.fn("trustgraph.workbench.gateway.flow.graphRagStreaming")(function*(
|
|
|
|
|
text: string,
|
|
|
|
|
receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void,
|
|
|
|
|
onError: (error: string) => void,
|
|
|
|
|
options?: GraphRagOptions,
|
|
|
|
|
collection?: string,
|
|
|
|
|
onExplain?: (event: ExplainEvent) => void,
|
|
|
|
|
) {
|
|
|
|
|
const recv = (message: unknown): boolean => {
|
|
|
|
|
const msg = streamingEnvelopeFrom(message);
|
|
|
|
|
if (msg.error !== undefined) {
|
|
|
|
|
onError(msg.error);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resp = msg.response ?? {};
|
|
|
|
|
const responseError = responseErrorMessage(resp);
|
|
|
|
|
if (responseError !== undefined) {
|
|
|
|
|
onError(responseError);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const messageType = stringProperty(resp, "message_type");
|
|
|
|
|
const explainId = stringProperty(resp, "explain_id");
|
|
|
|
|
const explainTriples = explainTriplesFrom(resp);
|
|
|
|
|
if (
|
|
|
|
|
messageType === "explain" &&
|
|
|
|
|
(explainId !== undefined || explainTriples !== undefined)
|
|
|
|
|
) {
|
|
|
|
|
const event: ExplainEvent = {
|
|
|
|
|
explainId: explainId ?? "",
|
|
|
|
|
explainGraph: stringProperty(resp, "explain_graph") ?? "",
|
|
|
|
|
};
|
|
|
|
|
if (explainTriples !== undefined) {
|
|
|
|
|
event.explainTriples = explainTriples;
|
|
|
|
|
}
|
|
|
|
|
onExplain?.(event);
|
|
|
|
|
if (
|
|
|
|
|
stringProperty(resp, "response") === undefined &&
|
|
|
|
|
booleanProperty(resp, "endOfStream") !== true &&
|
|
|
|
|
booleanProperty(resp, "end_of_session") !== true
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const chunk = stringProperty(resp, "response") ?? stringProperty(resp, "chunk") ?? "";
|
|
|
|
|
const complete = streamComplete(msg, resp, ["end_of_session", "endOfStream"]);
|
|
|
|
|
const metadata = complete ? streamingMetadataFrom(resp) : undefined;
|
|
|
|
|
receiver(chunk, complete, metadata);
|
|
|
|
|
return complete;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const request: JsonRecord = {
|
|
|
|
|
query: text,
|
|
|
|
|
user,
|
|
|
|
|
collection: withDefaultCollection(collection ?? "default"),
|
|
|
|
|
streaming: true,
|
|
|
|
|
};
|
|
|
|
|
if (options?.entityLimit !== undefined) request["entity-limit"] = options.entityLimit;
|
|
|
|
|
if (options?.tripleLimit !== undefined) request["triple-limit"] = options.tripleLimit;
|
|
|
|
|
if (options?.maxSubgraphSize !== undefined) request["max-subgraph-size"] = options.maxSubgraphSize;
|
|
|
|
|
if (options?.pathLength !== undefined) request["max-path-length"] = options.pathLength;
|
|
|
|
|
|
|
|
|
|
return yield* dispatchStream("graph-rag", request, flowId, recv, "Graph RAG", onError);
|
|
|
|
|
}),
|
|
|
|
|
documentRagStreaming: Effect.fn("trustgraph.workbench.gateway.flow.documentRagStreaming")(function*(
|
|
|
|
|
text: string,
|
|
|
|
|
receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void,
|
|
|
|
|
onError: (error: string) => void,
|
|
|
|
|
docLimit?: number,
|
|
|
|
|
collection?: string,
|
|
|
|
|
onExplain?: (event: ExplainEvent) => void,
|
|
|
|
|
) {
|
|
|
|
|
const recv = (message: unknown): boolean => {
|
|
|
|
|
const msg = streamingEnvelopeFrom(message);
|
|
|
|
|
if (msg.error !== undefined) {
|
|
|
|
|
onError(msg.error);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resp = msg.response ?? {};
|
|
|
|
|
const responseError = responseErrorMessage(resp);
|
|
|
|
|
if (responseError !== undefined) {
|
|
|
|
|
onError(responseError);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const explainId = stringProperty(resp, "explain_id");
|
|
|
|
|
const explainGraph = stringProperty(resp, "explain_graph");
|
|
|
|
|
if (
|
|
|
|
|
stringProperty(resp, "message_type") === "explain" &&
|
|
|
|
|
explainId !== undefined &&
|
|
|
|
|
explainGraph !== undefined
|
|
|
|
|
) {
|
|
|
|
|
onExplain?.({
|
|
|
|
|
explainId,
|
|
|
|
|
explainGraph,
|
|
|
|
|
});
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const chunk = stringProperty(resp, "response") ?? stringProperty(resp, "chunk") ?? "";
|
|
|
|
|
const complete = streamComplete(msg, resp, ["end_of_session", "endOfStream"]);
|
|
|
|
|
const metadata = complete ? streamingMetadataFrom(resp) : undefined;
|
|
|
|
|
receiver(chunk, complete, metadata);
|
|
|
|
|
return complete;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const request: JsonRecord = {
|
|
|
|
|
query: text,
|
|
|
|
|
user,
|
|
|
|
|
collection: withDefaultCollection(collection ?? "default"),
|
|
|
|
|
streaming: true,
|
|
|
|
|
};
|
|
|
|
|
if (docLimit !== undefined) request["doc-limit"] = docLimit;
|
|
|
|
|
|
|
|
|
|
return yield* dispatchStream("document-rag", request, flowId, recv, "Document RAG", onError);
|
|
|
|
|
}),
|
|
|
|
|
agent: Effect.fn("trustgraph.workbench.gateway.flow.agent")(function*(
|
|
|
|
|
question: string,
|
|
|
|
|
think: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void,
|
|
|
|
|
observe: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void,
|
|
|
|
|
answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void,
|
|
|
|
|
error: (e: string) => void,
|
|
|
|
|
onExplain?: (event: ExplainEvent) => void,
|
|
|
|
|
collection?: string,
|
|
|
|
|
) {
|
|
|
|
|
const recv = (message: unknown): boolean => {
|
|
|
|
|
const msg = streamingEnvelopeFrom(message);
|
|
|
|
|
if (msg.error !== undefined) {
|
|
|
|
|
error(msg.error);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resp = msg.response ?? {};
|
|
|
|
|
const responseError = responseErrorMessage(resp);
|
|
|
|
|
if (stringProperty(resp, "chunk_type") === "error" || responseError !== undefined) {
|
|
|
|
|
error(responseError ?? "Unknown agent error");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const chunkType = stringProperty(resp, "chunk_type");
|
|
|
|
|
const messageType = stringProperty(resp, "message_type");
|
|
|
|
|
const explainId = stringProperty(resp, "explain_id");
|
|
|
|
|
const explainTriples = explainTriplesFrom(resp);
|
|
|
|
|
if (
|
|
|
|
|
(chunkType === "explain" || messageType === "explain") &&
|
|
|
|
|
(explainId !== undefined || explainTriples !== undefined)
|
|
|
|
|
) {
|
|
|
|
|
const event: ExplainEvent = {
|
|
|
|
|
explainId: explainId ?? "",
|
|
|
|
|
explainGraph: stringProperty(resp, "explain_graph") ?? "",
|
|
|
|
|
};
|
|
|
|
|
if (explainTriples !== undefined) {
|
|
|
|
|
event.explainTriples = explainTriples;
|
|
|
|
|
}
|
|
|
|
|
onExplain?.(event);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = stringProperty(resp, "content") ?? "";
|
|
|
|
|
const messageComplete = booleanProperty(resp, "end_of_message") === true;
|
|
|
|
|
const dialogComplete = streamComplete(msg, resp, ["end_of_dialog"]);
|
|
|
|
|
const metadata = dialogComplete ? streamingMetadataFrom(resp) : undefined;
|
|
|
|
|
|
|
|
|
|
Match.value(chunkType).pipe(
|
|
|
|
|
Match.when("thought", () => think(content, messageComplete, metadata)),
|
|
|
|
|
Match.when("observation", () => observe(content, messageComplete, metadata)),
|
|
|
|
|
Match.when("answer", () => answer(content, messageComplete, metadata)),
|
|
|
|
|
Match.when("final-answer", () => answer(content, messageComplete, metadata)),
|
|
|
|
|
Match.when("action", () => undefined),
|
|
|
|
|
Match.orElse(() => undefined),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return dialogComplete;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return yield* dispatchStream(
|
|
|
|
|
"agent",
|
|
|
|
|
{
|
|
|
|
|
question,
|
|
|
|
|
user,
|
|
|
|
|
collection: withDefaultCollection(collection ?? "default"),
|
|
|
|
|
streaming: true,
|
|
|
|
|
},
|
|
|
|
|
flowId,
|
|
|
|
|
recv,
|
|
|
|
|
"Agent",
|
|
|
|
|
error,
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type WorkbenchGatewayApi = ReturnType<typeof makeWorkbenchGatewayApi>;
|
|
|
|
|
|
2026-06-01 16:22:25 -05:00
|
|
|
function queryAtom<A>(
|
|
|
|
|
name: string,
|
2026-06-11 06:29:29 -05:00
|
|
|
fetcher: (get: Atom.AtomContext, api: WorkbenchGatewayApi) => Effect.Effect<A, WorkbenchError, WorkbenchGatewayHttp>,
|
2026-06-01 16:22:25 -05:00
|
|
|
options?: {
|
|
|
|
|
readonly reactivityKeys?: WorkbenchReactivityKeys;
|
|
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
const readQuery = Effect.fn(`trustgraph.workbench.query.${name}`)(function*(get: Atom.AtomContext) {
|
2026-06-11 06:29:29 -05:00
|
|
|
const api = makeWorkbenchGatewayApi(get(settingsAtom));
|
2026-06-01 16:22:25 -05:00
|
|
|
return yield* fetcher(get, api).pipe(
|
|
|
|
|
Effect.tap(() => Metric.update(queryCounter, 1)),
|
|
|
|
|
Effect.tapError((error) => Effect.logError(`[workbench:${name}] query failed`, { error })),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-11 06:29:29 -05:00
|
|
|
const atom = WorkbenchGatewayHttp.runtime.atom((get) => readQuery(get));
|
2026-06-01 16:22:25 -05:00
|
|
|
const reactiveAtom = options?.reactivityKeys === undefined
|
|
|
|
|
? atom
|
|
|
|
|
: workbenchRuntime.factory.withReactivity(options.reactivityKeys)(atom);
|
|
|
|
|
|
|
|
|
|
return Atom.refreshOnWindowFocus(
|
|
|
|
|
reactiveAtom,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function localCommandAtom<Arg, A, R extends WorkbenchRuntimeRequirements = never>(
|
|
|
|
|
name: string,
|
|
|
|
|
run: (arg: Arg, get: Atom.FnContext) => Effect.Effect<A, WorkbenchError, R>,
|
|
|
|
|
options?: CommandOptions<A>,
|
|
|
|
|
) {
|
|
|
|
|
const runCommand = Effect.fn(`trustgraph.workbench.command.${name}`)(function*(arg: Arg, get: Atom.FnContext) {
|
|
|
|
|
return yield* run(arg, get).pipe(
|
|
|
|
|
Effect.tap(() => Metric.update(mutationCounter, 1)),
|
|
|
|
|
Effect.tapError((error) => Effect.logError(`[workbench:${name}] command failed`, { error })),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return workbenchRuntime.fn<Arg>()(runCommand, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commandAtom<Arg, A, R extends WorkbenchRuntimeRequirements = never>(
|
|
|
|
|
name: string,
|
2026-06-11 06:29:29 -05:00
|
|
|
run: (arg: Arg, get: Atom.FnContext, api: WorkbenchGatewayApi) => Effect.Effect<A, WorkbenchError, R | WorkbenchGatewayHttp>,
|
2026-06-01 16:22:25 -05:00
|
|
|
options?: CommandOptions<A>,
|
|
|
|
|
) {
|
|
|
|
|
const runCommand = Effect.fn(`trustgraph.workbench.command.${name}`)(function*(arg: Arg, get: Atom.FnContext) {
|
2026-06-11 06:29:29 -05:00
|
|
|
const api = makeWorkbenchGatewayApi(get(settingsAtom));
|
2026-06-01 16:22:25 -05:00
|
|
|
return yield* run(arg, get, api).pipe(
|
|
|
|
|
Effect.tap(() => Metric.update(mutationCounter, 1)),
|
|
|
|
|
Effect.tapError((error) => Effect.logError(`[workbench:${name}] command failed`, { error })),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-11 06:29:29 -05:00
|
|
|
// Browser-only services like WorkbenchFiles are installed as global layers on
|
|
|
|
|
// workbenchRuntimeFactory, but AtomRuntime's type parameter only tracks the
|
|
|
|
|
// HTTP service layer passed to AtomHttpApi.Service.
|
|
|
|
|
return WorkbenchGatewayHttp.runtime.fn<Arg>()(
|
|
|
|
|
runCommand as (
|
|
|
|
|
arg: Arg,
|
|
|
|
|
get: Atom.FnContext,
|
|
|
|
|
) => Effect.Effect<A, WorkbenchError, WorkbenchHttpAtomRequirements>,
|
|
|
|
|
options,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function gatewayCommandAtom<Arg, A, R extends WorkbenchRuntimeRequirements = never>(
|
|
|
|
|
name: string,
|
|
|
|
|
run: (arg: Arg, get: Atom.FnContext, api: WorkbenchGatewayApi) => Effect.Effect<A, WorkbenchError, R | WorkbenchGatewayHttp | WorkbenchGatewayRpc>,
|
|
|
|
|
options?: CommandOptions<A>,
|
|
|
|
|
) {
|
|
|
|
|
const runCommand = Effect.fn(`trustgraph.workbench.command.${name}`)(function*(arg: Arg, get: Atom.FnContext) {
|
|
|
|
|
const api = makeWorkbenchGatewayApi(get(settingsAtom));
|
|
|
|
|
return yield* run(arg, get, api).pipe(
|
|
|
|
|
Effect.tap(() => Metric.update(mutationCounter, 1)),
|
|
|
|
|
Effect.tapError((error) => Effect.logError(`[workbench:${name}] command failed`, { error })),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return workbenchGatewayRuntime.fn<Arg>()(
|
|
|
|
|
runCommand as (
|
|
|
|
|
arg: Arg,
|
|
|
|
|
get: Atom.FnContext,
|
|
|
|
|
) => Effect.Effect<A, WorkbenchError, WorkbenchGatewayAtomRequirements>,
|
|
|
|
|
options,
|
|
|
|
|
);
|
2026-06-01 16:22:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setActivity(get: Atom.FnContext, label: string, active: boolean): void {
|
|
|
|
|
const current = get(progressActivitiesAtom);
|
|
|
|
|
const next = active
|
|
|
|
|
? [...current.filter((item) => item !== label), label]
|
|
|
|
|
: current.filter((item) => item !== label);
|
|
|
|
|
get.set(progressActivitiesAtom, next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateConversation(get: Atom.FnContext, f: (current: ConversationState) => ConversationState): void {
|
|
|
|
|
const current = get(conversationAtom) as ConversationState;
|
|
|
|
|
const next = f(current);
|
|
|
|
|
get.set(conversationAtom, {
|
|
|
|
|
...next,
|
|
|
|
|
messages: next.messages.filter((message) => message.isStreaming !== true).length > 200
|
|
|
|
|
? next.messages.slice(-200)
|
|
|
|
|
: next.messages,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateLastMessage(get: Atom.FnContext, updater: (prev: ChatMessage) => ChatMessage): void {
|
|
|
|
|
updateConversation(get, (current) => {
|
|
|
|
|
if (current.messages.length === 0) return current;
|
|
|
|
|
const last = current.messages[current.messages.length - 1]!;
|
|
|
|
|
return {
|
|
|
|
|
...current,
|
|
|
|
|
messages: [...current.messages.slice(0, -1), updater(last)],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function refreshConfigAtoms(get: Atom.FnContext): void {
|
|
|
|
|
get.refresh(configAllAtom);
|
|
|
|
|
get.refresh(promptsAtom);
|
|
|
|
|
get.refresh(systemPromptAtom);
|
|
|
|
|
get.refresh(mcpServersAtom);
|
|
|
|
|
get.refresh(mcpToolsAtom);
|
|
|
|
|
get.refresh(tokenCostsAtom);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function withActivity<A, R>(
|
|
|
|
|
get: Atom.FnContext,
|
|
|
|
|
label: string,
|
|
|
|
|
effect: Effect.Effect<A, WorkbenchError, R>,
|
|
|
|
|
): Effect.Effect<A, WorkbenchError, R> {
|
|
|
|
|
return Effect.sync(() => setActivity(get, label, true)).pipe(
|
|
|
|
|
Effect.andThen(effect),
|
|
|
|
|
Effect.ensuring(Effect.sync(() => setActivity(get, label, false))),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Local app state
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export const settingsAtom = Atom.kvs({
|
|
|
|
|
runtime: workbenchRuntime,
|
|
|
|
|
key: "trustgraph-workbench-settings-v1",
|
|
|
|
|
schema: S.toCodecJson(SettingsSchema),
|
|
|
|
|
defaultValue: legacySettings,
|
|
|
|
|
}).pipe(Atom.keepAlive) as Atom.Writable<Settings, Settings>;
|
|
|
|
|
|
|
|
|
|
export const themeAtom = Atom.kvs({
|
|
|
|
|
runtime: workbenchRuntime,
|
|
|
|
|
key: "trustgraph-workbench-theme-v1",
|
|
|
|
|
schema: S.toCodecJson(ThemeSchema),
|
|
|
|
|
defaultValue: defaultTheme,
|
|
|
|
|
}).pipe(Atom.keepAlive) as Atom.Writable<Theme, Theme>;
|
|
|
|
|
|
|
|
|
|
export const flowIdAtom = Atom.kvs({
|
|
|
|
|
runtime: workbenchRuntime,
|
|
|
|
|
key: "trustgraph-workbench-flow-id-v1",
|
|
|
|
|
schema: S.toCodecJson(FlowIdSchema),
|
|
|
|
|
defaultValue: () => "default",
|
|
|
|
|
}).pipe(Atom.keepAlive) as Atom.Writable<string, string>;
|
|
|
|
|
|
|
|
|
|
export const conversationAtom = Atom.kvs({
|
|
|
|
|
runtime: workbenchRuntime,
|
|
|
|
|
key: "trustgraph-workbench-conversation-v1",
|
|
|
|
|
schema: S.toCodecJson(ConversationSchema),
|
|
|
|
|
defaultValue: legacyConversation,
|
|
|
|
|
}).pipe(Atom.keepAlive) as unknown as Atom.Writable<ConversationState, ConversationState>;
|
|
|
|
|
|
|
|
|
|
export const progressActivitiesAtom = Atom.make<string[]>([]).pipe(Atom.keepAlive);
|
|
|
|
|
export const isLoadingAtom = Atom.make((get) => get(progressActivitiesAtom).length > 0);
|
|
|
|
|
|
|
|
|
|
export const notificationsAtom = Atom.make<Notification[]>([]).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const setSettingsFieldAtom = Atom.writable(
|
|
|
|
|
() => null,
|
|
|
|
|
(ctx, update: { key: keyof Settings; value: Settings[keyof Settings] }) => {
|
|
|
|
|
ctx.set(settingsAtom, {
|
|
|
|
|
...ctx.get(settingsAtom),
|
|
|
|
|
[update.key]: update.value,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const updateFeatureSwitchesAtom = Atom.writable(
|
|
|
|
|
() => null,
|
|
|
|
|
(ctx, partial: Partial<FeatureSwitches>) => {
|
|
|
|
|
const settings = ctx.get(settingsAtom);
|
|
|
|
|
ctx.set(settingsAtom, {
|
|
|
|
|
...settings,
|
|
|
|
|
featureSwitches: {
|
|
|
|
|
...settings.featureSwitches,
|
|
|
|
|
...partial,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const setConversationInputAtom = Atom.writable(
|
|
|
|
|
() => null,
|
|
|
|
|
(ctx, input: string) => {
|
|
|
|
|
ctx.set(conversationAtom, { ...ctx.get(conversationAtom), input });
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const setChatModeAtom = Atom.writable(
|
|
|
|
|
() => null,
|
|
|
|
|
(ctx, chatMode: ChatMode) => {
|
|
|
|
|
ctx.set(conversationAtom, { ...ctx.get(conversationAtom), chatMode });
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const deleteMessageAtom = Atom.writable(
|
|
|
|
|
() => null,
|
|
|
|
|
(ctx, id: string) => {
|
|
|
|
|
const current = ctx.get(conversationAtom);
|
|
|
|
|
ctx.set(conversationAtom, {
|
|
|
|
|
...current,
|
|
|
|
|
messages: current.messages.filter((message) => message.id !== id),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const clearMessagesAtom = Atom.writable(
|
|
|
|
|
() => null,
|
|
|
|
|
(ctx) => {
|
|
|
|
|
ctx.set(conversationAtom, { ...ctx.get(conversationAtom), messages: [] });
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const pushNotificationAtom = localCommandAtom<Omit<Notification, "id">, void>(
|
|
|
|
|
"pushNotification",
|
|
|
|
|
Effect.fn("trustgraph.workbench.pushNotification")(function*(input, get) {
|
2026-06-01 23:19:54 -05:00
|
|
|
const id = yield* randomId("notif");
|
2026-06-01 16:22:25 -05:00
|
|
|
const notification: Notification = { id, ...input };
|
|
|
|
|
get.set(notificationsAtom, [...get(notificationsAtom), notification]);
|
|
|
|
|
yield* Effect.sleep("5 seconds");
|
|
|
|
|
get.set(notificationsAtom, get(notificationsAtom).filter((item) => item.id !== id));
|
|
|
|
|
}),
|
|
|
|
|
{ concurrent: true },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const removeNotificationAtom = Atom.writable(
|
|
|
|
|
() => null,
|
|
|
|
|
(ctx, id: string) => {
|
|
|
|
|
ctx.set(notificationsAtom, ctx.get(notificationsAtom).filter((item) => item.id !== id));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const themeClassAtom = Atom.make((get) => {
|
|
|
|
|
const theme = get(themeAtom);
|
|
|
|
|
if (typeof document !== "undefined") {
|
|
|
|
|
document.documentElement.classList.toggle("light", theme === "light");
|
|
|
|
|
document.body.classList.toggle("light", theme === "light");
|
|
|
|
|
document.body.classList.toggle("dark", theme === "dark");
|
|
|
|
|
}
|
|
|
|
|
return theme;
|
|
|
|
|
}).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const toggleThemeAtom = Atom.writable(
|
|
|
|
|
() => null,
|
|
|
|
|
(ctx) => {
|
|
|
|
|
ctx.set(themeAtom, ctx.get(themeAtom) === "dark" ? "light" : "dark");
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Socket lifecycle
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const liveApiFactory: WorkbenchApiFactory = {
|
|
|
|
|
create: (settings) =>
|
2026-06-11 06:29:29 -05:00
|
|
|
makeBaseApi(
|
2026-06-01 16:22:25 -05:00
|
|
|
settings.user,
|
|
|
|
|
settings.apiKey.length > 0 ? settings.apiKey : undefined,
|
|
|
|
|
settings.gatewayUrl.length > 0 ? settings.gatewayUrl : undefined,
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const apiFactoryAtom = Atom.make<WorkbenchApiFactory>(liveApiFactory).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const apiAtom = Atom.make((get) => {
|
|
|
|
|
const settings = get(settingsAtom);
|
|
|
|
|
const api = get(apiFactoryAtom).create(settings);
|
|
|
|
|
get.addFinalizer(() => api.close());
|
|
|
|
|
return api;
|
|
|
|
|
}).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const connectionStateAtom = Atom.make((get) => {
|
|
|
|
|
const api = get(apiAtom);
|
|
|
|
|
const fallback: ConnectionState = {
|
|
|
|
|
status: "connecting",
|
|
|
|
|
hasApiKey: get(settingsAtom).apiKey.length > 0,
|
|
|
|
|
};
|
|
|
|
|
const previous = Option.getOrElse(get.self<ConnectionState>(), () => fallback);
|
|
|
|
|
const unsubscribe = api.onConnectionStateChange((state) => get.setSelf(state));
|
|
|
|
|
get.addFinalizer(unsubscribe);
|
|
|
|
|
return previous;
|
|
|
|
|
}).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Remote cached read atoms
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export const flowsAtom = queryAtom(
|
|
|
|
|
"flows",
|
|
|
|
|
Effect.fn("trustgraph.workbench.flows")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
const ids = yield* api.flows().getFlows();
|
2026-06-01 16:22:25 -05:00
|
|
|
return yield* Effect.all(
|
|
|
|
|
ids.map((id) =>
|
2026-06-11 06:29:29 -05:00
|
|
|
api.flows().getFlow(id).pipe(
|
2026-06-01 16:22:25 -05:00
|
|
|
Effect.map((definition) => ({
|
|
|
|
|
id,
|
|
|
|
|
...(typeof definition === "object" && definition !== null ? definition : {}),
|
|
|
|
|
}) as FlowSummary),
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
{ concurrency: 4 },
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["flows"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("5 minutes"));
|
|
|
|
|
|
|
|
|
|
export const flowBlueprintsAtom = queryAtom(
|
|
|
|
|
"flowBlueprints",
|
|
|
|
|
Effect.fn("trustgraph.workbench.flowBlueprints")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
const list = yield* api.flows().getFlowBlueprints();
|
2026-06-01 16:22:25 -05:00
|
|
|
return list ?? [];
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["flows", "flow-blueprints"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("10 minutes"));
|
|
|
|
|
|
|
|
|
|
export const flowBlueprintAtom = Atom.family((name: string) =>
|
|
|
|
|
queryAtom(
|
|
|
|
|
`flowBlueprint.${name}`,
|
|
|
|
|
Effect.fn("trustgraph.workbench.flowBlueprint")(function*(_get, api) {
|
|
|
|
|
if (name.length === 0) return null;
|
2026-06-11 06:29:29 -05:00
|
|
|
return yield* api.flows().getFlowBlueprint(name);
|
2026-06-01 16:22:25 -05:00
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["flow-blueprint", name] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("10 minutes")),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const configAllAtom = queryAtom(
|
|
|
|
|
"configAll",
|
|
|
|
|
Effect.fn("trustgraph.workbench.configAll")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
return yield* api.config().getConfigAll();
|
2026-06-01 16:22:25 -05:00
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["config"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes"));
|
|
|
|
|
|
|
|
|
|
export const promptsAtom = queryAtom(
|
|
|
|
|
"prompts",
|
|
|
|
|
Effect.fn("trustgraph.workbench.prompts")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
return yield* api.config().getPrompts();
|
2026-06-01 16:22:25 -05:00
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["config", "prompts"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes"));
|
|
|
|
|
|
|
|
|
|
export const systemPromptAtom = queryAtom(
|
|
|
|
|
"systemPrompt",
|
|
|
|
|
Effect.fn("trustgraph.workbench.systemPrompt")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
const prompt = yield* api.config().getSystemPrompt();
|
2026-06-01 16:22:25 -05:00
|
|
|
return typeof prompt === "string" ? prompt : encodeJsonUnknownString(prompt);
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["config", "system-prompt"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes"));
|
|
|
|
|
|
|
|
|
|
export const promptDetailAtom = Atom.family((id: string) =>
|
|
|
|
|
queryAtom(
|
|
|
|
|
`prompt.${id}`,
|
|
|
|
|
Effect.fn("trustgraph.workbench.promptDetail")(function*(_get, api) {
|
|
|
|
|
if (id.length === 0) return null;
|
2026-06-11 06:29:29 -05:00
|
|
|
return yield* api.config().getPrompt(id);
|
2026-06-01 16:22:25 -05:00
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["config", "prompt", id] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes")),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const tokenCostsAtom = queryAtom(
|
|
|
|
|
"tokenCosts",
|
|
|
|
|
Effect.fn("trustgraph.workbench.tokenCosts")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
const data = yield* api.config().getTokenCosts();
|
2026-06-01 16:22:25 -05:00
|
|
|
return Array.isArray(data)
|
|
|
|
|
? data.map((item: Record<string, unknown>) => ({
|
|
|
|
|
model: String(item.model ?? ""),
|
|
|
|
|
input_price: Number(item.input_price ?? 0),
|
|
|
|
|
output_price: Number(item.output_price ?? 0),
|
|
|
|
|
}))
|
|
|
|
|
: [];
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["config", "token-costs"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes"));
|
|
|
|
|
|
|
|
|
|
export const mcpServersAtom = queryAtom(
|
|
|
|
|
"mcpServers",
|
|
|
|
|
Effect.fn("trustgraph.workbench.mcpServers")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
const values = yield* api.config().getValues("mcp");
|
2026-06-01 16:22:25 -05:00
|
|
|
return parseConfigEntries<McpServerEntry>(values, "MCP server config");
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["config", "mcp"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes"));
|
|
|
|
|
|
|
|
|
|
export const mcpToolsAtom = queryAtom(
|
|
|
|
|
"mcpTools",
|
|
|
|
|
Effect.fn("trustgraph.workbench.mcpTools")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
const values = yield* api.config().getValues("tool");
|
2026-06-01 16:22:25 -05:00
|
|
|
return parseConfigEntries<ToolEntry>(values, "tool config");
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["config", "tool"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes"));
|
|
|
|
|
|
|
|
|
|
export const libraryDocumentsAtom = queryAtom(
|
|
|
|
|
"libraryDocuments",
|
|
|
|
|
Effect.fn("trustgraph.workbench.libraryDocuments")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
return yield* api.librarian().getDocuments();
|
2026-06-01 16:22:25 -05:00
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["library", "documents"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("1 minute"));
|
|
|
|
|
|
|
|
|
|
export const libraryProcessingAtom = queryAtom(
|
|
|
|
|
"libraryProcessing",
|
|
|
|
|
Effect.fn("trustgraph.workbench.libraryProcessing")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
return (yield* api.librarian().getProcessing()) as ProcessingMetadata[];
|
2026-06-01 16:22:25 -05:00
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["library", "processing"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("30 seconds"));
|
|
|
|
|
|
|
|
|
|
export const documentMetadataAtom = Atom.family((documentId: string) =>
|
|
|
|
|
queryAtom(
|
|
|
|
|
`documentMetadata.${documentId}`,
|
|
|
|
|
Effect.fn("trustgraph.workbench.documentMetadata")(function*(_get, api) {
|
|
|
|
|
if (documentId.length === 0) return null;
|
2026-06-11 06:29:29 -05:00
|
|
|
return yield* api.librarian().getDocumentMetadata(documentId);
|
2026-06-01 16:22:25 -05:00
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["library", "document", documentId] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("5 minutes")),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const kgCoresAtom = queryAtom(
|
|
|
|
|
"kgCores",
|
|
|
|
|
Effect.fn("trustgraph.workbench.kgCores")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
return yield* api.knowledge().getKnowledgeCores();
|
2026-06-01 16:22:25 -05:00
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["knowledge", "kg-cores"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes"));
|
|
|
|
|
|
|
|
|
|
export const deCoresAtom = queryAtom(
|
|
|
|
|
"deCores",
|
|
|
|
|
Effect.fn("trustgraph.workbench.deCores")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
return yield* api.knowledge().getDocumentEmbeddingCores();
|
2026-06-01 16:22:25 -05:00
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["knowledge", "de-cores"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes"));
|
|
|
|
|
|
|
|
|
|
export const collectionsAtom = queryAtom(
|
|
|
|
|
"collections",
|
|
|
|
|
Effect.fn("trustgraph.workbench.collections")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
const collections = (yield* api.collectionManagement().listCollections()) as CollectionSummary[];
|
2026-06-01 16:22:25 -05:00
|
|
|
const list = Array.isArray(collections) ? [...collections] : [];
|
|
|
|
|
const hasDefault = list.some((item) => (item.collection ?? item.id ?? item.name) === "default");
|
|
|
|
|
if (!hasDefault) list.unshift({ id: "default", collection: "default", name: "default" });
|
|
|
|
|
return list;
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["collections"] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("2 minutes"));
|
|
|
|
|
|
|
|
|
|
export interface GraphTriplesInput {
|
|
|
|
|
readonly flowId: string;
|
|
|
|
|
readonly collection: string;
|
|
|
|
|
readonly limit: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ExplainTriplesInput {
|
|
|
|
|
readonly events: ExplainEvent[];
|
|
|
|
|
readonly flowId: string;
|
|
|
|
|
readonly collection: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const atomFamilyKeySeparator = "\u001f";
|
|
|
|
|
const explainGraphSeparator = "\u001e";
|
2026-06-04 07:49:58 -05:00
|
|
|
const explainTriplesInputs = MutableHashMap.empty<string, ExplainTriplesInput>();
|
2026-06-01 16:22:25 -05:00
|
|
|
|
|
|
|
|
function graphTriplesKey(input: GraphTriplesInput): string {
|
|
|
|
|
return [input.flowId, input.collection, String(input.limit)].join(atomFamilyKeySeparator);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseGraphTriplesKey(key: string): GraphTriplesInput {
|
|
|
|
|
const [flowId = "", collection = "", limit = "250"] = key.split(atomFamilyKeySeparator);
|
|
|
|
|
const parsedLimit = Number(limit);
|
|
|
|
|
return {
|
|
|
|
|
flowId,
|
|
|
|
|
collection,
|
|
|
|
|
limit: Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 250,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function explainTriplesKey(input: ExplainTriplesInput): string {
|
|
|
|
|
const graphs = input.events
|
|
|
|
|
.map((event) => event.explainGraph ?? event.explainId ?? "")
|
|
|
|
|
.filter((id) => id.length > 0)
|
|
|
|
|
.sort()
|
|
|
|
|
.join(explainGraphSeparator);
|
|
|
|
|
return [input.flowId, input.collection, graphs].join(atomFamilyKeySeparator);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const graphTriplesAtomByKey = Atom.family((key: string) => {
|
|
|
|
|
const input = parseGraphTriplesKey(key);
|
|
|
|
|
return queryAtom(
|
|
|
|
|
`graphTriples.${input.flowId}.${input.collection}.${input.limit}`,
|
|
|
|
|
Effect.fn("trustgraph.workbench.graphTriples")(function*(_get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
return yield* (
|
2026-06-01 16:22:25 -05:00
|
|
|
api.flow(input.flowId).triplesQuery(undefined, undefined, undefined, input.limit, input.collection)
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["graph", input.flowId, input.collection] },
|
|
|
|
|
).pipe(Atom.setIdleTTL("1 minute"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const graphTriplesAtom = (input: GraphTriplesInput) => graphTriplesAtomByKey(graphTriplesKey(input));
|
|
|
|
|
|
|
|
|
|
const explainTriplesAtomByKey = Atom.family((key: string) =>
|
|
|
|
|
queryAtom(
|
|
|
|
|
`explainTriples.${key}`,
|
|
|
|
|
Effect.fn("trustgraph.workbench.explainTriples")(function*(_get, api) {
|
2026-06-04 07:49:58 -05:00
|
|
|
const input = Option.getOrUndefined(MutableHashMap.get(explainTriplesInputs, key));
|
2026-06-01 16:22:25 -05:00
|
|
|
if (input === undefined) return [];
|
2026-06-04 07:49:58 -05:00
|
|
|
const inlineTriples = input.events.flatMap((event): ReadonlyArray<Triple> => event.explainTriples ?? []);
|
|
|
|
|
if (inlineTriples.length > 0) return [...inlineTriples];
|
2026-06-01 16:22:25 -05:00
|
|
|
const graphUris = input.events.filter(
|
|
|
|
|
(event): event is ExplainEvent & { explainGraph: string } =>
|
|
|
|
|
event.explainGraph !== undefined && event.explainGraph.length > 0,
|
|
|
|
|
);
|
|
|
|
|
const results = yield* Effect.all(
|
|
|
|
|
graphUris.map((event) =>
|
2026-06-11 06:29:29 -05:00
|
|
|
(
|
2026-06-01 16:22:25 -05:00
|
|
|
api.flow(input.flowId)
|
|
|
|
|
.triplesQuery(undefined, undefined, undefined, 500, input.collection, event.explainGraph)
|
2026-06-04 07:49:58 -05:00
|
|
|
).pipe(Effect.orElseSucceed((): Array<Triple> => []))
|
2026-06-01 16:22:25 -05:00
|
|
|
),
|
|
|
|
|
{ concurrency: 4 },
|
|
|
|
|
);
|
|
|
|
|
return results.flat();
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
reactivityKeys: [
|
|
|
|
|
"graph",
|
|
|
|
|
key.split(atomFamilyKeySeparator)[0] ?? "",
|
|
|
|
|
key.split(atomFamilyKeySeparator)[1] ?? "",
|
|
|
|
|
"explain",
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
).pipe(Atom.setIdleTTL("5 minutes")),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const explainTriplesAtom = (input: ExplainTriplesInput) => {
|
|
|
|
|
const key = explainTriplesKey(input);
|
2026-06-04 07:49:58 -05:00
|
|
|
MutableHashMap.set(explainTriplesInputs, key, input);
|
2026-06-01 16:22:25 -05:00
|
|
|
return explainTriplesAtomByKey(key);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// View-local atoms
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export const uploadDialogOpenAtom = Atom.make(false).pipe(Atom.keepAlive);
|
|
|
|
|
export const uploadFormAtom = Atom.make<UploadForm>(defaultUploadForm()).pipe(Atom.keepAlive);
|
|
|
|
|
export const librarySearchAtom = Atom.make("").pipe(Atom.keepAlive);
|
|
|
|
|
export const libraryDeleteTargetAtom = Atom.make<DocumentMetadata | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
export const libraryDetailTargetAtom = Atom.make<DocumentMetadata | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const mcpActiveTabAtom = Atom.make<"servers" | "tools">("servers").pipe(Atom.keepAlive);
|
|
|
|
|
export const mcpServerDialogAtom = Atom.make<McpServerEntry | "new" | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
export const mcpToolDialogAtom = Atom.make<ToolEntry | "new" | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
export const mcpDeleteTargetAtom = Atom.make<{ type: "server" | "tool"; key: string } | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
export const mcpServerFormAtom = Atom.make<McpServerForm>({
|
|
|
|
|
key: "",
|
|
|
|
|
url: "",
|
|
|
|
|
remoteName: "",
|
|
|
|
|
authToken: "",
|
|
|
|
|
showToken: false,
|
|
|
|
|
saving: false,
|
|
|
|
|
keyError: "",
|
|
|
|
|
}).pipe(Atom.keepAlive);
|
|
|
|
|
export const mcpToolFormAtom = Atom.make<McpToolForm>({
|
|
|
|
|
key: "",
|
|
|
|
|
name: "",
|
|
|
|
|
description: "",
|
|
|
|
|
mcpTool: "",
|
|
|
|
|
group: "default",
|
|
|
|
|
args: [],
|
|
|
|
|
saving: false,
|
|
|
|
|
keyError: "",
|
|
|
|
|
}).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const knowledgeDeleteTargetAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
export const activeActionAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const flowsStartDialogOpenAtom = Atom.make(false).pipe(Atom.keepAlive);
|
|
|
|
|
export const startFlowFormAtom = Atom.make<StartFlowForm>(defaultStartFlowForm()).pipe(Atom.keepAlive);
|
|
|
|
|
export const flowExpandedAtom = Atom.make<Record<string, boolean>>({}).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const settingsShowApiKeyAtom = Atom.make(false).pipe(Atom.keepAlive);
|
|
|
|
|
export const createCollectionDialogOpenAtom = Atom.make(false).pipe(Atom.keepAlive);
|
|
|
|
|
export const deleteCollectionDialogOpenAtom = Atom.make(false).pipe(Atom.keepAlive);
|
|
|
|
|
export const collectionFormAtom = Atom.make<CollectionForm>(defaultCollectionForm()).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const promptActiveTabAtom = Atom.make<"templates" | "system">("templates").pipe(Atom.keepAlive);
|
|
|
|
|
export const selectedPromptIdAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const graphViewAtom = Atom.make<GraphViewState>({
|
|
|
|
|
searchTerm: "",
|
|
|
|
|
selectedNodeId: null,
|
|
|
|
|
selectedNodeLabel: null,
|
|
|
|
|
showLabels: true,
|
|
|
|
|
showTypes: true,
|
|
|
|
|
nodeLimit: 250,
|
|
|
|
|
}).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
export const agentPhaseExpandedAtom = Atom.make<Record<string, boolean>>({}).pipe(Atom.keepAlive);
|
|
|
|
|
export const copiedMessageIdAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
export const activeChatRequestAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Mutations
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export const refreshWorkbenchAtom = commandAtom<"all" | "config" | "flows" | "library" | "knowledge", void, Reactivity.Reactivity>(
|
|
|
|
|
"refreshWorkbench",
|
|
|
|
|
Effect.fn("trustgraph.workbench.refreshWorkbench")(function*(target, get) {
|
|
|
|
|
if (target === "all" || target === "flows") get.refresh(flowsAtom);
|
|
|
|
|
if (target === "all" || target === "config") refreshConfigAtoms(get);
|
|
|
|
|
if (target === "all" || target === "library") {
|
|
|
|
|
get.refresh(libraryDocumentsAtom);
|
|
|
|
|
get.refresh(libraryProcessingAtom);
|
|
|
|
|
}
|
|
|
|
|
if (target === "all" || target === "knowledge") {
|
|
|
|
|
get.refresh(kgCoresAtom);
|
|
|
|
|
get.refresh(deCoresAtom);
|
|
|
|
|
}
|
|
|
|
|
get.refresh(collectionsAtom);
|
|
|
|
|
yield* Reactivity.invalidate([target]);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const copyMessageAtom = localCommandAtom<
|
|
|
|
|
{ readonly id: string; readonly content: string },
|
|
|
|
|
void,
|
|
|
|
|
BrowserClipboard.Clipboard
|
|
|
|
|
>(
|
|
|
|
|
"copyMessage",
|
|
|
|
|
Effect.fn("trustgraph.workbench.copyMessage")(function*({ id, content }, get) {
|
|
|
|
|
const clipboard = yield* BrowserClipboard.Clipboard;
|
|
|
|
|
const copied = yield* clipboard.writeString(content).pipe(
|
|
|
|
|
Effect.as(true),
|
|
|
|
|
Effect.catch((error) =>
|
|
|
|
|
Effect.sync(() => {
|
|
|
|
|
get.set(pushNotificationAtom, {
|
|
|
|
|
type: "error",
|
|
|
|
|
title: "Copy failed",
|
|
|
|
|
description: errorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
}).pipe(Effect.as(false))
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
if (!copied) return;
|
|
|
|
|
get.set(copiedMessageIdAtom, id);
|
|
|
|
|
yield* Effect.sleep("2 seconds");
|
|
|
|
|
if (get(copiedMessageIdAtom) === id) {
|
|
|
|
|
get.set(copiedMessageIdAtom, null);
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
{ concurrent: true },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const startFlowAtom = commandAtom<
|
|
|
|
|
{ id: string; blueprint: string; description: string; parameters: Record<string, unknown> },
|
|
|
|
|
void
|
|
|
|
|
>("startFlow", Effect.fn("trustgraph.workbench.startFlow")(function*(input, get, api) {
|
|
|
|
|
yield* withActivity(
|
|
|
|
|
get,
|
|
|
|
|
`Start flow ${input.id}`,
|
2026-06-11 06:29:29 -05:00
|
|
|
api.flows().startFlow(input.id, input.blueprint, input.description, input.parameters).pipe(
|
2026-06-01 16:22:25 -05:00
|
|
|
Effect.tap(() => Effect.sync(() => {
|
|
|
|
|
get.refresh(flowsAtom);
|
|
|
|
|
get.set(flowIdAtom, input.id);
|
|
|
|
|
get.set(pushNotificationAtom, {
|
|
|
|
|
type: "success",
|
|
|
|
|
title: "Flow started",
|
|
|
|
|
description: `"${input.id}" is now active.`,
|
|
|
|
|
});
|
|
|
|
|
})),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}), { reactivityKeys: ["flows"] });
|
|
|
|
|
|
|
|
|
|
export const stopFlowAtom = commandAtom<string, void>("stopFlow", Effect.fn("trustgraph.workbench.stopFlow")(function*(id, get, api) {
|
|
|
|
|
yield* withActivity(
|
|
|
|
|
get,
|
|
|
|
|
`Stop flow ${id}`,
|
2026-06-11 06:29:29 -05:00
|
|
|
api.flows().stopFlow(id).pipe(
|
2026-06-01 16:22:25 -05:00
|
|
|
Effect.tap(() => Effect.sync(() => {
|
|
|
|
|
get.refresh(flowsAtom);
|
|
|
|
|
get.set(pushNotificationAtom, {
|
|
|
|
|
type: "success",
|
|
|
|
|
title: "Flow stopped",
|
|
|
|
|
description: `"${id}" has been stopped.`,
|
|
|
|
|
});
|
|
|
|
|
})),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}), { reactivityKeys: ["flows"] });
|
|
|
|
|
|
|
|
|
|
export const saveMcpServerAtom = commandAtom<{ key: string; config: McpServerConfig }, void>(
|
|
|
|
|
"saveMcpServer",
|
|
|
|
|
Effect.fn("trustgraph.workbench.saveMcpServer")(function*({ key, config }, get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* api.config().putConfig([{ type: "mcp", key, value: encodeJsonUnknownString(config) }]);
|
2026-06-01 16:22:25 -05:00
|
|
|
get.refresh(mcpServersAtom);
|
|
|
|
|
get.refresh(configAllAtom);
|
|
|
|
|
get.set(pushNotificationAtom, { type: "success", title: "MCP server saved", description: key });
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["config", "mcp"] },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const deleteMcpServerAtom = commandAtom<string, void>("deleteMcpServer", Effect.fn("trustgraph.workbench.deleteMcpServer")(function*(key, get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* api.config().deleteConfig({ type: "mcp", key });
|
2026-06-01 16:22:25 -05:00
|
|
|
get.refresh(mcpServersAtom);
|
|
|
|
|
get.refresh(configAllAtom);
|
|
|
|
|
get.set(pushNotificationAtom, { type: "success", title: "MCP server deleted", description: key });
|
|
|
|
|
}), { reactivityKeys: ["config", "mcp"] });
|
|
|
|
|
|
|
|
|
|
export const saveMcpToolAtom = commandAtom<{ key: string; config: ToolConfig }, void>(
|
|
|
|
|
"saveMcpTool",
|
|
|
|
|
Effect.fn("trustgraph.workbench.saveMcpTool")(function*({ key, config }, get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* api.config().putConfig([{ type: "tool", key, value: encodeJsonUnknownString(config) }]);
|
2026-06-01 16:22:25 -05:00
|
|
|
get.refresh(mcpToolsAtom);
|
|
|
|
|
get.refresh(configAllAtom);
|
|
|
|
|
get.set(pushNotificationAtom, { type: "success", title: "Tool saved", description: key });
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["config", "tool"] },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const deleteMcpToolAtom = commandAtom<string, void>("deleteMcpTool", Effect.fn("trustgraph.workbench.deleteMcpTool")(function*(key, get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* api.config().deleteConfig({ type: "tool", key });
|
2026-06-01 16:22:25 -05:00
|
|
|
get.refresh(mcpToolsAtom);
|
|
|
|
|
get.refresh(configAllAtom);
|
|
|
|
|
get.set(pushNotificationAtom, { type: "success", title: "Tool deleted", description: key });
|
|
|
|
|
}), { reactivityKeys: ["config", "tool"] });
|
|
|
|
|
|
|
|
|
|
const chunkedUploadThreshold = 1_000_000;
|
|
|
|
|
|
|
|
|
|
export interface UploadDocumentInput {
|
|
|
|
|
readonly base64: string;
|
|
|
|
|
readonly mimeType: string;
|
|
|
|
|
readonly title: string;
|
|
|
|
|
readonly comments: string;
|
|
|
|
|
readonly tags: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const uploadDocumentEffect = Effect.fn("trustgraph.workbench.uploadDocument.effect")(function*(
|
|
|
|
|
input: UploadDocumentInput,
|
|
|
|
|
get: Atom.FnContext,
|
2026-06-11 06:29:29 -05:00
|
|
|
api: WorkbenchGatewayApi,
|
2026-06-01 16:22:25 -05:00
|
|
|
) {
|
|
|
|
|
yield* withActivity(
|
|
|
|
|
get,
|
|
|
|
|
"Upload document",
|
2026-06-11 06:29:29 -05:00
|
|
|
api.librarian().loadDocument(input.base64, input.mimeType, input.title, input.comments, input.tags).pipe(
|
2026-06-01 16:22:25 -05:00
|
|
|
Effect.tap(() => Effect.sync(() => {
|
|
|
|
|
get.refresh(libraryDocumentsAtom);
|
|
|
|
|
get.refresh(libraryProcessingAtom);
|
|
|
|
|
get.set(pushNotificationAtom, {
|
|
|
|
|
type: "success",
|
|
|
|
|
title: "Document uploaded",
|
|
|
|
|
description: `"${input.title}" is being processed.`,
|
|
|
|
|
});
|
|
|
|
|
})),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const uploadDocumentChunkedEffect = Effect.fn("trustgraph.workbench.uploadDocumentChunked.effect")(function*(
|
|
|
|
|
input: UploadDocumentInput,
|
|
|
|
|
get: Atom.FnContext,
|
2026-06-11 06:29:29 -05:00
|
|
|
api: WorkbenchGatewayApi,
|
2026-06-01 16:22:25 -05:00
|
|
|
) {
|
|
|
|
|
yield* withActivity(get, "Upload document (chunked)", Effect.gen(function*() {
|
|
|
|
|
const totalSize = input.base64.length;
|
|
|
|
|
get.set(uploadFormAtom, { ...get(uploadFormAtom), progress: {
|
|
|
|
|
phase: "preparing",
|
|
|
|
|
chunksTotal: 0,
|
|
|
|
|
chunksUploaded: 0,
|
|
|
|
|
bytesTotal: totalSize,
|
|
|
|
|
bytesUploaded: 0,
|
|
|
|
|
} });
|
|
|
|
|
const lib = api.librarian();
|
2026-06-01 23:19:54 -05:00
|
|
|
const documentId = yield* randomId("upload");
|
|
|
|
|
const timestamp = yield* Clock.currentTimeMillis;
|
2026-06-11 06:29:29 -05:00
|
|
|
const beginResp = yield* lib.beginUpload({
|
2026-06-01 23:19:54 -05:00
|
|
|
id: documentId,
|
|
|
|
|
time: Math.floor(timestamp / 1000),
|
2026-06-01 16:22:25 -05:00
|
|
|
kind: input.mimeType,
|
|
|
|
|
title: input.title,
|
|
|
|
|
comments: input.comments,
|
|
|
|
|
tags: input.tags,
|
|
|
|
|
user: get(settingsAtom).user,
|
2026-06-11 06:29:29 -05:00
|
|
|
}, totalSize);
|
2026-06-01 16:22:25 -05:00
|
|
|
const uploadId = beginResp["upload-id"];
|
|
|
|
|
const chunkSize = beginResp["chunk-size"];
|
|
|
|
|
const totalChunks = beginResp["total-chunks"];
|
|
|
|
|
let bytesUploaded = 0;
|
|
|
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
|
|
|
const start = i * chunkSize;
|
|
|
|
|
const end = Math.min(start + chunkSize, totalSize);
|
|
|
|
|
const chunk = input.base64.slice(start, end);
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* lib.uploadChunk(uploadId, i, chunk);
|
2026-06-01 16:22:25 -05:00
|
|
|
bytesUploaded += chunk.length;
|
|
|
|
|
get.set(uploadFormAtom, { ...get(uploadFormAtom), progress: {
|
|
|
|
|
phase: "uploading",
|
|
|
|
|
chunksTotal: totalChunks,
|
|
|
|
|
chunksUploaded: i + 1,
|
|
|
|
|
bytesTotal: totalSize,
|
|
|
|
|
bytesUploaded,
|
|
|
|
|
} });
|
|
|
|
|
}
|
|
|
|
|
get.set(uploadFormAtom, { ...get(uploadFormAtom), progress: {
|
|
|
|
|
phase: "finalizing",
|
|
|
|
|
chunksTotal: totalChunks,
|
|
|
|
|
chunksUploaded: totalChunks,
|
|
|
|
|
bytesTotal: totalSize,
|
|
|
|
|
bytesUploaded: totalSize,
|
|
|
|
|
} });
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* lib.completeUpload(uploadId);
|
2026-06-01 16:22:25 -05:00
|
|
|
get.refresh(libraryDocumentsAtom);
|
|
|
|
|
get.refresh(libraryProcessingAtom);
|
|
|
|
|
get.set(pushNotificationAtom, {
|
|
|
|
|
type: "success",
|
|
|
|
|
title: "Document uploaded",
|
|
|
|
|
description: `"${input.title}" is being processed.`,
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const uploadDocumentAtom = commandAtom<UploadDocumentInput, void>(
|
|
|
|
|
"uploadDocument",
|
|
|
|
|
Effect.fn("trustgraph.workbench.uploadDocument")(function*(input, get, api) {
|
|
|
|
|
yield* uploadDocumentEffect(input, get, api);
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["library"] },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const uploadDocumentChunkedAtom = commandAtom<UploadDocumentInput, void>(
|
|
|
|
|
"uploadDocumentChunked",
|
|
|
|
|
Effect.fn("trustgraph.workbench.uploadDocumentChunked")(function*(input, get, api) {
|
|
|
|
|
yield* uploadDocumentChunkedEffect(input, get, api);
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["library"] },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const submitUploadDocumentAtom = commandAtom<void, void, WorkbenchFiles>(
|
|
|
|
|
"submitUploadDocument",
|
|
|
|
|
Effect.fn("trustgraph.workbench.submitUploadDocument")(function*(_void, get, api) {
|
|
|
|
|
const form = get(uploadFormAtom);
|
|
|
|
|
if (form.file === null || form.uploading) return;
|
|
|
|
|
const title = form.title.trim();
|
|
|
|
|
if (title.length === 0) return;
|
|
|
|
|
const file = form.file;
|
|
|
|
|
get.set(uploadFormAtom, { ...form, uploading: true, progress: null });
|
|
|
|
|
|
|
|
|
|
const submit = Effect.gen(function*() {
|
|
|
|
|
const files = yield* WorkbenchFiles;
|
|
|
|
|
const base64 = yield* files.readAsBase64(file);
|
|
|
|
|
const input: UploadDocumentInput = {
|
|
|
|
|
base64,
|
|
|
|
|
mimeType: file.type.length > 0 ? file.type : "application/octet-stream",
|
|
|
|
|
title,
|
|
|
|
|
comments: form.comments,
|
|
|
|
|
tags: form.tags.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0),
|
|
|
|
|
};
|
|
|
|
|
if (base64.length > chunkedUploadThreshold) {
|
|
|
|
|
yield* uploadDocumentChunkedEffect(input, get, api);
|
|
|
|
|
} else {
|
|
|
|
|
yield* uploadDocumentEffect(input, get, api);
|
|
|
|
|
}
|
|
|
|
|
get.set(uploadFormAtom, defaultUploadForm());
|
|
|
|
|
get.set(uploadDialogOpenAtom, false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
yield* submit.pipe(
|
|
|
|
|
Effect.tapError((error) =>
|
|
|
|
|
Effect.sync(() => {
|
|
|
|
|
get.set(uploadFormAtom, { ...get(uploadFormAtom), uploading: false, progress: null });
|
|
|
|
|
get.set(pushNotificationAtom, {
|
|
|
|
|
type: "error",
|
|
|
|
|
title: "Upload failed",
|
|
|
|
|
description: errorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
{ reactivityKeys: ["library"] },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const removeDocumentAtom = commandAtom<string, void>("removeDocument", Effect.fn("trustgraph.workbench.removeDocument")(function*(documentId, get, api) {
|
|
|
|
|
yield* withActivity(
|
|
|
|
|
get,
|
|
|
|
|
"Remove document",
|
2026-06-11 06:29:29 -05:00
|
|
|
api.librarian().removeDocument(documentId, get(settingsAtom).collection).pipe(
|
2026-06-01 16:22:25 -05:00
|
|
|
Effect.tap(() => Effect.sync(() => {
|
|
|
|
|
get.refresh(libraryDocumentsAtom);
|
|
|
|
|
get.refresh(libraryProcessingAtom);
|
|
|
|
|
get.set(pushNotificationAtom, { type: "success", title: "Document deleted" });
|
|
|
|
|
})),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}), { reactivityKeys: ["library"] });
|
|
|
|
|
|
|
|
|
|
export const loadKgCoreAtom = commandAtom<string, void>("loadKgCore", Effect.fn("trustgraph.workbench.loadKgCore")(function*(id, get, api) {
|
|
|
|
|
get.set(activeActionAtom, id);
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* api.knowledge().loadKgCore(id, get(flowIdAtom), get(settingsAtom).collection).pipe(
|
2026-06-01 16:22:25 -05:00
|
|
|
Effect.tap(() => Effect.sync(() => {
|
|
|
|
|
get.set(pushNotificationAtom, { type: "success", title: "Core loaded", description: id });
|
|
|
|
|
})),
|
|
|
|
|
Effect.ensuring(Effect.sync(() => get.set(activeActionAtom, null))),
|
|
|
|
|
);
|
|
|
|
|
}), { reactivityKeys: ["knowledge"] });
|
|
|
|
|
|
|
|
|
|
export const deleteKgCoreAtom = commandAtom<string, void>("deleteKgCore", Effect.fn("trustgraph.workbench.deleteKgCore")(function*(id, get, api) {
|
|
|
|
|
get.set(activeActionAtom, id);
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* api.knowledge().deleteKgCore(id, get(settingsAtom).collection).pipe(
|
2026-06-01 16:22:25 -05:00
|
|
|
Effect.tap(() => Effect.sync(() => {
|
|
|
|
|
get.refresh(kgCoresAtom);
|
|
|
|
|
get.set(pushNotificationAtom, { type: "success", title: "Core deleted", description: id });
|
|
|
|
|
})),
|
|
|
|
|
Effect.ensuring(Effect.sync(() => get.set(activeActionAtom, null))),
|
|
|
|
|
);
|
|
|
|
|
}), { reactivityKeys: ["knowledge"] });
|
|
|
|
|
|
|
|
|
|
export const createCollectionAtom = commandAtom<CollectionForm, void>("createCollection", Effect.fn("trustgraph.workbench.createCollection")(function*(form, get, api) {
|
|
|
|
|
const id = form.id.trim();
|
|
|
|
|
const tags = form.tags.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* api.collectionManagement().updateCollection(
|
2026-06-01 16:22:25 -05:00
|
|
|
id,
|
|
|
|
|
form.name.trim().length > 0 ? form.name.trim() : undefined,
|
|
|
|
|
form.description.trim().length > 0 ? form.description.trim() : undefined,
|
|
|
|
|
tags.length > 0 ? tags : undefined,
|
2026-06-11 06:29:29 -05:00
|
|
|
);
|
2026-06-01 16:22:25 -05:00
|
|
|
get.refresh(collectionsAtom);
|
|
|
|
|
get.set(pushNotificationAtom, {
|
|
|
|
|
type: "success",
|
|
|
|
|
title: "Collection created",
|
|
|
|
|
description: `"${form.name.trim() || id}" is now active.`,
|
|
|
|
|
});
|
|
|
|
|
}), { reactivityKeys: ["collections"] });
|
|
|
|
|
|
|
|
|
|
export const deleteCollectionAtom = commandAtom<string, void>("deleteCollection", Effect.fn("trustgraph.workbench.deleteCollection")(function*(id, get, api) {
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* api.collectionManagement().deleteCollection(id);
|
2026-06-01 16:22:25 -05:00
|
|
|
get.refresh(collectionsAtom);
|
|
|
|
|
const current = get(settingsAtom);
|
|
|
|
|
if (current.collection === id) {
|
|
|
|
|
get.set(settingsAtom, { ...current, collection: "default" });
|
|
|
|
|
}
|
|
|
|
|
get.set(pushNotificationAtom, { type: "success", title: "Collection deleted", description: id });
|
|
|
|
|
}), { reactivityKeys: ["collections"] });
|
|
|
|
|
|
2026-06-11 06:29:29 -05:00
|
|
|
export const submitMessageAtom = gatewayCommandAtom<{ input: string }, void>(
|
2026-06-01 16:22:25 -05:00
|
|
|
"submitMessage",
|
2026-06-01 23:19:54 -05:00
|
|
|
Effect.fn("trustgraph.workbench.submitMessage")(function*({ input }, get, api) {
|
2026-06-01 16:22:25 -05:00
|
|
|
const trimmed = input.trim();
|
|
|
|
|
if (trimmed.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const activityLabel = "Chat request";
|
|
|
|
|
const flowId = get(flowIdAtom);
|
|
|
|
|
const collection = withDefaultCollection(get(settingsAtom).collection);
|
|
|
|
|
const chatMode = get(conversationAtom).chatMode;
|
|
|
|
|
const flow = api.flow(flowId);
|
|
|
|
|
const explainEvents: ExplainEvent[] = [];
|
2026-06-01 23:19:54 -05:00
|
|
|
const requestId = yield* randomId("chat");
|
|
|
|
|
const timestamp = yield* Clock.currentTimeMillis;
|
2026-06-01 16:22:25 -05:00
|
|
|
const isActiveRequest = () => get(activeChatRequestAtom) === requestId;
|
|
|
|
|
const finishRequest = () => {
|
|
|
|
|
if (isActiveRequest()) {
|
|
|
|
|
get.set(activeChatRequestAtom, null);
|
|
|
|
|
setActivity(get, activityLabel, false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const userMsg: ChatMessage = {
|
2026-06-01 23:19:54 -05:00
|
|
|
id: yield* randomId("msg"),
|
2026-06-01 16:22:25 -05:00
|
|
|
role: "user",
|
|
|
|
|
content: input,
|
2026-06-01 23:19:54 -05:00
|
|
|
timestamp,
|
2026-06-01 16:22:25 -05:00
|
|
|
};
|
|
|
|
|
const assistantMsg: ChatMessage = {
|
2026-06-01 23:19:54 -05:00
|
|
|
id: yield* randomId("msg"),
|
2026-06-01 16:22:25 -05:00
|
|
|
role: "assistant",
|
|
|
|
|
content: "",
|
2026-06-01 23:19:54 -05:00
|
|
|
timestamp,
|
2026-06-01 16:22:25 -05:00
|
|
|
isStreaming: true,
|
|
|
|
|
...(chatMode === "agent"
|
|
|
|
|
? {
|
|
|
|
|
agentPhases: { think: "", observe: "", answer: "" },
|
|
|
|
|
activePhase: "think" as const,
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
updateConversation(get, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
input: "",
|
|
|
|
|
messages: [...current.messages, userMsg, assistantMsg],
|
|
|
|
|
}));
|
|
|
|
|
get.set(activeChatRequestAtom, requestId);
|
|
|
|
|
setActivity(get, activityLabel, true);
|
|
|
|
|
|
|
|
|
|
const attachExplainEvents = () => {
|
|
|
|
|
if (!isActiveRequest()) return;
|
|
|
|
|
if (explainEvents.length > 0) {
|
|
|
|
|
updateLastMessage(get, (previous) => ({ ...previous, explainEvents: [...explainEvents] }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onError = (message: string) => {
|
|
|
|
|
if (!isActiveRequest()) return;
|
|
|
|
|
updateLastMessage(get, (previous) =>
|
|
|
|
|
withoutActivePhase({
|
|
|
|
|
...previous,
|
|
|
|
|
content: previous.content.length > 0 ? previous.content : `Error: ${message}`,
|
|
|
|
|
isStreaming: false,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
finishRequest();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onChunk = (chunk: string, complete: boolean, metadata?: StreamingMetadata) => {
|
|
|
|
|
if (!isActiveRequest()) return;
|
|
|
|
|
updateLastMessage(get, (previous) => {
|
|
|
|
|
const next: ChatMessage = {
|
|
|
|
|
...previous,
|
|
|
|
|
content: previous.content + chunk,
|
|
|
|
|
isStreaming: !complete,
|
|
|
|
|
};
|
|
|
|
|
const finalMetadata = complete ? metadataFrom(metadata) : undefined;
|
|
|
|
|
return finalMetadata !== undefined ? { ...next, metadata: finalMetadata } : next;
|
|
|
|
|
});
|
|
|
|
|
if (complete) {
|
|
|
|
|
attachExplainEvents();
|
|
|
|
|
finishRequest();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onExplain = (event: ExplainEvent) => {
|
|
|
|
|
if (!isActiveRequest()) return;
|
|
|
|
|
explainEvents.push(event);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-11 06:29:29 -05:00
|
|
|
yield* Match.value(chatMode).pipe(
|
|
|
|
|
Match.when("graph-rag", () =>
|
|
|
|
|
flow.graphRagStreaming(trimmed, onChunk, onError, undefined, collection, onExplain)
|
|
|
|
|
),
|
|
|
|
|
Match.when("document-rag", () =>
|
|
|
|
|
flow.documentRagStreaming(trimmed, onChunk, onError, undefined, collection, onExplain)
|
|
|
|
|
),
|
|
|
|
|
Match.when("agent", () =>
|
2026-06-01 16:22:25 -05:00
|
|
|
flow.agent(
|
|
|
|
|
trimmed,
|
|
|
|
|
(chunk, complete) => {
|
|
|
|
|
if (!isActiveRequest()) return;
|
|
|
|
|
updateLastMessage(get, (previous) => {
|
|
|
|
|
const phases = previous.agentPhases ?? { think: "", observe: "", answer: "" };
|
|
|
|
|
return {
|
|
|
|
|
...previous,
|
|
|
|
|
agentPhases: { ...phases, think: phases.think + chunk },
|
|
|
|
|
...(complete ? {} : { activePhase: "think" as const }),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
(chunk, complete) => {
|
|
|
|
|
if (!isActiveRequest()) return;
|
|
|
|
|
updateLastMessage(get, (previous) => {
|
|
|
|
|
const phases = previous.agentPhases ?? { think: "", observe: "", answer: "" };
|
|
|
|
|
return {
|
|
|
|
|
...previous,
|
|
|
|
|
agentPhases: { ...phases, observe: phases.observe + chunk },
|
|
|
|
|
...(complete ? {} : { activePhase: "observe" as const }),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
(chunk, complete, metadata) => {
|
|
|
|
|
if (!isActiveRequest()) return;
|
|
|
|
|
updateLastMessage(get, (previous) => {
|
|
|
|
|
const phases = previous.agentPhases ?? { think: "", observe: "", answer: "" };
|
|
|
|
|
const answer = phases.answer + chunk;
|
|
|
|
|
const next: ChatMessage = {
|
|
|
|
|
...previous,
|
|
|
|
|
content: answer,
|
|
|
|
|
agentPhases: { ...phases, answer },
|
|
|
|
|
...(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();
|
|
|
|
|
finishRequest();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onError,
|
|
|
|
|
onExplain,
|
|
|
|
|
collection,
|
2026-06-11 06:29:29 -05:00
|
|
|
)
|
|
|
|
|
),
|
2026-06-04 06:07:20 -05:00
|
|
|
Match.exhaustive,
|
|
|
|
|
);
|
2026-06-01 23:19:54 -05:00
|
|
|
}),
|
2026-06-01 16:22:25 -05:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const cancelChatAtom = Atom.writable(
|
|
|
|
|
() => null,
|
|
|
|
|
(ctx) => {
|
|
|
|
|
const current = ctx.get(conversationAtom);
|
|
|
|
|
const last = current.messages[current.messages.length - 1];
|
|
|
|
|
if (last === undefined || last.role !== "assistant" || last.isStreaming !== true) return;
|
|
|
|
|
ctx.set(conversationAtom, {
|
|
|
|
|
...current,
|
|
|
|
|
messages: [
|
|
|
|
|
...current.messages.slice(0, -1),
|
|
|
|
|
withoutActivePhase({
|
|
|
|
|
...last,
|
|
|
|
|
content: last.content.length > 0 ? last.content : "(Cancelled)",
|
|
|
|
|
isStreaming: false,
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
ctx.set(activeChatRequestAtom, null);
|
|
|
|
|
ctx.set(progressActivitiesAtom, ctx.get(progressActivitiesAtom).filter((label) => label !== "Chat request"));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const regenerateLastMessageAtom = commandAtom<void, void>(
|
|
|
|
|
"regenerateLastMessage",
|
|
|
|
|
Effect.fn("trustgraph.workbench.regenerateLastMessage")((_void, get) => Effect.sync(() => {
|
|
|
|
|
const current = get(conversationAtom);
|
|
|
|
|
const lastUser = [...current.messages].reverse().find((message) => message.role === "user");
|
|
|
|
|
if (lastUser === undefined) return;
|
|
|
|
|
get.set(conversationAtom, {
|
|
|
|
|
...current,
|
|
|
|
|
messages: current.messages.filter((message) => message.isStreaming !== true),
|
|
|
|
|
input: lastUser.content,
|
|
|
|
|
});
|
|
|
|
|
get.set(activeChatRequestAtom, null);
|
|
|
|
|
get.set(submitMessageAtom, { input: lastUser.content });
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export function optionGet<A>(option: Option.Option<A>): A | undefined {
|
|
|
|
|
return Option.getOrUndefined(option);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type { AsyncResult, Term, Triple };
|